| 1 | import { spawn as cpSpawn } from 'node:child_process'; |
| 2 | import { StringDecoder } from 'node:string_decoder'; |
| 3 | import type { AgentDef, AgentEvent, AgentInvokeContext, SpawnHandle } from './types.js'; |
| 4 | |
| 5 | /** |
| 6 | * Spawn an agent CLI and stream events to the listener. |
| 7 | * v0.1: only supports streamFormat='plain' fully (chunks emitted as text events). |
| 8 | * claude-stream / json-event-stream are scaffolded but yield to plain for now. |
| 9 | */ |
| 10 | export interface SpawnOptions { |
| 11 | def: AgentDef; |
| 12 | prompt: string; |
| 13 | context: AgentInvokeContext; |
| 14 | onEvent?: (event: AgentEvent) => void; |
| 15 | signal?: AbortSignal; |
| 16 | } |
| 17 | |
| 18 | export function spawnAgent(opts: SpawnOptions): SpawnHandle { |
| 19 | const { def, prompt, context, onEvent } = opts; |
| 20 | |
| 21 | // ---- http agents (anthropic-api etc): no child process, just fetch ---- |
| 22 | if (def.kind === 'http' && def.httpHandler) { |
| 23 | const ac = new AbortController(); |
| 24 | if (opts.signal) { |
| 25 | opts.signal.addEventListener('abort', () => ac.abort()); |
| 26 | } |
| 27 | const done = def.httpHandler(prompt, context, (ev) => onEvent?.(ev), ac.signal) |
| 28 | .then(({ exitCode }) => { |
| 29 | onEvent?.({ type: 'message_end', reason: exitCode === 0 ? 'ok' : 'error' }); |
| 30 | return { exitCode, signal: null as NodeJS.Signals | null }; |
| 31 | }) |
| 32 | .catch((err: Error) => { |
| 33 | onEvent?.({ type: 'error', message: err.message }); |
| 34 | onEvent?.({ type: 'message_end', reason: 'error' }); |
| 35 | return { exitCode: -1, signal: null as NodeJS.Signals | null }; |
| 36 | }); |
| 37 | return { |
| 38 | pid: 0, |
| 39 | stop: () => ac.abort(), |
| 40 | done, |
| 41 | }; |
| 42 | } |
| 43 | |
| 44 | // ---- ACP agents (AMR / vela): bidirectional JSON-RPC over stdio ---- |
| 45 | if (def.streamFormat === 'acp-json-rpc') { |
| 46 | const ac = new AbortController(); |
| 47 | if (opts.signal) opts.signal.addEventListener('abort', () => ac.abort()); |
| 48 | const done = (async () => { |
| 49 | const { resolveBin } = await import('./detect.js'); |
| 50 | const { runAcpAgent } = await import('./acp-client.js'); |
| 51 | const bin = await resolveBin(def); |
| 52 | if (!bin) { |
| 53 | onEvent?.({ type: 'error', message: `${def.name}: binary "${def.bin}" not found` }); |
| 54 | onEvent?.({ type: 'message_end', reason: 'error' }); |
| 55 | return { exitCode: -1, signal: null as NodeJS.Signals | null }; |
| 56 | } |
| 57 | const { exitCode } = await runAcpAgent({ |
| 58 | bin, |
| 59 | args: def.buildArgs(prompt, context), |
| 60 | prompt, |
| 61 | cwd: context.cwd, |
| 62 | ...((context.model || def.defaultModel) && { model: context.model || def.defaultModel }), |
| 63 | ...(def.env && { env: def.env }), |
| 64 | onEvent: (ev) => onEvent?.(ev), |
| 65 | signal: ac.signal, |
| 66 | }); |
| 67 | return { exitCode, signal: null as NodeJS.Signals | null }; |
| 68 | })(); |
| 69 | return { pid: 0, stop: () => ac.abort(), done }; |
| 70 | } |
| 71 | |
| 72 | // ---- CLI agents (claude / codex / gemini etc): spawn a child process ---- |
| 73 | // Resolving the real bin path is async on Windows (needs `where`), so the |
| 74 | // child is created inside an async IIFE. We still return a synchronous |
| 75 | // SpawnHandle: stop() goes through an AbortController, and `done` resolves |
| 76 | // when the child closes (or fails to start). |
| 77 | const args = def.buildArgs(prompt, context); |
| 78 | const env = { ...process.env, ...(def.env ?? {}) }; |
| 79 | const isWin = process.platform === 'win32'; |
| 80 | |
| 81 | const ac = new AbortController(); |
| 82 | if (opts.signal) opts.signal.addEventListener('abort', () => ac.abort()); |
| 83 | let childKill: (() => void) | null = null; |
| 84 | ac.signal.addEventListener('abort', () => childKill?.()); |
| 85 | |
| 86 | let stdoutBuf = ''; |
| 87 | let stderrBuf = ''; |
| 88 | |
| 89 | // Decode through StringDecoder, not chunk.toString('utf8'): a multi-byte |
| 90 | // UTF-8 character (e.g. any CJK glyph is 3 bytes) can straddle two `data` |
| 91 | // chunks, and decoding each chunk independently turns the split bytes into |
| 92 | // U+FFFD replacement chars (the "◆◆◆" mojibake in issue #9). StringDecoder |
| 93 | // buffers an incomplete trailing sequence until the next chunk completes it. |
| 94 | const outDecoder = new StringDecoder('utf8'); |
| 95 | const errDecoder = new StringDecoder('utf8'); |
| 96 | |
| 97 | const done = (async (): Promise<{ exitCode: number; signal: NodeJS.Signals | null }> => { |
| 98 | // On Windows most CLI agents ship as a `.cmd` shim (e.g. claude.cmd). |
| 99 | // Node's spawn() can't launch a batch file without a shell, and bare |
| 100 | // `claude` (no .cmd) resolves to nothing → ENOENT (-4058). Resolve the |
| 101 | // real path via `where` and run through a shell on win32. On POSIX the |
| 102 | // bin name on PATH works directly, so keep the cheap path. |
| 103 | let command = def.bin; |
| 104 | if (isWin) { |
| 105 | const { resolveBin } = await import('./detect.js'); |
| 106 | const resolved = await resolveBin(def); |
| 107 | if (resolved) command = resolved; |
| 108 | } |
| 109 | |
| 110 | const child = cpSpawn(command, args, { |
| 111 | cwd: context.cwd, |
| 112 | env, |
| 113 | stdio: ['pipe', 'pipe', 'pipe'], |
| 114 | ...(isWin && { shell: true }), |
| 115 | windowsHide: true, |
| 116 | }); |
| 117 | childKill = () => { |
| 118 | try { |
| 119 | child.kill('SIGTERM'); |
| 120 | } catch { |
| 121 | // ignore |
| 122 | } |
| 123 | }; |
| 124 | if (ac.signal.aborted) childKill(); |
| 125 | |
| 126 | if (def.promptViaStdin && child.stdin) { |
| 127 | child.stdin.write(prompt); |
| 128 | child.stdin.end(); |
| 129 | } |
| 130 | |
| 131 | child.stdout?.on('data', (chunk: Buffer) => { |
| 132 | const text = outDecoder.write(chunk); |
| 133 | if (!text) return; |
| 134 | stdoutBuf += text; |
| 135 | if (def.streamFormat === 'plain') { |
| 136 | onEvent?.({ type: 'text', chunk: text }); |
| 137 | } else if (def.streamFormat === 'claude-stream' || def.streamFormat === 'json-event-stream') { |
| 138 | // v0.2 hook: parse NDJSON and emit structured events |
| 139 | const lines = text.split('\n'); |
| 140 | for (const line of lines) { |
| 141 | if (!line.trim()) continue; |
| 142 | try { |
| 143 | const obj = JSON.parse(line); |
| 144 | if (typeof obj === 'object' && obj && 'type' in obj) { |
| 145 | // claude stream-json events have richer shape; treat unknown as text |
| 146 | onEvent?.({ type: 'text', chunk: JSON.stringify(obj) + '\n' }); |
| 147 | } |
| 148 | } catch { |
| 149 | onEvent?.({ type: 'text', chunk: line + '\n' }); |
| 150 | } |
| 151 | } |
| 152 | } |
| 153 | }); |
| 154 | |
| 155 | child.stderr?.on('data', (chunk: Buffer) => { |
| 156 | stderrBuf += errDecoder.write(chunk); |
| 157 | }); |
| 158 | |
| 159 | return await new Promise<{ exitCode: number; signal: NodeJS.Signals | null }>((resolve) => { |
| 160 | child.on('close', (code, signal) => { |
| 161 | // Flush any bytes the decoders were still holding (an incomplete trailing |
| 162 | // multi-byte sequence). Normally empty on a clean exit. |
| 163 | const outTail = outDecoder.end(); |
| 164 | if (outTail) { |
| 165 | stdoutBuf += outTail; |
| 166 | if (def.streamFormat === 'plain') onEvent?.({ type: 'text', chunk: outTail }); |
| 167 | } |
| 168 | stderrBuf += errDecoder.end(); |
| 169 | if (code !== 0) { |
| 170 | onEvent?.({ |
| 171 | type: 'error', |
| 172 | message: `agent exit code ${code}${stderrBuf ? `: ${stderrBuf.slice(0, 500)}` : ''}`, |
| 173 | }); |
| 174 | } |
| 175 | onEvent?.({ type: 'message_end', reason: code === 0 ? 'ok' : 'error' }); |
| 176 | resolve({ exitCode: code ?? 0, signal }); |
| 177 | }); |
| 178 | child.on('error', (err) => { |
| 179 | onEvent?.({ type: 'error', message: err.message }); |
| 180 | onEvent?.({ type: 'message_end', reason: 'error' }); |
| 181 | resolve({ exitCode: -1, signal: null }); |
| 182 | }); |
| 183 | }); |
| 184 | })(); |
| 185 | |
| 186 | return { |
| 187 | pid: 0, |
| 188 | stop: () => ac.abort(), |
| 189 | done, |
| 190 | }; |
| 191 | } |
| 192 | |
| 193 |