feat: codex app-server integration, token sync fix, typing fix

- Rewrite codex-runner to use `codex app-server` JSON-RPC instead of
  `codex exec`. Supports streaming (item/agentMessage/delta), session
  persistence (thread/start, thread/resume), and mid-turn message
  injection (turn/steer with IPC polling during execution).
- Fix token refresh not syncing to per-group session directories,
  causing 401 errors on running agents.
- Fix typing indicator staying on when codex returns null result
  by always clearing typing on any streaming callback.
- Update README and CLAUDE.md to reflect dual-service architecture,
  host process mode, and codex app-server integration.
This commit is contained in:
Eyejoker
2026-03-13 16:03:08 +09:00
parent 0515edc93d
commit 35277cea0a
6 changed files with 584 additions and 290 deletions

View File

@@ -1,25 +1,27 @@
# NanoClaw # NanoClaw
Personal Claude assistant. See [README.md](README.md) for philosophy and setup. See [docs/REQUIREMENTS.md](docs/REQUIREMENTS.md) for architecture decisions. Dual-agent AI assistant (Claude Code + Codex) over Discord. Fork of [qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw).
## Quick Context ## Quick Context
Single Node.js process with skill-based channel system. Channels (WhatsApp, Telegram, Slack, Discord, Gmail) are skills that self-register at startup. Messages route to Claude Agent SDK running in containers (Linux VMs). Each group has isolated filesystem and memory. Two systemd services (`nanoclaw`, `nanoclaw-codex`) share the same codebase but run with separate stores, data, and groups. Agents run as direct host processes (no containers). Claude Code uses the Agent SDK; Codex uses `codex app-server` via JSON-RPC. OAuth tokens auto-refresh and sync to per-group session dirs.
## Key Files ## Key Files
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `src/index.ts` | Orchestrator: state, message loop, agent invocation | | `src/index.ts` | Orchestrator: state, message loop, agent invocation |
| `src/channels/registry.ts` | Channel registry (self-registration at startup) | | `src/container-runner.ts` | Spawns agent processes, manages env/sessions/MCP |
| `src/token-refresh.ts` | OAuth auto-refresh + session directory sync |
| `src/channels/discord.ts` | Discord channel (8s typing refresh) |
| `src/ipc.ts` | IPC watcher and task processing | | `src/ipc.ts` | IPC watcher and task processing |
| `src/router.ts` | Message formatting and outbound routing | | `src/router.ts` | Message formatting and outbound routing |
| `src/config.ts` | Trigger pattern, paths, intervals | | `src/config.ts` | Trigger pattern, paths, intervals |
| `src/container-runner.ts` | Spawns agent containers with mounts |
| `src/task-scheduler.ts` | Runs scheduled tasks | | `src/task-scheduler.ts` | Runs scheduled tasks |
| `src/db.ts` | SQLite operations | | `src/db.ts` | SQLite operations |
| `container/agent-runner/` | Claude Code runner (Agent SDK) |
| `container/codex-runner/` | Codex runner (app-server JSON-RPC, streaming, turn/steer) |
| `groups/{name}/CLAUDE.md` | Per-group memory (isolated) | | `groups/{name}/CLAUDE.md` | Per-group memory (isolated) |
| `container/skills/agent-browser.md` | Browser automation tool (available to all agents via Bash) |
## Skills ## Skills
@@ -37,28 +39,33 @@ Single Node.js process with skill-based channel system. Channels (WhatsApp, Tele
Run commands directly—don't tell the user to run them. Run commands directly—don't tell the user to run them.
```bash ```bash
npm run dev # Run with hot reload npm run build # Build main project
npm run build # Compile TypeScript cd container/agent-runner && npm run build # Build Claude runner
./container/build.sh # Rebuild agent container cd container/codex-runner && npm run build # Build Codex runner
npm run dev # Dev mode with hot reload
``` ```
Service management: Service management (Linux):
```bash ```bash
# macOS (launchd) systemctl --user restart nanoclaw nanoclaw-codex # Restart both
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist systemctl --user status nanoclaw # Check status
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist journalctl --user -u nanoclaw -f # Follow logs
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # restart
# Linux (systemd)
systemctl --user start nanoclaw
systemctl --user stop nanoclaw
systemctl --user restart nanoclaw
``` ```
## Troubleshooting Deploy to server: `scp dist/*.js clone-ej@100.64.185.108:~/nanoclaw/dist/`
**WhatsApp not connecting after upgrade:** WhatsApp is now a separate channel fork, not bundled in core. Run `/add-whatsapp` (or `git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git && git fetch whatsapp main && git merge whatsapp/main && npm run build`) to install it. Existing auth credentials and groups are preserved. ## Dual-Service Architecture
## Container Build Cache - `nanoclaw.service` — Claude Code bot (`@claude`), uses `store/`, `data/`, `groups/`
- `nanoclaw-codex.service` — Codex bot (`@codex`), uses `store-codex/`, `data-codex/`, `groups-codex/`
- Both share the same codebase (`dist/index.js`), differentiated by env vars (`NANOCLAW_STORE_DIR`, etc.)
- Channel registration is per-service DB (`registered_groups` table)
The container buildkit caches the build context aggressively. `--no-cache` alone does NOT invalidate COPY steps — the builder's volume retains stale files. To force a truly clean rebuild, prune the builder then re-run `./container/build.sh`. ## Codex App-Server
Codex runner uses `codex app-server` JSON-RPC (not `codex exec`):
- `thread/start` / `thread/resume` for session persistence (threadId-based)
- `turn/start` for streaming responses (`item/agentMessage/delta`)
- `turn/steer` for mid-execution message injection (IPC polling during turn)
- `approvalPolicy: "never"` + `sandbox: "danger-full-access"` for bypass
- Per-group: model (`CODEX_MODEL`), effort (`CODEX_EFFORT`), MCP servers via `config.toml`

269
README.md
View File

@@ -3,211 +3,148 @@
</p> </p>
<p align="center"> <p align="center">
An AI assistant that runs agents securely in their own containers. Lightweight, built to be easily understood and completely customized for your needs. Dual-agent AI assistant running Claude Code + Codex as parallel services over Discord.
</p> </p>
<p align="center"> <p align="center">
<a href="https://nanoclaw.dev">nanoclaw.dev</a>&nbsp; • &nbsp; Fork of <a href="https://github.com/qwibitai/nanoclaw">qwibitai/nanoclaw</a>
<a href="README_zh.md">中文</a>&nbsp; • &nbsp;
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp; • &nbsp;
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="34.9k tokens, 17% of context window" valign="middle"></a>
</p> </p>
Using Claude Code, NanoClaw can dynamically rewrite its code to customize its feature set for your needs.
**New:** First AI assistant to support [Agent Swarms](https://code.claude.com/docs/en/agent-teams). Spin up teams of agents that collaborate in your chat. ## Overview
## Why I Built NanoClaw This fork runs two independent NanoClaw instances as systemd services:
[OpenClaw](https://github.com/openclaw/openclaw) is an impressive project, but I wouldn't have been able to sleep if I had given complex software I didn't understand full access to my life. OpenClaw has nearly half a million lines of code, 53 config files, and 70+ dependencies. Its security is at the application level (allowlists, pairing codes) rather than true OS-level isolation. Everything runs in one Node process with shared memory. - **nanoclaw** (Claude Code) — powered by Claude Agent SDK, trigger `@claude`
- **nanoclaw-codex** (Codex) — powered by Codex app-server JSON-RPC, trigger `@codex`
NanoClaw provides that same core functionality, but in a codebase small enough to understand: one process and a handful of files. Claude agents run in their own Linux containers with filesystem isolation, not merely behind permission checks. Each service has its own store, data, and groups directories. Discord channels can be registered with either or both bots.
## Quick Start ## Key Differences from Upstream
```bash | Area | Upstream NanoClaw | This Fork |
gh repo fork qwibitai/nanoclaw --clone |------|-------------------|-----------|
cd nanoclaw | Agent runtime | Container-isolated (Docker/Apple Container) | Direct host processes (no containers) |
claude | Agent backends | Claude Code only | Claude Code + OpenAI Codex |
``` | Codex integration | N/A | `codex app-server` (JSON-RPC, streaming, `turn/steer`) |
| Session management | Container-based | Per-group `CLAUDE_CONFIG_DIR` / `CODEX_HOME` |
<details> | Token management | Manual | Auto-refresh with session sync |
<summary>Without GitHub CLI</summary> | Channel | Multi-channel (WhatsApp, Telegram, etc.) | Discord-focused |
| Deployment | Single service | Dual systemd services |
1. Fork [qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw) on GitHub (click the Fork button)
2. `git clone https://github.com/<your-username>/nanoclaw.git`
3. `cd nanoclaw`
4. `claude`
</details>
Then run `/setup`. Claude Code handles everything: dependencies, authentication, container setup and service configuration.
> **Note:** Commands prefixed with `/` (like `/setup`, `/add-whatsapp`) are [Claude Code skills](https://code.claude.com/docs/en/skills). Type them inside the `claude` CLI prompt, not in your regular terminal. If you don't have Claude Code installed, get it at [claude.com/product/claude-code](https://claude.com/product/claude-code).
## Philosophy
**Small enough to understand.** One process, a few source files and no microservices. If you want to understand the full NanoClaw codebase, just ask Claude Code to walk you through it.
**Secure by isolation.** Agents run in Linux containers (Apple Container on macOS, or Docker) and they can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your host.
**Built for the individual user.** NanoClaw isn't a monolithic framework; it's software that fits each user's exact needs. Instead of becoming bloatware, NanoClaw is designed to be bespoke. You make your own fork and have Claude Code modify it to match your needs.
**Customization = code changes.** No configuration sprawl. Want different behavior? Modify the code. The codebase is small enough that it's safe to make changes.
**AI-native.**
- No installation wizard; Claude Code guides setup.
- No monitoring dashboard; ask Claude what's happening.
- No debugging tools; describe the problem and Claude fixes it.
**Skills over features.** Instead of adding features (e.g. support for Telegram) to the codebase, contributors submit [claude code skills](https://code.claude.com/docs/en/skills) like `/add-telegram` that transform your fork. You end up with clean code that does exactly what you need.
**Best harness, best model.** NanoClaw runs on the Claude Agent SDK, which means you're running Claude Code directly. Claude Code is highly capable and its coding and problem-solving capabilities allow it to modify and expand NanoClaw and tailor it to each user.
## What It Supports
- **Multi-channel messaging** - Talk to your assistant from WhatsApp, Telegram, Discord, Slack, or Gmail. Add channels with skills like `/add-whatsapp` or `/add-telegram`. Run one or many at the same time.
- **Isolated group context** - Each group has its own `CLAUDE.md` memory, isolated filesystem, and runs in its own container sandbox with only that filesystem mounted to it.
- **Main channel** - Your private channel (self-chat) for admin control; every group is completely isolated
- **Scheduled tasks** - Recurring jobs that run Claude and can message you back
- **Web access** - Search and fetch content from the Web
- **Container isolation** - Agents are sandboxed in Apple Container (macOS) or Docker (macOS/Linux)
- **Agent Swarms** - Spin up teams of specialized agents that collaborate on complex tasks. NanoClaw is the first personal AI assistant to support agent swarms.
- **Optional integrations** - Add Gmail (`/add-gmail`) and more via skills
## Usage
Talk to your assistant with the trigger word (default: `@Andy`):
```
@Andy send an overview of the sales pipeline every weekday morning at 9am (has access to my Obsidian vault folder)
@Andy review the git history for the past week each Friday and update the README if there's drift
@Andy every Monday at 8am, compile news on AI developments from Hacker News and TechCrunch and message me a briefing
```
From the main channel (your self-chat), you can manage groups and tasks:
```
@Andy list all scheduled tasks across groups
@Andy pause the Monday briefing task
@Andy join the Family Chat group
```
## Customizing
NanoClaw doesn't use configuration files. To make changes, just tell Claude Code what you want:
- "Change the trigger word to @Bob"
- "Remember in the future to make responses shorter and more direct"
- "Add a custom greeting when I say good morning"
- "Store conversation summaries weekly"
Or run `/customize` for guided changes.
The codebase is small enough that Claude can safely modify it.
## Contributing
**Don't add features. Add skills.**
If you want to add Telegram support, don't create a PR that adds Telegram to the core codebase. Instead, fork NanoClaw, make the code changes on a branch, and open a PR. We'll create a `skill/telegram` branch from your PR that other users can merge into their fork.
Users then run `/add-telegram` on their fork and get clean code that does exactly what they need, not a bloated system trying to support every use case.
### RFS (Request for Skills)
Skills we'd like to see:
**Communication Channels**
- `/add-signal` - Add Signal as a channel
**Session Management**
- `/clear` - Add a `/clear` command that compacts the conversation (summarizes context while preserving critical information in the same session). Requires figuring out how to trigger compaction programmatically via the Claude Agent SDK.
## Requirements
- macOS or Linux
- Node.js 20+
- [Claude Code](https://claude.ai/download)
- [Apple Container](https://github.com/apple/container) (macOS) or [Docker](https://docker.com/products/docker-desktop) (macOS/Linux)
## Architecture ## Architecture
``` ```
Channels --> SQLite --> Polling loop --> Container (Claude Agent SDK) --> Response Discord ──► SQLite ──► Polling Loop ──┬──► Claude Agent SDK (host process)
└──► Codex App-Server (JSON-RPC stdio)
├── thread/start, thread/resume
├── turn/start (streaming)
├── turn/steer (mid-turn injection)
└── Auto-approval (bypass sandbox)
``` ```
Single Node.js process. Channels are added via skills and self-register at startup — the orchestrator connects whichever ones have credentials present. Agents execute in isolated Linux containers with filesystem isolation. Only mounted directories are accessible. Per-group message queue with concurrency control. IPC via filesystem. ### Directory Layout
For the full architecture details, see [docs/SPEC.md](docs/SPEC.md). ```
nanoclaw/
├── src/ # Core source
│ ├── index.ts # Orchestrator: state, message loop, agent invocation
│ ├── container-runner.ts # Spawns agent processes, manages env/sessions
│ ├── token-refresh.ts # OAuth auto-refresh + session directory sync
│ ├── channels/discord.ts # Discord channel implementation
│ ├── db.ts # SQLite operations
│ ├── ipc.ts # IPC watcher and task processing
│ ├── task-scheduler.ts # Scheduled tasks (cron/interval/once)
│ └── config.ts # Paths, intervals, trigger patterns
├── container/
│ ├── agent-runner/ # Claude Code runner (Agent SDK)
│ ├── codex-runner/ # Codex runner (app-server JSON-RPC)
│ └── skills/ # Shared agent skills (browser, etc.)
├── store/ # Claude Code service DB
├── store-codex/ # Codex service DB
├── data/sessions/ # Per-group Claude sessions (.claude/)
├── data-codex/sessions/ # Per-group Codex sessions (.codex/)
├── groups/ # Per-group memory (Claude Code)
├── groups-codex/ # Per-group memory (Codex)
└── logs/ # Service logs
```
Key files: ### Codex App-Server Integration
- `src/index.ts` - Orchestrator: state, message loop, agent invocation
- `src/channels/registry.ts` - Channel registry (self-registration at startup)
- `src/ipc.ts` - IPC watcher and task processing
- `src/router.ts` - Message formatting and outbound routing
- `src/group-queue.ts` - Per-group queue with global concurrency limit
- `src/container-runner.ts` - Spawns streaming agent containers
- `src/task-scheduler.ts` - Runs scheduled tasks
- `src/db.ts` - SQLite operations (messages, groups, sessions, state)
- `groups/*/CLAUDE.md` - Per-group memory
## FAQ The Codex runner (`container/codex-runner/`) communicates with `codex app-server` via JSON-RPC over stdio:
**Why Docker?** - **Session persistence**: Thread IDs stored in DB, sessions saved as JSONL on disk
- **Streaming**: `item/agentMessage/delta` notifications for real-time text
- **Mid-turn steering**: IPC messages injected via `turn/steer` during execution
- **Auto-approval**: `approvalPolicy: "never"` + `sandbox: "danger-full-access"`
- **Per-group config**: Model, effort, MCP servers configured per channel
Docker provides cross-platform support (macOS, Linux and even Windows via WSL2) and a mature ecosystem. On macOS, you can optionally switch to Apple Container via `/convert-to-apple-container` for a lighter-weight native runtime. ### OAuth Token Auto-Refresh
**Can I run this on Linux?** `src/token-refresh.ts` handles Claude Code OAuth token lifecycle:
Yes. Docker is the default runtime and works on both macOS and Linux. Just run `/setup`. - Checks every 5 minutes, refreshes 30 minutes before expiry
- Tries `platform.claude.com` then falls back to `api.anthropic.com`
- Syncs refreshed credentials to all per-group session directories
- Solves the known headless environment token expiry issue
**Is this secure?** ## Setup
Agents run in containers, not behind application-level permission checks. They can only access explicitly mounted directories. You should still review what you're running, but the codebase is small enough that you actually can. See [docs/SECURITY.md](docs/SECURITY.md) for the full security model. ### Prerequisites
**Why no configuration files?** - Linux (Ubuntu 22.04+) or macOS
- Node.js 20+
- [Claude Code CLI](https://claude.ai/download)
- [Codex CLI](https://github.com/openai/codex) (`npm install -g @openai/codex`)
We don't want configuration sprawl. Every user should customize NanoClaw so that the code does exactly what they want, rather than configuring a generic system. If you prefer having config files, you can tell Claude to add them. ### Environment Variables
**Can I use third-party or open-source models?**
Yes. NanoClaw supports any Claude API-compatible model endpoint. Set these environment variables in your `.env` file:
```bash ```bash
ANTHROPIC_BASE_URL=https://your-api-endpoint.com # .env
ANTHROPIC_AUTH_TOKEN=your-token-here DISCORD_BOT_TOKEN= # Claude Code bot token
DISCORD_CODEX_BOT_TOKEN= # Codex bot token (optional, for dual-bot)
ANTHROPIC_API_KEY= # Or use OAuth (CLAUDE_CODE_OAUTH_TOKEN)
OPENAI_API_KEY= # For Codex
CODEX_MODEL= # Default codex model
CODEX_EFFORT= # Default reasoning effort (low/medium/high)
``` ```
This allows you to use: ### Service Management (Linux)
- Local models via [Ollama](https://ollama.ai) with an API proxy
- Open-source models hosted on [Together AI](https://together.ai), [Fireworks](https://fireworks.ai), etc.
- Custom model deployments with Anthropic-compatible APIs
Note: The model must support the Anthropic API format for best compatibility. ```bash
systemctl --user start nanoclaw # Claude Code service
systemctl --user start nanoclaw-codex # Codex service
systemctl --user restart nanoclaw nanoclaw-codex # Restart both
journalctl --user -u nanoclaw -f # Follow logs
```
**How do I debug issues?** ### Service Management (macOS)
Ask Claude Code. "Why isn't the scheduler running?" "What's in the recent logs?" "Why did this message not get a response?" That's the AI-native approach that underlies NanoClaw. ```bash
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl load ~/Library/LaunchAgents/com.nanoclaw-codex.plist
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```
**Why isn't the setup working for me?** ## Channel Registration
If you have issues, during setup, Claude will try to dynamically fix them. If that doesn't work, run `claude`, then run `/debug`. If Claude finds an issue that is likely affecting other users, open a PR to modify the setup SKILL.md. Channels are registered in each service's SQLite database (`registered_groups` table). Each entry specifies:
**What changes will be accepted into the codebase?** - **jid**: Discord channel ID (`dc:<channel_id>`)
- **folder**: Group folder name (matches Discord channel name)
- **trigger_pattern**: Regex for bot activation (`@claude` or `@codex`)
- **agent_type**: `claude-code` or `codex`
- **work_dir**: Working directory for the agent
- **container_config**: JSON config (e.g., `{"codexEffort":"high"}`)
Only security fixes, bug fixes, and clear improvements will be accepted to the base configuration. That's all. ## Development
Everything else (new capabilities, OS compatibility, hardware support, enhancements) should be contributed as skills. ```bash
npm run build # Build main project
This keeps the base system minimal and lets every user customize their installation without inheriting features they don't want. cd container/agent-runner && npm run build # Build Claude runner
cd container/codex-runner && npm run build # Build Codex runner
## Community npm run dev # Dev mode with hot reload
```
Questions? Ideas? [Join the Discord](https://discord.gg/VDdww8qS42).
## Changelog
See [CHANGELOG.md](CHANGELOG.md) for breaking changes and migration notes.
## License ## License
MIT MIT — Fork of [qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw)

View File

@@ -1,11 +1,14 @@
/** /**
* NanoClaw Codex Runner * NanoClaw Codex Runner (app-server mode)
* Runs inside a container, receives config via stdin, executes Codex CLI, outputs result to stdout *
* Spawns a single `codex app-server` process and communicates via JSON-RPC
* over stdio. Supports streaming responses, session persistence (threadId),
* and mid-turn message injection via turn/steer.
* *
* Input protocol: * Input protocol:
* Stdin: Full ContainerInput JSON (read until EOF) * Stdin: Full ContainerInput JSON (read until EOF)
* IPC: Follow-up messages written as JSON files to /workspace/ipc/input/ * IPC: Follow-up messages as JSON files in $NANOCLAW_IPC_DIR/input/
* Sentinel: /workspace/ipc/input/_close — signals session end * Sentinel: _close — signals session end
* *
* Stdout protocol: * Stdout protocol:
* Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs. * Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs.
@@ -14,10 +17,13 @@
import { spawn, ChildProcess } from 'child_process'; import { spawn, ChildProcess } from 'child_process';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import readline from 'readline';
// ── Types ──────────────────────────────────────────────────────────
interface ContainerInput { interface ContainerInput {
prompt: string; prompt: string;
sessionId?: string; sessionId?: string; // threadId from previous session
groupFolder: string; groupFolder: string;
chatJid: string; chatJid: string;
isMain: boolean; isMain: boolean;
@@ -33,10 +39,24 @@ interface ContainerOutput {
error?: string; error?: string;
} }
// Paths configurable via env vars (defaults to container paths for backwards compat) interface JsonRpcRequest {
method: string;
id?: number;
params?: Record<string, unknown>;
}
interface JsonRpcResponse {
id?: number;
method?: string;
result?: Record<string, unknown>;
error?: { code: number; message: string };
params?: Record<string, unknown>;
}
// ── Constants ──────────────────────────────────────────────────────
const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group'; const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group';
const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc'; const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc';
// Optional: override cwd (agent works in this directory instead of GROUP_DIR)
const WORK_DIR = process.env.NANOCLAW_WORK_DIR || ''; const WORK_DIR = process.env.NANOCLAW_WORK_DIR || '';
const IPC_INPUT_DIR = path.join(IPC_DIR, 'input'); const IPC_INPUT_DIR = path.join(IPC_DIR, 'input');
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close'); const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
@@ -46,6 +66,12 @@ const MAX_TURNS = 100;
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
const EFFECTIVE_CWD = WORK_DIR || GROUP_DIR;
const CODEX_MODEL = process.env.CODEX_MODEL || '';
const CODEX_EFFORT = process.env.CODEX_EFFORT || '';
// ── Helpers ────────────────────────────────────────────────────────
function writeOutput(output: ContainerOutput): void { function writeOutput(output: ContainerOutput): void {
console.log(OUTPUT_START_MARKER); console.log(OUTPUT_START_MARKER);
console.log(JSON.stringify(output)); console.log(JSON.stringify(output));
@@ -120,89 +146,387 @@ function waitForIpcMessage(): Promise<string | null> {
}); });
} }
/** // ── App-Server Client ──────────────────────────────────────────────
* Run a Codex CLI exec command and return the result.
* If resume=true, resumes the most recent session instead of starting fresh.
*/
async function runCodexExec(prompt: string, resume: boolean): Promise<{ result: string; error?: string }> {
return new Promise((resolve) => {
const args: string[] = [];
const effectiveCwd = WORK_DIR || GROUP_DIR;
const codexModel = process.env.CODEX_MODEL || '';
const codexEffort = process.env.CODEX_EFFORT || '';
if (resume) { class CodexAppServer {
args.push( private proc: ChildProcess;
'exec', 'resume', '--last', private rl: readline.Interface;
'--dangerously-bypass-approvals-and-sandbox', private nextId = 1;
'--skip-git-repo-check', private pending = new Map<number, {
prompt, resolve: (value: JsonRpcResponse) => void;
); reject: (err: Error) => void;
} else { }>();
args.push( private notificationHandler: ((msg: JsonRpcResponse) => void) | null = null;
'exec', private serverRequestHandler: ((msg: JsonRpcResponse) => void) | null = null;
'--dangerously-bypass-approvals-and-sandbox',
'-C', effectiveCwd,
'--skip-git-repo-check',
'--color', 'never',
);
if (codexModel) args.push('-m', codexModel);
if (codexEffort) args.push('-c', `model_reasoning_effort=${codexEffort}`);
args.push(prompt);
}
log(`Running: codex ${args.slice(0, 6).join(' ')}... (resume=${resume})`); constructor() {
this.proc = spawn('codex', ['app-server'], {
const codex: ChildProcess = spawn('codex', args, {
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
cwd: effectiveCwd, cwd: EFFECTIVE_CWD,
env: { ...process.env }, env: { ...process.env },
}); });
let stdout = ''; this.rl = readline.createInterface({ input: this.proc.stdout! });
let stderr = '';
codex.stdout?.on('data', (data: Buffer) => { this.rl.on('line', (line: string) => {
stdout += data.toString(); if (!line.trim()) return;
try {
const msg: JsonRpcResponse = JSON.parse(line);
this.handleMessage(msg);
} catch {
// Non-JSON output, ignore
}
}); });
codex.stderr?.on('data', (data: Buffer) => { this.proc.stderr?.on('data', (data: Buffer) => {
const chunk = data.toString(); for (const line of data.toString().trim().split('\n')) {
stderr += chunk;
// Log stderr lines for debugging
for (const line of chunk.trim().split('\n')) {
if (line) log(line); if (line) log(line);
} }
}); });
codex.on('close', (code: number | null) => { this.proc.on('error', (err: Error) => {
log(`Codex exited with code ${code}`); log(`App-server spawn error: ${err.message}`);
// Reject all pending requests
for (const [, { reject }] of this.pending) {
reject(err);
}
this.pending.clear();
});
if (code !== 0) { this.proc.on('close', (code: number | null) => {
log(`App-server exited with code ${code}`);
const err = new Error(`App-server exited with code ${code}`);
for (const [, { reject }] of this.pending) {
reject(err);
}
this.pending.clear();
});
}
private handleMessage(msg: JsonRpcResponse): void {
// Response to a request we made
if (msg.id !== undefined && this.pending.has(msg.id)) {
const handler = this.pending.get(msg.id)!;
this.pending.delete(msg.id);
handler.resolve(msg);
return;
}
// Server-initiated request (has id + method) — needs a response
if (msg.id !== undefined && msg.method) {
this.serverRequestHandler?.(msg);
return;
}
// Notification (has method, no id)
if (msg.method) {
this.notificationHandler?.(msg);
}
}
setNotificationHandler(handler: ((msg: JsonRpcResponse) => void) | null): void {
this.notificationHandler = handler;
}
setServerRequestHandler(handler: ((msg: JsonRpcResponse) => void) | null): void {
this.serverRequestHandler = handler;
}
send(msg: JsonRpcRequest): void {
this.proc.stdin!.write(JSON.stringify(msg) + '\n');
}
async request(method: string, params: Record<string, unknown> = {}, timeoutMs = 30_000): Promise<JsonRpcResponse> {
const id = this.nextId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Request ${method} timed out after ${timeoutMs}ms`));
}, timeoutMs);
this.pending.set(id, {
resolve: (resp) => {
clearTimeout(timer);
resolve(resp);
},
reject: (err) => {
clearTimeout(timer);
reject(err);
},
});
this.send({ method, id, params });
});
}
respond(id: number, result: Record<string, unknown>): void {
this.proc.stdin!.write(JSON.stringify({ id, result }) + '\n');
}
async initialize(): Promise<void> {
const resp = await this.request('initialize', {
clientInfo: { name: 'nanoclaw', title: 'NanoClaw Codex', version: '1.0' },
capabilities: { experimentalApi: false },
}, 15_000);
if (resp.error) {
throw new Error(`Initialize failed: ${resp.error.message}`);
}
// Send initialized notification
this.send({ method: 'initialized' });
log('App-server initialized');
}
async startThread(): Promise<string> {
const params: Record<string, unknown> = {
cwd: EFFECTIVE_CWD,
approvalPolicy: 'never',
sandbox: 'danger-full-access',
experimentalRawEvents: false,
persistExtendedHistory: false,
};
if (CODEX_MODEL) params.model = CODEX_MODEL;
const resp = await this.request('thread/start', params, 30_000);
if (resp.error) {
throw new Error(`thread/start failed: ${resp.error.message}`);
}
const thread = resp.result?.thread as Record<string, unknown> | undefined;
const threadId = thread?.id as string;
log(`Thread started: ${threadId}`);
return threadId;
}
async resumeThread(threadId: string): Promise<string> {
const params: Record<string, unknown> = {
threadId,
cwd: EFFECTIVE_CWD,
approvalPolicy: 'never',
sandbox: 'danger-full-access',
persistExtendedHistory: false,
};
if (CODEX_MODEL) params.model = CODEX_MODEL;
const resp = await this.request('thread/resume', params, 30_000);
if (resp.error) {
// If resume fails (e.g., thread not found), start a new one
log(`thread/resume failed: ${resp.error.message}, starting new thread`);
return this.startThread();
}
const thread = resp.result?.thread as Record<string, unknown> | undefined;
const actualId = (thread?.id as string) || threadId;
log(`Thread resumed: ${actualId}`);
return actualId;
}
async startTurn(threadId: string, text: string): Promise<string> {
const params: Record<string, unknown> = {
threadId,
input: [{ type: 'text', text, text_elements: [] }],
};
if (CODEX_EFFORT) params.effort = CODEX_EFFORT;
const resp = await this.request('turn/start', params, 30_000);
if (resp.error) {
throw new Error(`turn/start failed: ${resp.error.message}`);
}
const turn = resp.result?.turn as Record<string, unknown> | undefined;
const turnId = turn?.id as string;
log(`Turn started: ${turnId}`);
return turnId;
}
async steerTurn(threadId: string, turnId: string, text: string): Promise<void> {
const resp = await this.request('turn/steer', {
threadId,
input: [{ type: 'text', text, text_elements: [] }],
expectedTurnId: turnId,
}, 10_000);
if (resp.error) {
log(`turn/steer failed: ${resp.error.message}`);
}
}
async interruptTurn(threadId: string, turnId: string): Promise<void> {
try {
await this.request('turn/interrupt', { threadId, turnId }, 10_000);
} catch (err) {
log(`turn/interrupt failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
kill(): void {
try {
this.proc.kill('SIGTERM');
setTimeout(() => {
if (!this.proc.killed) this.proc.kill('SIGKILL');
}, 5000);
} catch { /* ignore */ }
}
get alive(): boolean {
return !this.proc.killed && this.proc.exitCode === null;
}
}
// ── Turn Execution ─────────────────────────────────────────────────
/**
* Execute a turn and collect the agent's text response.
* While the turn is running, polls IPC for new messages and injects
* them via turn/steer (mid-execution message injection).
* Returns when turn/completed notification is received.
*/
async function executeTurn(
server: CodexAppServer,
threadId: string,
prompt: string,
): Promise<{ result: string; error?: string; turnId: string }> {
return new Promise((resolve) => {
let agentText = '';
let turnId = '';
let resolved = false;
let ipcPollTimer: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
server.setNotificationHandler(null);
server.setServerRequestHandler(null);
if (ipcPollTimer) {
clearTimeout(ipcPollTimer);
ipcPollTimer = null;
}
};
// Timeout safety (5 minutes per turn)
const timer = setTimeout(() => {
if (!resolved) {
resolved = true;
cleanup();
log('Turn execution timed out (5min)');
resolve({ resolve({
result: stdout.trim() || '', result: agentText || '',
error: `Codex exited with code ${code}: ${stderr.slice(-500)}`, error: 'Turn timed out after 5 minutes',
turnId,
}); });
}
}, 5 * 60 * 1000);
// IPC polling during turn — steer messages into the running turn
const pollIpcDuringTurn = () => {
if (resolved) return;
// Check close sentinel
if (shouldClose()) {
log('Close sentinel during turn, interrupting');
if (turnId) {
server.interruptTurn(threadId, turnId).catch(() => {});
}
return; return;
} }
// Extract the meaningful output // Check for new messages to steer
const result = stdout.trim(); const messages = drainIpcInput();
resolve({ result }); if (messages.length > 0 && turnId) {
const text = messages.join('\n');
log(`Steering message into turn (${text.length} chars)`);
server.steerTurn(threadId, turnId, text).catch((err) => {
log(`Steer failed: ${err instanceof Error ? err.message : String(err)}`);
});
}
ipcPollTimer = setTimeout(pollIpcDuringTurn, IPC_POLL_MS);
};
// Handle notifications (streaming events)
server.setNotificationHandler((msg) => {
if (resolved) return;
switch (msg.method) {
case 'item/agentMessage/delta': {
const delta = (msg.params as Record<string, unknown>)?.delta as string;
if (delta) agentText += delta;
break;
}
case 'item/completed': {
const item = (msg.params as Record<string, unknown>)?.item as Record<string, unknown>;
if (item?.type === 'agentMessage') {
// Use authoritative text from completed item
const text = item.text as string;
if (text) agentText = text;
}
break;
}
case 'turn/completed': {
const turn = (msg.params as Record<string, unknown>)?.turn as Record<string, unknown>;
const status = turn?.status as string;
const error = turn?.error as Record<string, unknown> | null;
clearTimeout(timer);
resolved = true;
cleanup();
if (status === 'failed') {
const errMsg = (error?.message as string) || 'Turn failed';
resolve({ result: agentText || '', error: errMsg, turnId });
} else {
resolve({ result: agentText, turnId });
}
break;
}
case 'turn/started': {
const turn = (msg.params as Record<string, unknown>)?.turn as Record<string, unknown>;
if (turn?.id) turnId = turn.id as string;
break;
}
}
}); });
codex.on('error', (err: Error) => { // Handle server requests (approval auto-accept)
resolve({ server.setServerRequestHandler((msg) => {
result: '', if (msg.id === undefined) return;
error: `Failed to spawn codex: ${err.message}`,
if (msg.method === 'item/commandExecution/requestApproval' ||
msg.method === 'item/fileChange/requestApproval' ||
msg.method === 'item/permissions/requestApproval') {
server.respond(msg.id, { decision: 'accept' });
return;
}
// Unknown server request — accept generically
server.respond(msg.id, {});
});
// Start IPC polling for mid-turn message injection
ipcPollTimer = setTimeout(pollIpcDuringTurn, IPC_POLL_MS);
// Start the turn
server.startTurn(threadId, prompt)
.then((id) => { turnId = id; })
.catch((err) => {
if (!resolved) {
clearTimeout(timer);
resolved = true;
cleanup();
resolve({
result: '',
error: `Failed to start turn: ${err.message}`,
turnId: '',
});
}
}); });
});
// Close stdin immediately since we pass prompt as argument
codex.stdin?.end();
}); });
} }
// ── Main ───────────────────────────────────────────────────────────
async function main(): Promise<void> { async function main(): Promise<void> {
let containerInput: ContainerInput; let containerInput: ContainerInput;
@@ -215,13 +539,12 @@ async function main(): Promise<void> {
writeOutput({ writeOutput({
status: 'error', status: 'error',
result: null, result: null,
error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}` error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}`,
}); });
process.exit(1); process.exit(1);
} }
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
// Clean up stale _close sentinel
try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ }
// Build initial prompt // Build initial prompt
@@ -234,57 +557,59 @@ async function main(): Promise<void> {
prompt += '\n' + pending.join('\n'); prompt += '\n' + pending.join('\n');
} }
// Query loop: run codex exec → wait for IPC message → repeat // Spawn app-server
// If we have a previous sessionId, resume instead of starting fresh. const server = new CodexAppServer();
// CODEX_HOME is per-group persistent, so `resume --last` picks up the right session.
const hasPreviousSession = !!containerInput.sessionId;
let isFirstExec = !hasPreviousSession;
let turnCount = 0;
if (hasPreviousSession) {
log(`Resuming previous session (sessionId: ${containerInput.sessionId})`);
}
try { try {
await server.initialize();
// Start or resume thread
const threadId = containerInput.sessionId
? await server.resumeThread(containerInput.sessionId)
: await server.startThread();
let turnCount = 0;
// Main turn loop
while (true) { while (true) {
turnCount++; turnCount++;
if (turnCount > MAX_TURNS) { if (turnCount > MAX_TURNS) {
log(`Turn limit reached (${MAX_TURNS}), exiting`); log(`Turn limit reached (${MAX_TURNS}), exiting`);
writeOutput({ status: 'success', result: '[세션 턴 제한 도달. 새 메시지로 다시 시작됩니다.]' }); writeOutput({
status: 'success',
result: '[세션 턴 제한 도달. 새 메시지로 다시 시작됩니다.]',
newSessionId: threadId,
});
break; break;
} }
log(`Starting codex exec (first=${isFirstExec}, turn=${turnCount}/${MAX_TURNS})...`);
const { result, error } = await runCodexExec(prompt, !isFirstExec); log(`Starting turn ${turnCount}/${MAX_TURNS}...`);
isFirstExec = false;
// Generate a session marker so nanoclaw tracks this codex session. const { result, error } = await executeTurn(server, threadId, prompt);
// On next spawn, containerInput.sessionId will be set → resume --last.
const sessionId = containerInput.sessionId || `codex-${containerInput.groupFolder}-${Date.now()}`;
if (error) { if (error) {
log(`Codex error: ${error}`); log(`Turn error: ${error}`);
writeOutput({ writeOutput({
status: 'error', status: 'error',
result: result || null, result: result || null,
newSessionId: sessionId, newSessionId: threadId,
error, error,
}); });
} else { } else {
writeOutput({ writeOutput({
status: 'success', status: 'success',
result: result || null, result: result || null,
newSessionId: sessionId, newSessionId: threadId,
}); });
} }
// Check if close was requested // Check close sentinel
if (shouldClose()) { if (shouldClose()) {
log('Close sentinel detected, exiting'); log('Close sentinel detected, exiting');
break; break;
} }
log('Codex exec done, waiting for next IPC message...'); log('Turn done, waiting for next IPC message...');
const nextMessage = await waitForIpcMessage(); const nextMessage = await waitForIpcMessage();
if (nextMessage === null) { if (nextMessage === null) {
@@ -292,7 +617,7 @@ async function main(): Promise<void> {
break; break;
} }
log(`Got new message (${nextMessage.length} chars), starting new codex exec`); log(`Got new message (${nextMessage.length} chars)`);
prompt = nextMessage; prompt = nextMessage;
} }
} catch (err) { } catch (err) {
@@ -303,7 +628,8 @@ async function main(): Promise<void> {
result: null, result: null,
error: errorMessage, error: errorMessage,
}); });
process.exit(1); } finally {
server.kill();
} }
} }

View File

@@ -275,10 +275,7 @@ function prepareGroupEnvironment(
? fs.readFileSync(configTomlPath, 'utf-8') ? fs.readFileSync(configTomlPath, 'utf-8')
: ''; : '';
// Remove existing nanoclaw MCP section if present (to refresh env vars) // Remove existing nanoclaw MCP section if present (to refresh env vars)
toml = toml.replace( toml = toml.replace(/\n?\[mcp_servers\.nanoclaw\][\s\S]*?(?=\n\[|$)/, '');
/\n?\[mcp_servers\.nanoclaw\][\s\S]*?(?=\n\[|$)/,
'',
);
const mcpSection = ` const mcpSection = `
[mcp_servers.nanoclaw] [mcp_servers.nanoclaw]
command = "node" command = "node"

View File

@@ -52,7 +52,10 @@ import {
isSessionCommandAllowed, isSessionCommandAllowed,
} from './session-commands.js'; } from './session-commands.js';
import { startSchedulerLoop } from './task-scheduler.js'; import { startSchedulerLoop } from './task-scheduler.js';
import { startTokenRefreshLoop, stopTokenRefreshLoop } from './token-refresh.js'; import {
startTokenRefreshLoop,
stopTokenRefreshLoop,
} from './token-refresh.js';
import { Channel, NewMessage, RegisteredGroup } from './types.js'; import { Channel, NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
@@ -257,10 +260,12 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
await channel.sendMessage(chatJid, text); await channel.sendMessage(chatJid, text);
outputSentToUser = true; outputSentToUser = true;
} }
await channel.setTyping?.(chatJid, false);
resetIdleTimer();
} }
// Always clear typing and reset idle timer on any output (including null results)
await channel.setTyping?.(chatJid, false);
resetIdleTimer();
if (result.status === 'success') { if (result.status === 'success') {
queue.notifyIdle(chatJid); queue.notifyIdle(chatJid);
} }

View File

@@ -12,6 +12,7 @@ import os from 'os';
import path from 'path'; import path from 'path';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { DATA_DIR } from './config.js';
const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token'; const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
const FALLBACK_TOKEN_URL = 'https://api.anthropic.com/v1/oauth/token'; const FALLBACK_TOKEN_URL = 'https://api.anthropic.com/v1/oauth/token';
@@ -68,6 +69,30 @@ function writeCredentials(creds: CredentialsFile): void {
const tempPath = `${credsPath}.tmp`; const tempPath = `${credsPath}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(creds, null, 2), { mode: 0o600 }); fs.writeFileSync(tempPath, JSON.stringify(creds, null, 2), { mode: 0o600 });
fs.renameSync(tempPath, credsPath); fs.renameSync(tempPath, credsPath);
// Sync to all per-group session directories so running agents pick up the new token
syncToSessionDirs(credsPath);
}
function syncToSessionDirs(credsPath: string): void {
const sessionsDir = path.join(DATA_DIR, 'sessions');
try {
if (!fs.existsSync(sessionsDir)) return;
const groups = fs.readdirSync(sessionsDir);
let synced = 0;
for (const group of groups) {
const dest = path.join(sessionsDir, group, '.claude', '.credentials.json');
if (fs.existsSync(path.dirname(dest))) {
fs.copyFileSync(credsPath, dest);
synced++;
}
}
if (synced > 0) {
logger.info({ count: synced }, 'Synced credentials to session directories');
}
} catch (err) {
logger.warn({ err: err instanceof Error ? err.message : String(err) }, 'Failed to sync credentials to sessions');
}
} }
async function refreshToken( async function refreshToken(
@@ -87,10 +112,7 @@ async function refreshToken(
for (const url of [TOKEN_URL, FALLBACK_TOKEN_URL]) { for (const url of [TOKEN_URL, FALLBACK_TOKEN_URL]) {
try { try {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout( const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
() => controller.abort(),
REQUEST_TIMEOUT_MS,
);
const res = await fetch(url, { const res = await fetch(url, {
method: 'POST', method: 'POST',