Files
platform/src/servers/sidecar/opencode/runner.ts
T
pastilhasandClaude Opus 4.8 71e39b7639 move opencode's turn output into its own sidecar
The second copy of the same problem. The opencode sidecar reported raw
ChatEvents and officer translated them, buffered the assistant text and wrote
every durable message to chat_session_events — so an officer restart mid-turn
lost whatever the model had produced since the last write, and `connect.ts`
dropped the events that arrived while it was down without a word.

Both harnesses speak ChatEvents, so the sidecar reuses the agent's session log
verbatim: translate, commit, then deliver the finished message with its cursor
id as `opencode:message`. Officer folds it into the in-memory transcript and
relays it, exactly as it now does for claude — `createEventHandler` (166 lines,
a duplicate of turn-stream.ts) and `emitToSession` are gone, and nothing in
officer writes to chat_session_events any more.

`opencode:event` stops being a wire event; it is the runner's internal report to
the sidecar it runs in, typed as such so it cannot leak back onto the socket.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 05:18:09 +00:00

247 lines
8.2 KiB
TypeScript

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 <cwd>` 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<string, RunHandle>();
// 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<typeof setTimeout> | 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<Uint8Array>) {
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<Uint8Array>) {
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<string, unknown>) ?? {},
});
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' }).
}