返回 html-video
acp-client.ts
根目录 / packages / runtime / src / acp-client.ts
1 /**
2 * Minimal ACP (Agent Client Protocol) JSON-RPC client over child-process stdio.
3 *
4 * Used for AMR (the `vela agent run --runtime opencode` runtime), which speaks
5 * ACP rather than printing to stdout. This is a slim, single-prompt client —
6 * just enough to drive one turn and stream the text back; it does NOT implement
7 * the full OD daemon ACP surface (permissions, terminals, MCP, multi-turn).
8 *
9 * Handshake (all newline-delimited JSON-RPC 2.0 on stdin/stdout):
10 * → initialize { protocolVersion, clientCapabilities, clientInfo }
11 * → session/new { cwd, mcpServers: [] }
12 * → session/set_model { sessionId, modelId } (only when model !== 'default')
13 * → session/prompt { sessionId, prompt: [{type:'text', text}] }
14 * ← session/update notifications: agent_message_chunk.content.text = output
15 * ← result for the prompt request id = turn complete
16 *
17 * Distilled from open-design/apps/daemon/src/acp.ts (the production version).
18 */
19 import { spawn as cpSpawn } from 'node:child_process';
20 import { resolve as resolvePath } from 'node:path';
21 import type { AgentEvent } from './types.js';
22
23 const ACP_PROTOCOL_VERSION = 1;
24 const DEFAULT_STAGE_TIMEOUT_MS = 120_000;
25
26 export interface RunAcpOptions {
27 /** Resolved absolute path to the agent binary (e.g. vela). */
28 bin: string;
29 /** Argv after the binary, e.g. ['agent','run','--runtime','opencode']. */
30 args: string[];
31 prompt: string;
32 cwd: string;
33 /** Model id for session/set_model. Omit / 'default' → skip set_model. */
34 model?: string;
35 env?: Record<string, string>;
36 onEvent: (e: AgentEvent) => void;
37 signal: AbortSignal;
38 clientName?: string;
39 clientVersion?: string;
40 }
41
42 type JsonRpcId = number;
43
44 export async function runAcpAgent(opts: RunAcpOptions): Promise<{ exitCode: number }> {
45 const cwd = resolvePath(opts.cwd || process.cwd());
46 const child = cpSpawn(opts.bin, opts.args, {
47 cwd,
48 env: { ...process.env, ...(opts.env ?? {}) },
49 stdio: ['pipe', 'pipe', 'pipe'],
50 });
51
52 if (!child.stdin || !child.stdout) {
53 opts.onEvent({ type: 'error', message: 'ACP: child has no stdio pipes' });
54 return { exitCode: -1 };
55 }
56
57 let stderrBuf = '';
58 child.stderr?.on('data', (c: Buffer) => { stderrBuf += c.toString('utf8'); });
59
60 return await new Promise<{ exitCode: number }>((done) => {
61 let settled = false;
62 let nextId = 1;
63 let sessionId: string | null = null;
64 let initId: JsonRpcId | null = null;
65 let newSessionId: JsonRpcId | null = null;
66 let setModelId: JsonRpcId | null = null;
67 let promptId: JsonRpcId | null = null;
68 let buf = '';
69 let stageTimer: ReturnType<typeof setTimeout> | null = null;
70
71 const writeRpc = (id: JsonRpcId, method: string, params: unknown) => {
72 child.stdin!.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
73 };
74
75 const armTimer = (label: string) => {
76 if (stageTimer) clearTimeout(stageTimer);
77 stageTimer = setTimeout(() => finish(-1, `ACP timed out waiting for ${label}`), DEFAULT_STAGE_TIMEOUT_MS);
78 };
79
80 const finish = (code: number, errMsg?: string) => {
81 if (settled) return;
82 settled = true;
83 if (stageTimer) clearTimeout(stageTimer);
84 if (errMsg) {
85 const tail = stderrBuf.trim().slice(-400);
86 opts.onEvent({ type: 'error', message: tail ? `${errMsg} — ${tail}` : errMsg });
87 }
88 try { child.kill('SIGTERM'); } catch { /* already gone */ }
89 done({ exitCode: code });
90 };
91
92 opts.signal.addEventListener('abort', () => finish(-1));
93
94 child.on('error', (err) => finish(-1, `ACP spawn failed: ${err.message}`));
95 child.on('exit', (code) => {
96 // Normal completion path resolves on the prompt result; an exit before
97 // that is an error (often: not logged in, surfaced on stderr).
98 if (!settled) finish(code ?? -1, `vela exited (code ${code ?? 'null'}) before the turn completed`);
99 });
100
101 const handle = (msg: Record<string, unknown>) => {
102 // --- responses to our requests (have an id + result/error) ---
103 if ('id' in msg && (('result' in msg) || ('error' in msg))) {
104 if (msg.error) {
105 const e = msg.error as { message?: string; code?: number };
106 return finish(-1, `ACP error${e.code ? ` ${e.code}` : ''}: ${e.message ?? 'unknown'}`);
107 }
108 const id = msg.id as JsonRpcId;
109 if (id === initId) {
110 // initialized → open a session
111 newSessionId = nextId++;
112 armTimer('session/new');
113 return writeRpc(newSessionId, 'session/new', { cwd, mcpServers: [] });
114 }
115 if (id === newSessionId) {
116 const result = msg.result as { sessionId?: string } | undefined;
117 sessionId = result?.sessionId ?? null;
118 if (!sessionId) return finish(-1, 'ACP: session/new returned no sessionId');
119 if (opts.model && opts.model !== 'default') {
120 setModelId = nextId++;
121 armTimer('session/set_model');
122 return writeRpc(setModelId, 'session/set_model', { sessionId, modelId: opts.model });
123 }
124 return sendPrompt();
125 }
126 if (id === setModelId) {
127 return sendPrompt();
128 }
129 if (id === promptId) {
130 // Turn complete.
131 opts.onEvent({ type: 'message_end', reason: 'ok' });
132 return finish(0);
133 }
134 return;
135 }
136
137 // --- notifications (method, no response expected) ---
138 if (msg.method === 'session/update') {
139 const params = msg.params as { update?: Record<string, unknown> } | undefined;
140 const update = params?.update;
141 if (update?.sessionUpdate === 'agent_message_chunk') {
142 const content = update.content as { text?: string } | undefined;
143 if (content?.text) {
144 armTimer('session/prompt stream');
145 opts.onEvent({ type: 'text', chunk: content.text });
146 }
147 }
148 return;
149 }
150
151 // --- requests FROM the agent (permission etc.) — auto-deny/ignore ---
152 if (msg.method === 'session/request_permission' && 'id' in msg) {
153 // We run non-interactively; reject so the agent proceeds with defaults
154 // rather than hanging. (OD picks an allow option; for our read-only
155 // text turn, declining is safe and avoids file mutations.)
156 const params = msg.params as { options?: Array<{ optionId?: string; kind?: string }> } | undefined;
157 const allow = params?.options?.find((o) => /allow|accept|yes/i.test(o.kind ?? o.optionId ?? ''));
158 const optionId = allow?.optionId ?? params?.options?.[0]?.optionId;
159 if (optionId) child.stdin!.write(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { outcome: { outcome: 'selected', optionId } } })}\n`);
160 return;
161 }
162 };
163
164 const sendPrompt = () => {
165 promptId = nextId++;
166 armTimer('session/prompt');
167 writeRpc(promptId, 'session/prompt', {
168 sessionId,
169 prompt: [{ type: 'text', text: opts.prompt }],
170 });
171 };
172
173 child.stdout!.on('data', (chunk: Buffer) => {
174 buf += chunk.toString('utf8');
175 let nl: number;
176 while ((nl = buf.indexOf('\n')) >= 0) {
177 const line = buf.slice(0, nl).trim();
178 buf = buf.slice(nl + 1);
179 if (!line) continue;
180 let msg: Record<string, unknown>;
181 try { msg = JSON.parse(line); } catch { continue; } // ignore non-JSON noise
182 try { handle(msg); } catch (e) { finish(-1, `ACP handler error: ${e instanceof Error ? e.message : e}`); }
183 }
184 });
185
186 // Kick off the handshake.
187 initId = nextId++;
188 armTimer('initialize');
189 writeRpc(initId, 'initialize', {
190 protocolVersion: ACP_PROTOCOL_VERSION,
191 clientCapabilities: { terminal: false },
192 clientInfo: { name: opts.clientName ?? 'html-video', version: opts.clientVersion ?? '0.1' },
193 });
194 });
195 }
196
196 lines TYPESCRIPT