| 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 |