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>
247 lines
8.2 KiB
TypeScript
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' }).
|
|
}
|