返回 html-video
spawn.ts
根目录 / packages / runtime / src / spawn.ts
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
193 lines TYPESCRIPT