import { existsSync } from 'node:fs'; import type { Subprocess } from 'bun'; import type { ChatEvent, MessageCost } from '../../api/chat/types'; import type { OpenCodeRunParams } from '../protocol'; // Drives one chat turn by spawning `opencode run … --format json` and mapping its newline-delimited // JSON events to the shared ChatEvent contract. This is the reliable path: `--dir ` hard-anchors // every tool to the chat's working directory, and `run` (unlike serve + POST /message) reports tool // completion faithfully and exits when the turn is done — no wedged "running" tools. `--dangerously- // skip-permissions` auto-approves so tools never block on an approval we can't answer over this channel. // // A watchdog guards against a genuinely hung child (an interactive prompt, a `sleep`, a network stall): // an inactivity timer (reset on every stdout chunk) and an absolute per-turn ceiling both kill the // process and emit a clean `error`, so the UI never sits at "Working…" forever. const INACTIVITY_MS = 120_000; // no stdout for this long → assume wedged, kill const HARD_CAP_MS = 10 * 60_000; // absolute per-turn ceiling export type RunnerConfig = { bin: string; // absolute path to the opencode binary fallbackCwd: string; // used when params.cwd is missing/nonexistent }; // What a turn reports to the sidecar it runs in. `opencode:event` is deliberately not a wire event any // more: the sidecar translates each one into a TurnMessage and commits it before officer sees anything, // so the durable record does not depend on officer being up (see index.ts). export type RunnerMessage = | { type: 'opencode:event'; sessionKey: string; event: ChatEvent } | { type: 'opencode:session'; sessionKey: string; sessionId: string }; type Emit = (msg: RunnerMessage) => void; type RunHandle = { proc: Subprocess; killedByUser: boolean }; // One turn per sessionKey; a new turn supersedes any stale process for that key. const running = new Map(); // Shape of `opencode run --format json` events (verified live against 1.17.9). type RunPart = { type?: string; text?: string; tool?: string; callID?: string; state?: { status?: string; input?: unknown; output?: unknown; error?: unknown }; tokens?: { input?: number; output?: number }; cost?: number; }; type RunEvent = { type?: string; sessionID?: string; part?: RunPart }; export function runOpenCodeTurn(params: OpenCodeRunParams, config: RunnerConfig, emit: Emit): void { const { sessionKey } = params; // Supersede any lingering turn for this session. const stale = running.get(sessionKey); if (stale) { try { stale.proc.kill(); } catch { /* already gone */ } running.delete(sessionKey); } const args = ['run', '--format', 'json', '--dangerously-skip-permissions']; if (params.cwd) args.push('--dir', params.cwd); if (params.model) args.push('--model', params.model); if (params.resumeSessionId) args.push('--session', params.resumeSessionId); args.push(params.prompt); const cwd = params.cwd && existsSync(params.cwd) ? params.cwd : config.fallbackCwd; const proc = Bun.spawn([config.bin, ...args], { cwd, stdin: 'ignore', // == /dev/null: `run` hangs waiting on stdin otherwise stdout: 'pipe', stderr: 'pipe', }); const handle: RunHandle = { proc, killedByUser: false }; running.set(sessionKey, handle); let done = false; let reportedSession = false; let cost: MessageCost = { inputTokens: 0, outputTokens: 0, totalUSD: 0 }; let stderrTail = ''; const emitEvent = (event: ChatEvent) => emit({ type: 'opencode:event', sessionKey, event }); let inactivityTimer: ReturnType | undefined; const finish = (event: ChatEvent) => { if (done) return; done = true; clearTimeout(hardTimer); if (inactivityTimer) clearTimeout(inactivityTimer); running.delete(sessionKey); emitEvent(event); }; // ── Watchdogs ── const hardTimer = setTimeout(() => { try { proc.kill(); } catch { /* already gone */ } finish({ type: 'error', message: `OpenCode turn exceeded ${HARD_CAP_MS / 1000}s and was stopped` }); }, HARD_CAP_MS); const bumpInactivity = () => { if (done) return; if (inactivityTimer) clearTimeout(inactivityTimer); inactivityTimer = setTimeout(() => { try { proc.kill(); } catch { /* already gone */ } finish({ type: 'error', message: `OpenCode turn stalled (no output for ${INACTIVITY_MS / 1000}s) and was stopped`, }); }, INACTIVITY_MS); }; bumpInactivity(); // ── Capture a tail of stderr for error reporting ── void (async () => { const dec = new TextDecoder(); try { for await (const chunk of proc.stderr as unknown as AsyncIterable) { stderrTail = (stderrTail + dec.decode(chunk, { stream: true })).slice(-2000); } } catch { /* stream closed */ } })(); // ── Parse stdout: newline-delimited JSON events ── void (async () => { const dec = new TextDecoder(); let buf = ''; try { for await (const chunk of proc.stdout as unknown as AsyncIterable) { bumpInactivity(); buf += dec.decode(chunk, { stream: true }); let nl: number; while ((nl = buf.indexOf('\n')) >= 0) { const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1); if (line) handleLine(line); } } const last = buf.trim(); if (last) handleLine(last); } catch { /* stream closed / process killed */ } })(); function handleLine(line: string): void { let evt: RunEvent; try { evt = JSON.parse(line) as RunEvent; } catch { return; // non-JSON log line } // Report the OpenCode session id once, so the API can resume it (`--session`) next turn. if (!reportedSession && evt.sessionID) { reportedSession = true; emit({ type: 'opencode:session', sessionKey, sessionId: evt.sessionID }); } switch (evt.type) { case 'text': { // `run` emits complete text parts (not token deltas) — forward each as final text. const text = evt.part?.text; if (typeof text === 'string' && text.length > 0) emitEvent({ type: 'text', text }); return; } case 'tool_use': { // In `run`, the tool part arrives already resolved (status + output). Emit start then result. const part = evt.part; if (!part || part.type !== 'tool' || !part.callID) return; const st = part.state ?? {}; emitEvent({ type: 'tool:start', toolCallId: part.callID, toolName: part.tool ?? 'tool', toolInput: (st.input as Record) ?? {}, }); const isError = st.status === 'error'; emitEvent({ type: 'tool:result', toolCallId: part.callID, output: String((isError ? st.error : st.output) ?? ''), isError, }); return; } case 'step_finish': { // Accumulate per-step tokens/cost into the turn's MessageCost. const part = evt.part; const t = part?.tokens; cost = { inputTokens: cost.inputTokens + (t?.input ?? 0), outputTokens: cost.outputTokens + (t?.output ?? 0), totalUSD: cost.totalUSD + (typeof part?.cost === 'number' ? part.cost : 0), }; return; } default: return; // step_start etc. — nothing to forward } } // ── Completion: process exit is the authoritative turn-done signal ── void proc.exited.then((code) => { if (done) return; if (handle.killedByUser) { finish({ type: 'stopped' }); return; } if (code === 0) { finish({ type: 'result', cost }); return; } const tail = stderrTail.trim(); finish({ type: 'error', message: tail ? `OpenCode exited (${code}): ${tail.slice(-500)}` : `OpenCode exited with code ${code}`, }); }); } export function killOpenCodeTurn(sessionKey: string): void { const handle = running.get(sessionKey); if (!handle) return; handle.killedByUser = true; try { handle.proc.kill(); } catch { /* already gone */ } // proc.exited fires → finish({ type: 'stopped' }). }