/** * lib/ai/claude_cli.js * * Spawns the `claude` CLI (Claude Code) for a single companion turn using * subscription auth (OAuth / keychain), streaming normalized events. * * Mirrors the core logic of Void 1.0's agent.js but: * - Is a pure function (no global state, no EventEmitter) * - Uses ESM * - Emits a simplified, UI-vocabulary-aligned event set (delta, tool, * tool_result, result, error) * - Is injectable for testing via `claudeExe` * * ## stream-json event format (CLI 2.1.159) * The CLI wraps Anthropic API streaming events in a `stream_event` envelope: * { type: "stream_event", event: { type: "content_block_delta", ... } } * * Top-level bare events are also emitted: * { type: "system", subtype: "init", ... } — ignored * { type: "assistant", ... } — ignored (snapshot, duplicates deltas) * { type: "tool_result", ... } — surfaced as-is * { type: "result", subtype: "success", ... } — final usage/cost summary * { type: "error", ... } — error * * ## Tool event semantics * A single `{type:'tool', tool, status:'done'|'error'}` is emitted per * completed tool call when the input_json assembly is finished (on * content_block_stop for a tool_use block). No separate 'running' event is * emitted — the UI renders a chip per tool completion. A pending-tool map * tracks open tool blocks by index so we know name+id at stop time. * * ## Allowed flags (verified against 2.1.159) * --print (-p) * --output-format stream-json * --verbose * --include-partial-messages * --append-system-prompt (inline text, no temp file needed) * --append-system-prompt-file (file variant — undocumented but works) * --session-id * --mcp-config (accepts multiple space-separated paths) * --strict-mcp-config * --allowedTools ... (space-separated, single flag, multiple values) * * @module lib/ai/claude_cli */ import { spawn } from 'child_process'; import { createInterface } from 'readline'; /** * Run a single non-interactive Claude CLI turn. * * @param {object} opts * @param {string} opts.sessionId UUID for the session (passed as --session-id) * @param {string} opts.systemPrompt Appended system prompt text * @param {string} opts.userText The user message (positional arg) * @param {string} [opts.mcpConfigPath] If set, passed as --mcp-config + --strict-mcp-config * @param {string[]} [opts.allowedTools] Tool names to allow (--allowedTools multi-value) * @param {function} [opts.onEvent] Called for each normalized event * @param {string} [opts.claudeExe] Path or name of claude binary (default: CLAUDE_EXE env or 'claude') * @param {string[]} [opts.tools] Exclusive available-tools allowlist (--tools); removes built-ins * @param {boolean} [opts.resume] Continue an existing session (--resume) vs create (--session-id) * @param {string} [opts.home] If set, overrides HOME in child env (for service-user creds) * @param {string} [opts.cwd] Working directory for the child process * @param {number} [opts.timeoutMs] Milliseconds before SIGTERM (default: 600000) * * @returns {Promise<{text: string, toolTrace: Array<{tool:string,status:string,id?:string}>, usage: object|null}>} */ export async function runClaudeTurn(opts) { const { sessionId, systemPrompt, userText, mcpConfigPath, allowedTools = [], tools = [], resume = false, onEvent, claudeExe = process.env.CLAUDE_EXE || 'claude', home = process.env.VOID_CLAUDE_HOME, cwd, timeoutMs = 600_000, } = opts; const emit = onEvent || (() => {}); // Build args const args = [ '--print', '--output-format', 'stream-json', '--verbose', '--include-partial-messages', '--append-system-prompt', systemPrompt, // First turn creates the session (--session-id); later turns MUST continue // it with --resume (reusing --session-id on an existing session errors). ...(resume ? ['--resume', sessionId] : ['--session-id', sessionId]), ]; if (mcpConfigPath) { args.push('--mcp-config', mcpConfigPath, '--strict-mcp-config'); } if (tools.length > 0) { // --tools is the EXCLUSIVE availability allowlist: restricts the session to // exactly these tools, removing claude's built-ins (Bash/Read/Write/Grep/…). args.push('--tools', ...tools); } if (allowedTools.length > 0) { // --allowedTools accepts space-separated list as multiple values under one flag args.push('--allowedTools', ...allowedTools); } // NOTE: the user message is fed via STDIN, NOT as a positional arg. The // variadic --tools/--allowedTools flags greedily consume trailing args, so a // positional prompt after them is swallowed ("Input must be provided..."). The // CLI reads the prompt from stdin in --print mode. // Child env: clone, strip API key env vars so CLI uses subscription/OAuth auth const childEnv = { ...process.env }; delete childEnv.ANTHROPIC_API_KEY; delete childEnv.ANTHROPIC_AUTH_TOKEN; if (home) childEnv.HOME = home; // Accumulated state let text = ''; /** @type {Array<{tool:string,status:string,id?:string}>} */ const toolTrace = []; let usage = null; // Track open tool_use blocks by content index so we can emit on stop // Map const pendingTools = new Map(); // Track the last tool_use name/id for correlating tool_result events // (tool_result arrives after all content_blocks are done, with a tool_use_id) const toolById = new Map(); // id → name return new Promise((resolve) => { let proc; try { proc = spawn(claudeExe, args, { cwd: cwd || process.cwd(), env: childEnv, stdio: ['pipe', 'pipe', 'pipe'], }); } catch (err) { emit({ type: 'error', message: err.message }); resolve({ text, toolTrace, usage }); return; } // Feed the prompt via stdin (see note above), then close stdin. // Guard EPIPE: the child may exit before reading stdin. proc.stdin.on('error', () => {}); proc.stdin.write(userText); proc.stdin.end(); let timedOut = false; const timeout = setTimeout(() => { timedOut = true; emit({ type: 'error', message: `claude CLI timed out after ${timeoutMs}ms` }); proc.kill('SIGTERM'); }, timeoutMs); const rl = createInterface({ input: proc.stdout, crlfDelay: Infinity }); rl.on('line', (line) => { if (!line.trim()) return; let raw; try { raw = JSON.parse(line); } catch { // Non-JSON line — ignore silently (could be debug output) return; } processRawLine(raw); }); proc.stderr.on('data', (chunk) => { // Log stderr but don't surface as errors — many are informational // (process.stderr is not captured in tests; this is a no-op there) }); proc.on('error', (err) => { clearTimeout(timeout); emit({ type: 'error', message: err.message }); resolve({ text, toolTrace, usage }); }); proc.on('close', (code) => { clearTimeout(timeout); if (code !== 0 && code !== null && !timedOut) { emit({ type: 'error', message: `claude CLI exited with code ${code}` }); } resolve({ text, toolTrace, usage }); }); /** * Normalise one parsed JSON line. * The CLI emits two shapes: * 1. Bare top-level: { type: "system"|"assistant"|"tool_result"|"result"|"error"|"rate_limit_event"|... } * 2. Wrapped: { type: "stream_event", event: { type: "content_block_*"|"message_*", ... } } */ function processRawLine(raw) { const t = raw.type; if (t === 'stream_event') { processStreamEvent(raw.event); return; } // Bare top-level events if (t === 'system' || t === 'rate_limit_event') { // Ignored — system/init info and rate limit metadata not relevant to UI return; } if (t === 'assistant') { // Full message snapshot — duplicates deltas, ignore to avoid doubling text return; } if (t === 'tool_result') { // { type: "tool_result", tool_use_id: "...", content: [...] } const id = raw.tool_use_id; const name = toolById.get(id) || null; const result = raw.content; const ev = { type: 'tool_result', name, result }; emit(ev); return; } if (t === 'result') { // Final summary: { type:"result", subtype:"success"|"error", total_cost_usd, usage, ... } usage = raw.usage || null; const ev = { type: 'result', usage, cost: raw.total_cost_usd ?? null }; emit(ev); return; } if (t === 'error') { const message = raw.error?.message || raw.message || JSON.stringify(raw); emit({ type: 'error', message }); return; } // Unknown top-level type — ignore } /** * Process an unwrapped Anthropic streaming event (the inner `.event` from * a stream_event envelope, or a direct API-shaped event). */ function processStreamEvent(ev) { if (!ev) return; const t = ev.type; if (t === 'content_block_start') { const block = ev.content_block; if (block?.type === 'tool_use') { // Track open tool block so we can emit on stop pendingTools.set(ev.index, { name: block.name, id: block.id }); toolById.set(block.id, block.name); } // text start: no event emitted — we stream via deltas return; } if (t === 'content_block_delta') { const d = ev.delta; if (!d) return; if (d.type === 'text_delta') { text += d.text; emit({ type: 'delta', text: d.text }); } // input_json_delta: tool input accumulation — no UI event needed mid-stream return; } if (t === 'content_block_stop') { const pending = pendingTools.get(ev.index); if (pending) { pendingTools.delete(ev.index); const entry = { tool: pending.name, status: 'done', id: pending.id }; toolTrace.push(entry); emit({ type: 'tool', tool: pending.name, status: 'done', id: pending.id }); } return; } // message_start, message_delta, message_stop — no normalised events } }); }