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
@@ -1,8 +1,11 @@
import * as sidecar from '@@/sidecar-registry';
import { logger } from '../logger';
import { setOpenCodeSession } from './state';
// The OpenCode sidecar (officer-opencode) starts its `opencode serve` on a random port and reports it
// here on connect. We remember it so the OpenCode harness always routes to the current server.
// here on connect. We remember it so the OpenCode harness always routes to the current server. It also
// reports the OpenCode `ses_…` id it created for each live turn, which we persist so the next turn can
// resume it (`opencode run --session …`).
let serverPort: number | null = null;
@@ -13,6 +16,10 @@ sidecar.on('opencode:server', (msg) => {
logger.info('OpenCode sidecar server registered', { port, url: getOpenCodeServerUrl() });
});
sidecar.onOpenCodeSession((sessionKey, sessionId) => {
setOpenCodeSession(sessionKey, sessionId);
});
/** The base URL of the sidecar's OpenCode server, or null if the sidecar hasn't reported in yet. */
export function getOpenCodeServerUrl(): string | null {
return serverPort ? `http://127.0.0.1:${serverPort}` : null;