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
+36
View File
@@ -8,6 +8,7 @@ import type {
ClaudeSpawnParams,
ClaudeSpawnStreamingParams,
ClaudeCodeResult,
OpenCodeRunParams,
PtyCommand,
PtyEvent,
VncStartParams,
@@ -350,6 +351,41 @@ export function onClaudeEvent(handler: (sessionKey: string, event: ChatEvent) =>
});
}
// ── OpenCode Code (single sidecar, capability 'opencode') ──
export async function spawnOpenCodeStreaming(params: OpenCodeRunParams): Promise<void> {
const res = await sendCommand('opencode', { type: 'opencode:run-streaming', id: nextId(), params });
if (res.type === 'opencode:spawned') return;
if (res.type === 'opencode:error') throw new Error(res.error);
throw new Error('Unexpected response');
}
export function killOpenCode(sessionKey: string): void {
sendFire('opencode', { type: 'opencode:kill', id: nextId(), sessionKey });
}
export function onOpenCodeEvent(handler: (sessionKey: string, event: ChatEvent) => void): () => void {
return on('opencode:event', (msg) => {
if (msg.type === 'opencode:event') {
handler(
(msg as SidecarEvent & { type: 'opencode:event' }).sessionKey,
(msg as SidecarEvent & { type: 'opencode:event' }).event,
);
}
});
}
export function onOpenCodeSession(handler: (sessionKey: string, sessionId: string) => void): () => void {
return on('opencode:session', (msg) => {
if (msg.type === 'opencode:session') {
handler(
(msg as SidecarEvent & { type: 'opencode:session' }).sessionKey,
(msg as SidecarEvent & { type: 'opencode:session' }).sessionId,
);
}
});
}
// ── Terminal (PTY sidecar) ──
export function sendPtyCommand(cmd: PtyCommand): void {