route OpenCode chat through the officer-opencode sidecar

Turns now run in the sidecar via `opencode run --dir <cwd> --format json
--dangerously-skip-permissions [-s <ses_>]` instead of the serve's
`POST /session/{id}/message` path. That path was unreliable at reporting
tool completion — tools finished but the turn stayed status=running,
wedging the UI at "Working…". `run` re-anchors tools to the chat cwd via
--dir, reports completion faithfully, and exits when done.

- runner.ts (new): spawn `run`, map its JSON events (text/tool_use/
  step_finish) to ChatEvent, report the `ses_` id for resume, accumulate
  cost; inactivity (120s) + hard-cap (10min) watchdogs kill a hung turn
  and emit a clean error instead of hanging forever.
- protocol.ts: opencode:run-streaming/kill commands; opencode:spawned/
  event/session events; OpenCodeRunParams.
- sidecar index.ts: wire run/kill; sweepStaleServes() on startup kills
  only an `opencode serve` whose resolved /proc/<pid>/cwd == SERVE_CWD,
  so an unclean prior exit can't leave two.
- sidecar-registry.ts: spawnOpenCodeStreaming/killOpenCode/onOpenCodeEvent/
  onOpenCodeSession helpers.
- send-opencode.ts: rewritten to mirror send-claude-code (subscribe →
  resolve resume id → spawn → kill handle).
- sidecar-server.ts: persist reported ses_ id into state for resume.
- list-models/server-manager: route to the sidecar's reported serve URL.

The serve stays up only for read-only calls that never hung (model
listing, session history).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 14:54:52 +00:00
co-authored by Claude Opus 4.8
parent dcb23b0a86
commit 5d077a4a54
8 changed files with 428 additions and 74 deletions
+236
View File
@@ -0,0 +1,236 @@
import { existsSync } from 'node:fs';
import type { Subprocess } from 'bun';
import type { ChatEvent, MessageCost } from '../../api/chat/types';
import type { OpenCodeRunParams, SidecarEvent } 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
};
type Emit = (event: SidecarEvent) => 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' }).
}