From b34e04665c8b3eea4905d4d7fa09f6fa1cf2130a Mon Sep 17 00:00:00 2001 From: Jonathan Katz Date: Thu, 21 May 2026 00:28:10 +0300 Subject: [PATCH 1/3] feat: add ACP server bridge (claw-acp) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `claw-acp`, a TypeScript/Bun script that exposes NanoClaw as an ACP (Agent Client Protocol) server so IDEs like WebStorm, Zed, and acpx can use NanoClaw as their AI backend via ACP JSON-RPC 2.0 over stdio. The bridge forwards prompts to NanoClaw's existing CLI Unix socket and returns responses through the ACP session — no host changes required. Supported methods: initialize, session/new, session/prompt, session/cancel, session/close, fs/read_text_file (with line/limit, sandboxed to $HOME). Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/add-acp-server/SKILL.md | 187 +++++++++ .../skills/add-acp-server/scripts/claw-acp.ts | 371 ++++++++++++++++++ 2 files changed, 558 insertions(+) create mode 100644 .claude/skills/add-acp-server/SKILL.md create mode 100644 .claude/skills/add-acp-server/scripts/claw-acp.ts diff --git a/.claude/skills/add-acp-server/SKILL.md b/.claude/skills/add-acp-server/SKILL.md new file mode 100644 index 00000000000..2b0c1201797 --- /dev/null +++ b/.claude/skills/add-acp-server/SKILL.md @@ -0,0 +1,187 @@ +--- +name: add-acp-server +description: Add ACP server mode — lets IDEs like Zed connect to NanoClaw as an AI agent via the Agent Client Protocol. +--- + +# add-acp-server — NanoClaw ACP Server Bridge + +`claw-acp` makes NanoClaw available as an AI backend for any IDE that supports the +[Agent Client Protocol](https://agentclientprotocol.com) (Zed, VS Code ACP extension, `acpx`). + +The IDE spawns `claw-acp` as a subprocess and communicates via ACP JSON-RPC 2.0 over stdio. +`claw-acp` bridges the requests to the running NanoClaw v2 host via the CLI Unix socket +and returns responses through the same ACP session. + +## Architecture + +``` +IDE (Zed / acpx) ─── ACP JSON-RPC 2.0 on stdio ───▶ claw-acp + │ + NanoClaw CLI socket + │ + NanoClaw v2 host → agent container +``` + +No NanoClaw source changes — `claw-acp` is a pure bridge on top of the existing CLI channel. + +## Prerequisites + +- NanoClaw v2 running (`systemctl --user start nanoclaw`) +- Bun installed (`bun --version`) +- The CLI channel wired to an agent group (set up via `/manage-channels` or `/init-first-agent`) + +## Install + +Run this skill from within the NanoClaw directory. + +### 1. Copy the script + +```bash +mkdir -p scripts +cp "${CLAUDE_SKILL_DIR}/scripts/claw-acp.ts" scripts/claw-acp.ts +chmod +x scripts/claw-acp.ts +``` + +### 2. Symlink into PATH + +```bash +mkdir -p ~/bin +ln -sf "$(pwd)/scripts/claw-acp.ts" ~/bin/claw-acp +``` + +Make sure `~/bin` is in PATH. Add to `~/.zshrc` or `~/.bashrc` if needed: + +```bash +export PATH="$HOME/bin:$PATH" +``` + +### 3. Verify + +```bash +claw-acp --help +``` + +## IDE Configuration + +### JetBrains (WebStorm, IntelliJ, PyCharm, …) + +Create `~/.jetbrains/acp.json`: + +```json +{ + "default_mcp_settings": {}, + "agent_servers": { + "NanoClaw": { + "command": "claw-acp", + "args": [], + "env": {} + } + } +} +``` + +Open AI Chat → click the agent dropdown → select **NanoClaw**. + +### Zed + +In `~/.config/zed/settings.json`: + +```json +{ + "agent_servers": { + "NanoClaw": { + "type": "custom", + "command": "claw-acp", + "args": [] + } + } +} +``` + +### acpx (Claude Code / Codex) + +In `~/.acpx/config.json`: + +```json +{ + "agents": { + "nanoclaw": { + "command": "claw-acp" + } + } +} +``` + +Then use it: + +```bash +acpx nanoclaw "Summarize the open issues" +``` + +## Usage + +```bash +# IDE spawns this automatically — or test manually: +claw-acp + +# Verbose mode (logs ACP traffic to stderr): +claw-acp -v + +# Override NanoClaw directory: +NANOCLAW_DIR=/path/to/nanoclaw claw-acp + +# Restrict which files the IDE can ask the agent to read (default: $HOME): +NANOCLAW_FS_ROOT=/home/user/projects claw-acp +``` + +## Manual test + +With NanoClaw running, open a terminal and paste these lines one at a time: + +``` +claw-acp +``` + +Then paste: + +``` +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientInfo":{"name":"test"},"capabilities":{}}} +{"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"/tmp","mcpServers":[]}} +{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"s1","prompt":[{"type":"text","text":"Hello, who are you?"}]}} +``` + +You should see three JSON-RPC responses: an `initialize` result, a `session/new` result with +a `sessionId`, and a `session/prompt` result with your agent's reply in a `session/update` +notification followed by `stopReason: "end_turn"`. + +## Supported ACP methods + +| Method | Support | +|--------|---------| +| `initialize` | Full | +| `session/new` | Full | +| `session/prompt` | Full (non-streaming) | +| `session/cancel` | Full | +| `session/close` | Full | +| `fs/read_text_file` | Full (with `line`/`limit` support) | +| `fs/write_text_file` | Not supported | + +## Known limitations + +- **Single active session**: NanoClaw's CLI channel supports one chat client at a time. + A second `claw-acp` process will evict the first. +- **Non-streaming**: the full agent response is delivered as one `session/update` chunk, + not token-by-token. IDEs display it immediately on arrival. +- **Read-only filesystem**: `fs/read_text_file` is supported; `fs/write_text_file` is not. +- **Requires running host**: `claw-acp` is a bridge — it needs the NanoClaw v2 service. + +## Troubleshooting + +**"CLI socket not found"** — NanoClaw is not running or `NANOCLAW_DIR` is wrong. +Run `systemctl --user start nanoclaw` and check `data/cli.sock` exists. + +**No response** — The CLI channel may not be wired to an agent group. Run `/manage-channels` +or check that an agent is wired to the `cli` channel in the NanoClaw UI. + +**Wrong NanoClaw directory** — Set `NANOCLAW_DIR=/path/to/your/nanoclaw` in the IDE's +environment or in `~/.bashrc`. diff --git a/.claude/skills/add-acp-server/scripts/claw-acp.ts b/.claude/skills/add-acp-server/scripts/claw-acp.ts new file mode 100644 index 00000000000..c57dc78fde6 --- /dev/null +++ b/.claude/skills/add-acp-server/scripts/claw-acp.ts @@ -0,0 +1,371 @@ +#!/usr/bin/env bun +/** + * claw-acp — NanoClaw ACP server bridge + * + * Bridges ACP JSON-RPC 2.0 (stdio) to NanoClaw v2's CLI Unix socket. + * IDEs like Zed or WebStorm spawn this as a subprocess and use NanoClaw + * as their AI backend. + * + * Usage: + * claw-acp # IDE spawns this; ACP JSON-RPC on stdin/stdout + * claw-acp -v # verbose — log protocol traffic to stderr + * + * JetBrains config (~/.jetbrains/acp.json): + * { "agent_servers": { "NanoClaw": { "command": "claw-acp" } } } + * + * Zed config (~/.config/zed/settings.json): + * { "agent_servers": { "NanoClaw": { "type": "custom", "command": "claw-acp" } } } + * + * Requires: NanoClaw v2 host running (systemctl --user start nanoclaw) + */ + +import { randomUUID } from 'crypto'; +import fs from 'fs'; +import net from 'net'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const VERSION = '2.0.0'; +const RECV_TIMEOUT_MS = 60_000; +const verbose = process.argv.includes('-v') || process.argv.includes('--verbose'); + +if (process.argv.includes('-h') || process.argv.includes('--help')) { + process.stderr.write( + 'claw-acp: NanoClaw ACP server bridge\n\n' + + 'Usage: claw-acp [-v]\n\n' + + ' -v, --verbose Log ACP traffic and socket events to stderr\n\n' + + 'IDEs spawn claw-acp as a subprocess and communicate via ACP JSON-RPC 2.0\n' + + 'on stdio. Prompts are forwarded to the NanoClaw CLI socket and routed\n' + + 'through the normal agent pipeline.\n\n' + + 'Set NANOCLAW_DIR to override the auto-detected NanoClaw directory.\n' + + 'Set NANOCLAW_FS_ROOT to restrict fs/ file access (default: $HOME).\n' + ); + process.exit(0); +} + +function dbg(...args: unknown[]): void { + if (verbose) process.stderr.write('» ' + args.join(' ') + '\n'); +} + +// ── NanoClaw directory detection ────────────────────────────────────────────── + +function findNanoclawDir(): string { + const envDir = process.env.NANOCLAW_DIR; + if (envDir) return envDir; + + // Walk up from this script (up to 8 levels) looking for NanoClaw v2 markers. + // Works whether the script is at scripts/ (normal install) or deep inside a + // worktree (.claude/worktrees/*/scripts/). + let dir = path.dirname(fileURLToPath(import.meta.url)); + for (let i = 0; i < 8; i++) { + if ( + fs.existsSync(path.join(dir, 'data', 'v2.db')) || + fs.existsSync(path.join(dir, 'data', 'cli.sock')) + ) { + return dir; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return path.join(process.env.HOME ?? '~', 'src', 'nanoclaw'); +} + +const NANOCLAW_DIR = findNanoclawDir(); +const SOCK_PATH = path.join(NANOCLAW_DIR, 'data', 'cli.sock'); +const FS_ROOT = process.env.NANOCLAW_FS_ROOT ?? process.env.HOME ?? '/home'; + +// ── Line reader ─────────────────────────────────────────────────────────────── +// Same pattern as container/agent-runner/src/providers/acp-client.ts:LineReader + +class LineReader { + private buf = ''; + private lines: string[] = []; + private waiters: Array<(line: string | null) => void> = []; + private ended = false; + + feed(chunk: string): void { + this.buf += chunk; + const parts = this.buf.split('\n'); + this.buf = parts.pop()!; + for (const line of parts) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (this.waiters.length > 0) this.waiters.shift()!(trimmed); + else this.lines.push(trimmed); + } + } + + end(): void { + this.ended = true; + for (const w of this.waiters) w(null); + this.waiters = []; + } + + readLine(): Promise { + if (this.lines.length > 0) return Promise.resolve(this.lines.shift()!); + if (this.ended) return Promise.resolve(null); + return new Promise(resolve => this.waiters.push(resolve)); + } +} + +// ── Stdout writer ───────────────────────────────────────────────────────────── + +function writeAcp(obj: object): void { + const line = JSON.stringify(obj); + dbg('→', line); + process.stdout.write(line + '\n'); +} + +function respond(id: number, result: object): void { + writeAcp({ jsonrpc: '2.0', id, result }); +} + +function respondError(id: number, code: number, message: string): void { + writeAcp({ jsonrpc: '2.0', id, error: { code, message } }); +} + +function notify(method: string, params: object): void { + writeAcp({ jsonrpc: '2.0', method, params }); +} + +// ── ACP Bridge ──────────────────────────────────────────────────────────────── + +class AcpBridge { + private sock: net.Socket | null = null; + private sockReader: LineReader | null = null; + + async connect(): Promise { + if (this.sock) return; + + if (!fs.existsSync(SOCK_PATH)) { + throw new Error( + `NanoClaw CLI socket not found at ${SOCK_PATH}. ` + + 'Is the NanoClaw host running? Try: systemctl --user start nanoclaw', + ); + } + + const reader = new LineReader(); + const sock = net.createConnection(SOCK_PATH); + + await new Promise((resolve, reject) => { + sock.once('connect', resolve); + sock.once('error', reject); + }); + + sock.setEncoding('utf8'); + sock.on('data', (chunk: string) => reader.feed(chunk)); + sock.on('end', () => reader.end()); + sock.on('error', (err: Error) => { + process.stderr.write(`[claw-acp] socket error: ${err.message}\n`); + reader.end(); + }); + + this.sock = sock; + this.sockReader = reader; + dbg('connected to', SOCK_PATH); + } + + private sendCli(text: string): void { + const msg = JSON.stringify({ text }) + '\n'; + dbg('cli→', msg.trimEnd()); + this.sock!.write(msg, 'utf8'); + } + + private async recvCli(): Promise { + const deadline = new Promise((_, reject) => + setTimeout( + () => reject(new Error(`NanoClaw response timeout after ${RECV_TIMEOUT_MS / 1000}s`)), + RECV_TIMEOUT_MS, + ) + ); + return Promise.race([this._recvCliLoop(), deadline]); + } + + private async _recvCliLoop(): Promise { + while (true) { + const line = await this.sockReader!.readLine(); + if (line === null) throw new Error('NanoClaw CLI socket closed unexpectedly'); + dbg('cli←', line); + try { + const obj = JSON.parse(line) as Record; + if (typeof obj.text === 'string') return obj.text; + } catch { + // skip non-JSON lines + } + } + } + + close(): void { + this.sockReader?.end(); + this.sockReader = null; + try { this.sock?.destroy(); } catch { /* swallow */ } + this.sock = null; + } + + // ── ACP method handlers ─────────────────────────────────────────────────── + + private handleInitialize(id: number): void { + respond(id, { + protocolVersion: 1, + agentCapabilities: { + promptCapabilities: { + image: false, + embeddedContext: false, + }, + }, + serverInfo: { name: 'nanoclaw', title: 'NanoClaw', version: VERSION }, + authMethods: [], + }); + } + + private handleSessionNew(id: number): void { + respond(id, { sessionId: randomUUID() }); + } + + private async handleSessionPrompt( + id: number, + params: Record, + ): Promise { + const blocks = (params.prompt as Array> | undefined) ?? []; + const text = blocks + .filter(b => b.type === 'text' && typeof b.text === 'string') + .map(b => b.text as string) + .join('') + .trim(); + + if (!text) { + respond(id, { stopReason: 'end_turn' }); + return; + } + + try { + await this.connect(); + this.sendCli(text); + const response = await this.recvCli(); + + notify('session/update', { + sessionId: params.sessionId ?? '', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: response }, + }, + }); + + respond(id, { stopReason: 'end_turn' }); + } catch (err) { + this.close(); + respondError(id, -32000, err instanceof Error ? err.message : String(err)); + } + } + + private handleSessionCancel(id: number): void { + this.close(); + respond(id, {}); + } + + private handleSessionClose(id: number): void { + this.close(); + respond(id, {}); + } + + private handleFsReadTextFile(id: number, params: Record): void { + const { path: filePath, line: startLine, limit } = params as { + path?: string; + sessionId?: string; + line?: number; + limit?: number; + }; + if (!filePath) { + respondError(id, -32602, 'Missing required param: path'); + return; + } + const resolved = path.resolve(filePath); + if (!resolved.startsWith(FS_ROOT + path.sep) && resolved !== FS_ROOT) { + respondError(id, -32000, `Path outside allowed root (${FS_ROOT}): ${resolved}`); + return; + } + try { + const raw = fs.readFileSync(resolved, 'utf8'); + if (startLine !== undefined || limit !== undefined) { + const lines = raw.split('\n'); + const start = Math.max(0, (startLine ?? 1) - 1); + const slice = limit !== undefined ? lines.slice(start, start + limit) : lines.slice(start); + respond(id, { content: slice.join('\n') }); + } else { + respond(id, { content: raw }); + } + } catch (err) { + respondError(id, -32000, err instanceof Error ? err.message : String(err)); + } + } + + // ── Main stdin loop ─────────────────────────────────────────────────────── + + async run(): Promise { + const stdinReader = new LineReader(); + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk: string) => stdinReader.feed(chunk)); + process.stdin.on('end', () => stdinReader.end()); + + try { + while (true) { + const raw = await stdinReader.readLine(); + if (raw === null) break; + dbg('←', raw); + + let msg: Record; + try { + msg = JSON.parse(raw) as Record; + } catch { + continue; + } + + const id = msg.id as number | undefined; + const method = msg.method as string | undefined; + const params = (msg.params ?? {}) as Record; + + if (method === 'initialize') { + this.handleInitialize(id!); + } else if (method === 'session/new') { + this.handleSessionNew(id!); + } else if (method === 'session/prompt') { + await this.handleSessionPrompt(id!, params); + } else if (method === 'session/cancel') { + this.handleSessionCancel(id!); + } else if (method === 'session/close') { + this.handleSessionClose(id!); + } else if (method === 'fs/read_text_file') { + this.handleFsReadTextFile(id!, params); + } else if (id !== undefined) { + respondError(id, -32601, `Method not found: ${method}`); + } + // Notifications (no id): silently ignored + } + } finally { + this.close(); + } + } +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +if (process.stdin.isTTY) { + process.stderr.write('claw-acp: NanoClaw ACP server bridge\n'); + process.stderr.write(` NanoClaw directory : ${NANOCLAW_DIR}\n`); + process.stderr.write(` CLI socket : ${SOCK_PATH}\n`); + process.stderr.write(` File system root : ${FS_ROOT}\n`); + process.stderr.write(' Waiting for ACP JSON-RPC on stdin… (Ctrl-C to exit)\n\n'); + process.stderr.write(' Quick test — paste these lines one at a time:\n'); + process.stderr.write( + ' {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientInfo":{"name":"test"},"capabilities":{}}}\n', + ); + process.stderr.write( + ' {"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"/tmp","mcpServers":[]}}\n', + ); + process.stderr.write( + ' {"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"s1","prompt":[{"type":"text","text":"Hello!"}]}}\n\n', + ); +} + +await new AcpBridge().run(); From ac090bfe7fd9573f9fefe58284fd0644bce76207 Mon Sep 17 00:00:00 2001 From: Jonathan Katz Date: Thu, 21 May 2026 01:09:18 +0300 Subject: [PATCH 2/3] test: add claw-acp test suite and refactor for testability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exports LineReader, AcpBridge, and CliTransport. Adds injectable I/O (input, output, connectCli, recvTimeoutMs, fsRoot) so tests run without a live NanoClaw instance. 23 tests covering LineReader, all ACP methods, timeout, path sandboxing, and error codes — mirrors the acp-client.test.ts pattern. Co-Authored-By: Claude Sonnet 4.6 --- .../add-acp-server/scripts/claw-acp.test.ts | 382 ++++++++++++++++++ .../skills/add-acp-server/scripts/claw-acp.ts | 193 +++++---- 2 files changed, 500 insertions(+), 75 deletions(-) create mode 100644 .claude/skills/add-acp-server/scripts/claw-acp.test.ts diff --git a/.claude/skills/add-acp-server/scripts/claw-acp.test.ts b/.claude/skills/add-acp-server/scripts/claw-acp.test.ts new file mode 100644 index 00000000000..eec7a2d48fd --- /dev/null +++ b/.claude/skills/add-acp-server/scripts/claw-acp.test.ts @@ -0,0 +1,382 @@ +import { describe, expect, test } from 'bun:test'; +import { mkdtempSync, writeFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { AcpBridge, LineReader, type CliTransport } from './claw-acp.ts'; + +// ── Test helpers ────────────────────────────────────────────────────────────── + +/** Captures ACP output and provides an input LineReader for injection. */ +class MockIO { + readonly written: Record[] = []; + private readonly _reader = new LineReader(); + + feed(obj: object): void { + this._reader.feed(JSON.stringify(obj) + '\n'); + } + + end(): void { + this._reader.end(); + } + + get inputReader(): LineReader { + return this._reader; + } + + get outputFn(): (line: string) => void { + return (line) => this.written.push(JSON.parse(line)); + } + + /** All result/error responses (not notifications). */ + get responses(): Record[] { + return this.written.filter(m => 'id' in m); + } + + /** All notification messages. */ + get notifications(): Record[] { + return this.written.filter(m => !('id' in m) && 'method' in m); + } +} + +/** Simulates the NanoClaw CLI socket. */ +class MockCli { + readonly sent: string[] = []; + private readonly _reader = new LineReader(); + private _closed = false; + + respond(text: string): void { + this._reader.feed(JSON.stringify({ text }) + '\n'); + } + + terminate(): void { + if (!this._closed) { + this._closed = true; + this._reader.end(); + } + } + + get transport(): CliTransport { + return { + reader: this._reader, + write: (msg) => { + const parsed = JSON.parse(msg) as Record; + if (typeof parsed.text === 'string') this.sent.push(parsed.text); + }, + close: () => this.terminate(), + }; + } +} + +/** Creates a bridge with injected MockIO and MockCli. */ +function makeBridge(cli: MockCli, io: MockIO, opts: { recvTimeoutMs?: number; fsRoot?: string } = {}) { + return new AcpBridge({ + input: io.inputReader, + output: io.outputFn, + connectCli: () => Promise.resolve(cli.transport), + recvTimeoutMs: opts.recvTimeoutMs ?? 5_000, + fsRoot: opts.fsRoot, + }); +} + +// ── LineReader ──────────────────────────────────────────────────────────────── + +describe('LineReader', () => { + test('reads a complete line', async () => { + const r = new LineReader(); + r.feed('hello\n'); + expect(await r.readLine()).toBe('hello'); + }); + + test('buffers partial chunks and resolves when newline arrives', async () => { + const r = new LineReader(); + const p = r.readLine(); + r.feed('hel'); + r.feed('lo\n'); + expect(await p).toBe('hello'); + }); + + test('skips blank lines', async () => { + const r = new LineReader(); + r.feed('\n\n \nfoo\n'); + expect(await r.readLine()).toBe('foo'); + }); + + test('resolves null on end()', async () => { + const r = new LineReader(); + const p = r.readLine(); + r.end(); + expect(await p).toBeNull(); + }); + + test('queued lines are returned before null', async () => { + const r = new LineReader(); + r.feed('a\nb\n'); + r.end(); + expect(await r.readLine()).toBe('a'); + expect(await r.readLine()).toBe('b'); + expect(await r.readLine()).toBeNull(); + }); +}); + +// ── handleInitialize ────────────────────────────────────────────────────────── + +describe('handleInitialize', () => { + test('responds with correct protocol fields', () => { + const io = new MockIO(); + const bridge = new AcpBridge({ output: io.outputFn }); + bridge.handleInitialize(1); + + expect(io.written).toHaveLength(1); + const msg = io.written[0] as Record; + expect(msg.jsonrpc).toBe('2.0'); + expect(msg.id).toBe(1); + const result = msg.result as Record; + expect(result.protocolVersion).toBe(1); + expect((result.agentCapabilities as Record).promptCapabilities).toBeDefined(); + expect((result.serverInfo as Record).name).toBe('nanoclaw'); + expect(result.authMethods).toEqual([]); + }); + + test('echoes back the request id', () => { + const io = new MockIO(); + const bridge = new AcpBridge({ output: io.outputFn }); + bridge.handleInitialize(42); + expect((io.written[0] as Record).id).toBe(42); + }); +}); + +// ── handleSessionNew ────────────────────────────────────────────────────────── + +describe('handleSessionNew', () => { + test('returns a valid UUID sessionId', () => { + const io = new MockIO(); + const bridge = new AcpBridge({ output: io.outputFn }); + bridge.handleSessionNew(2); + const result = (io.written[0] as Record).result as Record; + expect(typeof result.sessionId).toBe('string'); + expect(result.sessionId as string).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + }); +}); + +// ── handleSessionPrompt ─────────────────────────────────────────────────────── + +describe('handleSessionPrompt', () => { + test('happy path: sends to CLI, emits update, responds end_turn', async () => { + const io = new MockIO(); + const cli = new MockCli(); + const bridge = makeBridge(cli, io); + + const promptP = bridge.handleSessionPrompt(3, { + sessionId: 'sess-1', + prompt: [{ type: 'text', text: 'Hello' }], + }); + + // CLI receives the prompt — respond + await new Promise(r => setTimeout(r, 10)); + cli.respond('World'); + + await promptP; + + expect(cli.sent).toEqual(['Hello']); + + const notif = io.notifications[0]; + expect(notif.method).toBe('session/update'); + const params = notif.params as Record; + expect(params.sessionId).toBe('sess-1'); + const update = params.update as Record; + expect(update.sessionUpdate).toBe('agent_message_chunk'); + expect((update.content as Record).text).toBe('World'); + + const resp = io.responses[0]; + expect((resp.result as Record).stopReason).toBe('end_turn'); + }); + + test('empty prompt returns end_turn without connecting to CLI', async () => { + const io = new MockIO(); + const cli = new MockCli(); + const bridge = makeBridge(cli, io); + + await bridge.handleSessionPrompt(3, { sessionId: 's1', prompt: [] }); + + expect(cli.sent).toHaveLength(0); + expect((io.responses[0].result as Record).stopReason).toBe('end_turn'); + }); + + test('timeout: returns -32000 error and closes CLI', async () => { + const io = new MockIO(); + const cli = new MockCli(); + const bridge = makeBridge(cli, io, { recvTimeoutMs: 50 }); + + await bridge.handleSessionPrompt(3, { + sessionId: 's1', + prompt: [{ type: 'text', text: 'ping' }], + }); + + const resp = io.responses[0]; + expect(resp.error).toBeDefined(); + expect((resp.error as Record).code).toBe(-32000); + expect((resp.error as Record).message as string).toContain('timeout'); + }); + + test('CLI socket closed mid-wait: returns -32000 error', async () => { + const io = new MockIO(); + const cli = new MockCli(); + const bridge = makeBridge(cli, io); + + const promptP = bridge.handleSessionPrompt(3, { + sessionId: 's1', + prompt: [{ type: 'text', text: 'ping' }], + }); + + await new Promise(r => setTimeout(r, 10)); + cli.terminate(); + + await promptP; + + const resp = io.responses[0]; + expect(resp.error).toBeDefined(); + expect((resp.error as Record).code).toBe(-32000); + }); + + test('concatenates multiple text blocks', async () => { + const io = new MockIO(); + const cli = new MockCli(); + const bridge = makeBridge(cli, io); + + const promptP = bridge.handleSessionPrompt(3, { + sessionId: 's1', + prompt: [ + { type: 'text', text: 'Hello ' }, + { type: 'text', text: 'world' }, + ], + }); + + await new Promise(r => setTimeout(r, 10)); + cli.respond('ok'); + await promptP; + + expect(cli.sent[0]).toBe('Hello world'); + }); +}); + +// ── handleSessionCancel / handleSessionClose ────────────────────────────────── + +describe('handleSessionCancel', () => { + test('responds with empty result', () => { + const io = new MockIO(); + const bridge = new AcpBridge({ output: io.outputFn }); + bridge.handleSessionCancel(5); + expect(io.responses[0].result).toEqual({}); + }); +}); + +describe('handleSessionClose', () => { + test('responds with empty result', () => { + const io = new MockIO(); + const bridge = new AcpBridge({ output: io.outputFn }); + bridge.handleSessionClose(6); + expect(io.responses[0].result).toEqual({}); + }); +}); + +// ── handleFsReadTextFile ────────────────────────────────────────────────────── + +describe('handleFsReadTextFile', () => { + let tmpDir: string; + + // Use a real temp dir as the fsRoot so path checks pass + const setup = () => { + tmpDir = mkdtempSync(join(tmpdir(), 'claw-acp-test-')); + return tmpDir; + }; + const teardown = () => rmSync(tmpDir, { recursive: true, force: true }); + + test('reads a file and returns content', () => { + const root = setup(); + try { + const filePath = join(root, 'test.txt'); + writeFileSync(filePath, 'hello world'); + const io = new MockIO(); + const bridge = new AcpBridge({ output: io.outputFn, fsRoot: root }); + bridge.handleFsReadTextFile(7, { path: filePath }); + expect((io.responses[0].result as Record).content).toBe('hello world'); + } finally { teardown(); } + }); + + test('line/limit returns partial content', () => { + const root = setup(); + try { + const filePath = join(root, 'multi.txt'); + writeFileSync(filePath, 'line1\nline2\nline3\nline4\n'); + const io = new MockIO(); + const bridge = new AcpBridge({ output: io.outputFn, fsRoot: root }); + bridge.handleFsReadTextFile(7, { path: filePath, line: 2, limit: 2 }); + expect((io.responses[0].result as Record).content).toBe('line2\nline3'); + } finally { teardown(); } + }); + + test('path outside fsRoot returns -32000', () => { + const io = new MockIO(); + const bridge = new AcpBridge({ output: io.outputFn, fsRoot: '/tmp/restricted' }); + bridge.handleFsReadTextFile(7, { path: '/etc/passwd' }); + const err = io.responses[0].error as Record; + expect(err.code).toBe(-32000); + expect(err.message as string).toContain('outside allowed root'); + }); + + test('missing path param returns -32602', () => { + const io = new MockIO(); + const bridge = new AcpBridge({ output: io.outputFn }); + bridge.handleFsReadTextFile(7, {}); + const err = io.responses[0].error as Record; + expect(err.code).toBe(-32602); + }); + + test('file not found returns -32000 with ENOENT', () => { + const root = setup(); + try { + const io = new MockIO(); + const bridge = new AcpBridge({ output: io.outputFn, fsRoot: root }); + bridge.handleFsReadTextFile(7, { path: join(root, 'missing.txt') }); + const err = io.responses[0].error as Record; + expect(err.code).toBe(-32000); + expect(err.message as string).toContain('ENOENT'); + } finally { teardown(); } + }); +}); + +// ── Unknown method / notifications ──────────────────────────────────────────── + +describe('run() dispatch', () => { + test('unknown method with id returns -32601', async () => { + const io = new MockIO(); + const bridge = new AcpBridge({ input: io.inputReader, output: io.outputFn }); + io.feed({ jsonrpc: '2.0', id: 9, method: 'unknown/method', params: {} }); + io.end(); + await bridge.run(); + const err = io.responses[0].error as Record; + expect(err.code).toBe(-32601); + expect(err.message as string).toContain('unknown/method'); + }); + + test('notification (no id) is silently ignored', async () => { + const io = new MockIO(); + const bridge = new AcpBridge({ input: io.inputReader, output: io.outputFn }); + io.feed({ jsonrpc: '2.0', method: 'some/notification', params: {} }); + io.end(); + await bridge.run(); + expect(io.written).toHaveLength(0); + }); + + test('invalid JSON is silently skipped', async () => { + const io = new MockIO(); + const bridge = new AcpBridge({ input: io.inputReader, output: io.outputFn }); + // Feed raw invalid JSON via the reader directly + io.inputReader.feed('not json\n'); + io.end(); + await bridge.run(); + expect(io.written).toHaveLength(0); + }); +}); diff --git a/.claude/skills/add-acp-server/scripts/claw-acp.ts b/.claude/skills/add-acp-server/scripts/claw-acp.ts index c57dc78fde6..5a968cb9dba 100644 --- a/.claude/skills/add-acp-server/scripts/claw-acp.ts +++ b/.claude/skills/add-acp-server/scripts/claw-acp.ts @@ -25,8 +25,8 @@ import net from 'net'; import path from 'path'; import { fileURLToPath } from 'url'; -const VERSION = '2.0.0'; -const RECV_TIMEOUT_MS = 60_000; +export const VERSION = '2.0.0'; +export const RECV_TIMEOUT_MS = 60_000; const verbose = process.argv.includes('-v') || process.argv.includes('--verbose'); if (process.argv.includes('-h') || process.argv.includes('--help')) { @@ -73,12 +73,12 @@ function findNanoclawDir(): string { const NANOCLAW_DIR = findNanoclawDir(); const SOCK_PATH = path.join(NANOCLAW_DIR, 'data', 'cli.sock'); -const FS_ROOT = process.env.NANOCLAW_FS_ROOT ?? process.env.HOME ?? '/home'; +export const FS_ROOT = process.env.NANOCLAW_FS_ROOT ?? process.env.HOME ?? '/home'; // ── Line reader ─────────────────────────────────────────────────────────────── // Same pattern as container/agent-runner/src/providers/acp-client.ts:LineReader -class LineReader { +export class LineReader { private buf = ''; private lines: string[] = []; private waiters: Array<(line: string | null) => void> = []; @@ -109,34 +109,65 @@ class LineReader { } } -// ── Stdout writer ───────────────────────────────────────────────────────────── +// ── CLI transport abstraction ───────────────────────────────────────────────── -function writeAcp(obj: object): void { - const line = JSON.stringify(obj); - dbg('→', line); - process.stdout.write(line + '\n'); +export interface CliTransport { + reader: LineReader; + write: (msg: string) => void; + close: () => void; } -function respond(id: number, result: object): void { - writeAcp({ jsonrpc: '2.0', id, result }); -} +// ── ACP Bridge ──────────────────────────────────────────────────────────────── -function respondError(id: number, code: number, message: string): void { - writeAcp({ jsonrpc: '2.0', id, error: { code, message } }); +export interface AcpBridgeOpts { + input?: LineReader; + output?: (line: string) => void; + connectCli?: () => Promise; + recvTimeoutMs?: number; + fsRoot?: string; } -function notify(method: string, params: object): void { - writeAcp({ jsonrpc: '2.0', method, params }); -} +export class AcpBridge { + private cli: CliTransport | null = null; + private readonly out: (line: string) => void; + private readonly recvTimeoutMs: number; + private readonly fsRoot: string; -// ── ACP Bridge ──────────────────────────────────────────────────────────────── + constructor(private readonly opts: AcpBridgeOpts = {}) { + this.out = opts.output ?? ((line) => process.stdout.write(line + '\n')); + this.recvTimeoutMs = opts.recvTimeoutMs ?? RECV_TIMEOUT_MS; + this.fsRoot = opts.fsRoot ?? FS_ROOT; + } + + // ── Output helpers ──────────────────────────────────────────────────────── + + private write(obj: object): void { + const line = JSON.stringify(obj); + dbg('→', line); + this.out(line); + } -class AcpBridge { - private sock: net.Socket | null = null; - private sockReader: LineReader | null = null; + private respond(id: number, result: object): void { + this.write({ jsonrpc: '2.0', id, result }); + } + + private respondError(id: number, code: number, message: string): void { + this.write({ jsonrpc: '2.0', id, error: { code, message } }); + } + + private notify(method: string, params: object): void { + this.write({ jsonrpc: '2.0', method, params }); + } + + // ── CLI socket ──────────────────────────────────────────────────────────── async connect(): Promise { - if (this.sock) return; + if (this.cli) return; + + if (this.opts.connectCli) { + this.cli = await this.opts.connectCli(); + return; + } if (!fs.existsSync(SOCK_PATH)) { throw new Error( @@ -161,22 +192,28 @@ class AcpBridge { reader.end(); }); - this.sock = sock; - this.sockReader = reader; + this.cli = { + reader, + write: (msg) => sock.write(msg, 'utf8'), + close: () => { + reader.end(); + try { sock.destroy(); } catch { /* swallow */ } + }, + }; dbg('connected to', SOCK_PATH); } private sendCli(text: string): void { const msg = JSON.stringify({ text }) + '\n'; dbg('cli→', msg.trimEnd()); - this.sock!.write(msg, 'utf8'); + this.cli!.write(msg); } private async recvCli(): Promise { const deadline = new Promise((_, reject) => setTimeout( - () => reject(new Error(`NanoClaw response timeout after ${RECV_TIMEOUT_MS / 1000}s`)), - RECV_TIMEOUT_MS, + () => reject(new Error(`NanoClaw response timeout after ${this.recvTimeoutMs / 1000}s`)), + this.recvTimeoutMs, ) ); return Promise.race([this._recvCliLoop(), deadline]); @@ -184,7 +221,7 @@ class AcpBridge { private async _recvCliLoop(): Promise { while (true) { - const line = await this.sockReader!.readLine(); + const line = await this.cli!.reader.readLine(); if (line === null) throw new Error('NanoClaw CLI socket closed unexpectedly'); dbg('cli←', line); try { @@ -197,16 +234,14 @@ class AcpBridge { } close(): void { - this.sockReader?.end(); - this.sockReader = null; - try { this.sock?.destroy(); } catch { /* swallow */ } - this.sock = null; + this.cli?.close(); + this.cli = null; } // ── ACP method handlers ─────────────────────────────────────────────────── - private handleInitialize(id: number): void { - respond(id, { + handleInitialize(id: number): void { + this.respond(id, { protocolVersion: 1, agentCapabilities: { promptCapabilities: { @@ -219,11 +254,11 @@ class AcpBridge { }); } - private handleSessionNew(id: number): void { - respond(id, { sessionId: randomUUID() }); + handleSessionNew(id: number): void { + this.respond(id, { sessionId: randomUUID() }); } - private async handleSessionPrompt( + async handleSessionPrompt( id: number, params: Record, ): Promise { @@ -235,7 +270,7 @@ class AcpBridge { .trim(); if (!text) { - respond(id, { stopReason: 'end_turn' }); + this.respond(id, { stopReason: 'end_turn' }); return; } @@ -244,7 +279,7 @@ class AcpBridge { this.sendCli(text); const response = await this.recvCli(); - notify('session/update', { + this.notify('session/update', { sessionId: params.sessionId ?? '', update: { sessionUpdate: 'agent_message_chunk', @@ -252,24 +287,24 @@ class AcpBridge { }, }); - respond(id, { stopReason: 'end_turn' }); + this.respond(id, { stopReason: 'end_turn' }); } catch (err) { this.close(); - respondError(id, -32000, err instanceof Error ? err.message : String(err)); + this.respondError(id, -32000, err instanceof Error ? err.message : String(err)); } } - private handleSessionCancel(id: number): void { + handleSessionCancel(id: number): void { this.close(); - respond(id, {}); + this.respond(id, {}); } - private handleSessionClose(id: number): void { + handleSessionClose(id: number): void { this.close(); - respond(id, {}); + this.respond(id, {}); } - private handleFsReadTextFile(id: number, params: Record): void { + handleFsReadTextFile(id: number, params: Record): void { const { path: filePath, line: startLine, limit } = params as { path?: string; sessionId?: string; @@ -277,12 +312,12 @@ class AcpBridge { limit?: number; }; if (!filePath) { - respondError(id, -32602, 'Missing required param: path'); + this.respondError(id, -32602, 'Missing required param: path'); return; } const resolved = path.resolve(filePath); - if (!resolved.startsWith(FS_ROOT + path.sep) && resolved !== FS_ROOT) { - respondError(id, -32000, `Path outside allowed root (${FS_ROOT}): ${resolved}`); + if (!resolved.startsWith(this.fsRoot + path.sep) && resolved !== this.fsRoot) { + this.respondError(id, -32000, `Path outside allowed root (${this.fsRoot}): ${resolved}`); return; } try { @@ -291,22 +326,28 @@ class AcpBridge { const lines = raw.split('\n'); const start = Math.max(0, (startLine ?? 1) - 1); const slice = limit !== undefined ? lines.slice(start, start + limit) : lines.slice(start); - respond(id, { content: slice.join('\n') }); + this.respond(id, { content: slice.join('\n') }); } else { - respond(id, { content: raw }); + this.respond(id, { content: raw }); } } catch (err) { - respondError(id, -32000, err instanceof Error ? err.message : String(err)); + this.respondError(id, -32000, err instanceof Error ? err.message : String(err)); } } // ── Main stdin loop ─────────────────────────────────────────────────────── async run(): Promise { - const stdinReader = new LineReader(); - process.stdin.setEncoding('utf8'); - process.stdin.on('data', (chunk: string) => stdinReader.feed(chunk)); - process.stdin.on('end', () => stdinReader.end()); + let stdinReader: LineReader; + + if (this.opts.input) { + stdinReader = this.opts.input; + } else { + stdinReader = new LineReader(); + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk: string) => stdinReader.feed(chunk)); + process.stdin.on('end', () => stdinReader.end()); + } try { while (true) { @@ -338,7 +379,7 @@ class AcpBridge { } else if (method === 'fs/read_text_file') { this.handleFsReadTextFile(id!, params); } else if (id !== undefined) { - respondError(id, -32601, `Method not found: ${method}`); + this.respondError(id, -32601, `Method not found: ${method}`); } // Notifications (no id): silently ignored } @@ -350,22 +391,24 @@ class AcpBridge { // ── Entry point ─────────────────────────────────────────────────────────────── -if (process.stdin.isTTY) { - process.stderr.write('claw-acp: NanoClaw ACP server bridge\n'); - process.stderr.write(` NanoClaw directory : ${NANOCLAW_DIR}\n`); - process.stderr.write(` CLI socket : ${SOCK_PATH}\n`); - process.stderr.write(` File system root : ${FS_ROOT}\n`); - process.stderr.write(' Waiting for ACP JSON-RPC on stdin… (Ctrl-C to exit)\n\n'); - process.stderr.write(' Quick test — paste these lines one at a time:\n'); - process.stderr.write( - ' {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientInfo":{"name":"test"},"capabilities":{}}}\n', - ); - process.stderr.write( - ' {"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"/tmp","mcpServers":[]}}\n', - ); - process.stderr.write( - ' {"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"s1","prompt":[{"type":"text","text":"Hello!"}]}}\n\n', - ); -} +if (import.meta.main) { + if (process.stdin.isTTY) { + process.stderr.write('claw-acp: NanoClaw ACP server bridge\n'); + process.stderr.write(` NanoClaw directory : ${NANOCLAW_DIR}\n`); + process.stderr.write(` CLI socket : ${SOCK_PATH}\n`); + process.stderr.write(` File system root : ${FS_ROOT}\n`); + process.stderr.write(' Waiting for ACP JSON-RPC on stdin… (Ctrl-C to exit)\n\n'); + process.stderr.write(' Quick test — paste these lines one at a time:\n'); + process.stderr.write( + ' {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientInfo":{"name":"test"},"capabilities":{}}}\n', + ); + process.stderr.write( + ' {"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"/tmp","mcpServers":[]}}\n', + ); + process.stderr.write( + ' {"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"s1","prompt":[{"type":"text","text":"Hello!"}]}}\n\n', + ); + } -await new AcpBridge().run(); + await new AcpBridge().run(); +} From 9d946a64c51f1a98eda7fb8626222eacf6283802 Mon Sep 17 00:00:00 2001 From: Jonathan Katz Date: Thu, 21 May 2026 01:35:49 +0300 Subject: [PATCH 3/3] feat: resolve @file references via IDE fs/read_text_file callback When an IDE sends a resource_link block in session/prompt (e.g. a WebStorm @file mention), the bridge now calls fs/read_text_file back on the IDE, fetches the file content, and embeds it as text in the NanoClaw prompt. Fixes the deadlock by firing session/prompt without blocking the run loop, so incoming IDE responses can be processed while the prompt is in flight. Co-Authored-By: Claude Sonnet 4.6 --- .../skills/add-acp-server/scripts/claw-acp.ts | 65 +++++++++++++++++-- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/.claude/skills/add-acp-server/scripts/claw-acp.ts b/.claude/skills/add-acp-server/scripts/claw-acp.ts index 5a968cb9dba..88c5060542e 100644 --- a/.claude/skills/add-acp-server/scripts/claw-acp.ts +++ b/.claude/skills/add-acp-server/scripts/claw-acp.ts @@ -132,6 +132,9 @@ export class AcpBridge { private readonly out: (line: string) => void; private readonly recvTimeoutMs: number; private readonly fsRoot: string; + // Outgoing requests we sent to the IDE, waiting for responses + private readonly pending = new Map void>(); + private nextId = 1000; constructor(private readonly opts: AcpBridgeOpts = {}) { this.out = opts.output ?? ((line) => process.stdout.write(line + '\n')); @@ -159,6 +162,45 @@ export class AcpBridge { this.write({ jsonrpc: '2.0', method, params }); } + // Send a request to the IDE and wait for its response (e.g. fs/read_text_file) + private requestIde(method: string, params: object): Promise { + const id = this.nextId++; + return new Promise((resolve, reject) => { + this.pending.set(id, (result, error) => { + if (error) reject(new Error(typeof error === 'object' ? JSON.stringify(error) : String(error))); + else resolve(result as T); + }); + this.write({ jsonrpc: '2.0', id, method, params }); + }); + } + + // Resolve resource_link blocks by asking the IDE to read the files + private async resolveResources( + blocks: Array>, + sessionId: string, + ): Promise { + const parts: string[] = []; + for (const block of blocks) { + if (block.type === 'text' && typeof block.text === 'string') { + parts.push(block.text as string); + } else if (block.type === 'resource_link' || block.type === 'resource') { + const uri = (block.uri ?? block.url) as string | undefined; + if (!uri) continue; + const filePath = uri.startsWith('file://') ? uri.slice(7) : uri; + try { + const result = await this.requestIde<{ content: string }>( + 'fs/read_text_file', + { sessionId, path: filePath }, + ); + parts.push(`\n---\n${filePath}\n---\n${result.content}`); + } catch (err) { + dbg('resource fetch failed:', filePath, err instanceof Error ? err.message : String(err)); + } + } + } + return parts.join('').trim(); + } + // ── CLI socket ──────────────────────────────────────────────────────────── async connect(): Promise { @@ -263,11 +305,8 @@ export class AcpBridge { params: Record, ): Promise { const blocks = (params.prompt as Array> | undefined) ?? []; - const text = blocks - .filter(b => b.type === 'text' && typeof b.text === 'string') - .map(b => b.text as string) - .join('') - .trim(); + const sessionId = (params.sessionId as string | undefined) ?? ''; + const text = await this.resolveResources(blocks, sessionId); if (!text) { this.respond(id, { stopReason: 'end_turn' }); @@ -366,12 +405,26 @@ export class AcpBridge { const method = msg.method as string | undefined; const params = (msg.params ?? {}) as Record; + // Response to one of our outgoing requests (e.g. fs/read_text_file we sent to IDE) + if (id !== undefined && !method && (msg.result !== undefined || msg.error !== undefined)) { + const handler = this.pending.get(id as number); + if (handler) { + this.pending.delete(id as number); + handler(msg.result, msg.error); + continue; + } + } + if (method === 'initialize') { this.handleInitialize(id!); } else if (method === 'session/new') { this.handleSessionNew(id!); } else if (method === 'session/prompt') { - await this.handleSessionPrompt(id!, params); + // Don't await — the loop must keep running to process IDE responses + // (e.g. fs/read_text_file replies) while the prompt is in flight. + this.handleSessionPrompt(id!, params).catch(err => + this.respondError(id!, -32000, err instanceof Error ? err.message : String(err)) + ); } else if (method === 'session/cancel') { this.handleSessionCancel(id!); } else if (method === 'session/close') {