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.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 new file mode 100644 index 00000000000..88c5060542e --- /dev/null +++ b/.claude/skills/add-acp-server/scripts/claw-acp.ts @@ -0,0 +1,467 @@ +#!/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'; + +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')) { + 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'); +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 + +export 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)); + } +} + +// ── CLI transport abstraction ───────────────────────────────────────────────── + +export interface CliTransport { + reader: LineReader; + write: (msg: string) => void; + close: () => void; +} + +// ── ACP Bridge ──────────────────────────────────────────────────────────────── + +export interface AcpBridgeOpts { + input?: LineReader; + output?: (line: string) => void; + connectCli?: () => Promise; + recvTimeoutMs?: number; + fsRoot?: string; +} + +export class AcpBridge { + private cli: CliTransport | null = null; + 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')); + 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); + } + + 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 }); + } + + // 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 { + if (this.cli) return; + + if (this.opts.connectCli) { + this.cli = await this.opts.connectCli(); + 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.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.cli!.write(msg); + } + + private async recvCli(): Promise { + const deadline = new Promise((_, reject) => + setTimeout( + () => reject(new Error(`NanoClaw response timeout after ${this.recvTimeoutMs / 1000}s`)), + this.recvTimeoutMs, + ) + ); + return Promise.race([this._recvCliLoop(), deadline]); + } + + private async _recvCliLoop(): Promise { + while (true) { + const line = await this.cli!.reader.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.cli?.close(); + this.cli = null; + } + + // ── ACP method handlers ─────────────────────────────────────────────────── + + handleInitialize(id: number): void { + this.respond(id, { + protocolVersion: 1, + agentCapabilities: { + promptCapabilities: { + image: false, + embeddedContext: false, + }, + }, + serverInfo: { name: 'nanoclaw', title: 'NanoClaw', version: VERSION }, + authMethods: [], + }); + } + + handleSessionNew(id: number): void { + this.respond(id, { sessionId: randomUUID() }); + } + + async handleSessionPrompt( + id: number, + params: Record, + ): Promise { + const blocks = (params.prompt as Array> | undefined) ?? []; + const sessionId = (params.sessionId as string | undefined) ?? ''; + const text = await this.resolveResources(blocks, sessionId); + + if (!text) { + this.respond(id, { stopReason: 'end_turn' }); + return; + } + + try { + await this.connect(); + this.sendCli(text); + const response = await this.recvCli(); + + this.notify('session/update', { + sessionId: params.sessionId ?? '', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: response }, + }, + }); + + this.respond(id, { stopReason: 'end_turn' }); + } catch (err) { + this.close(); + this.respondError(id, -32000, err instanceof Error ? err.message : String(err)); + } + } + + handleSessionCancel(id: number): void { + this.close(); + this.respond(id, {}); + } + + handleSessionClose(id: number): void { + this.close(); + this.respond(id, {}); + } + + handleFsReadTextFile(id: number, params: Record): void { + const { path: filePath, line: startLine, limit } = params as { + path?: string; + sessionId?: string; + line?: number; + limit?: number; + }; + if (!filePath) { + this.respondError(id, -32602, 'Missing required param: path'); + return; + } + const resolved = path.resolve(filePath); + if (!resolved.startsWith(this.fsRoot + path.sep) && resolved !== this.fsRoot) { + this.respondError(id, -32000, `Path outside allowed root (${this.fsRoot}): ${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); + this.respond(id, { content: slice.join('\n') }); + } else { + this.respond(id, { content: raw }); + } + } catch (err) { + this.respondError(id, -32000, err instanceof Error ? err.message : String(err)); + } + } + + // ── Main stdin loop ─────────────────────────────────────────────────────── + + async run(): Promise { + 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) { + 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; + + // 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') { + // 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') { + this.handleSessionClose(id!); + } else if (method === 'fs/read_text_file') { + this.handleFsReadTextFile(id!, params); + } else if (id !== undefined) { + this.respondError(id, -32601, `Method not found: ${method}`); + } + // Notifications (no id): silently ignored + } + } finally { + this.close(); + } + } +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +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(); +}