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:
@@ -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' }).
|
||||
}
|
||||
Reference in New Issue
Block a user