import { mkdirSync, readdirSync, readFileSync, realpathSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { DATA_PATH } from '../../data-path'; import { createSidecarConnector } from '../connect'; import { createSessionLogStore } from '../claude/session-log'; import type { SidecarCommand, SidecarEvent } from '../protocol'; import { runOpenCodeTurn, killOpenCodeTurn } from './runner'; // The OpenCode sidecar (officer-opencode). Same philosophy as officer-claude: a singleton process that // OWNS its runtime — here, an `opencode serve` — registers with the API server, and answers commands. It // listens on a random port, reported to the API on connect so it can route there. // // The serve's working directory is DATA_PATH/opencode_server, and that is now ALL it is: turns do not go // through the serve, they are `opencode run --dir ` subprocesses (runner.ts). The serve is used for // session CRUD and model enumeration only. const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`; const OPENCODE_BIN = process.env.OPENCODE_BIN || join(homedir(), '.opencode', 'bin', 'opencode'); const SERVE_CWD = join(DATA_PATH, 'opencode_server'); const HEALTH_TIMEOUT_MS = 20_000; /** * Kill any orphaned `opencode serve` whose working directory is EXACTLY serveCwd — e.g. one left behind * by a previous sidecar that exited uncleanly (SIGKILL/crash), where its SIGTERM shutdown handler never * ran. Strictly scoped by resolved cwd: an `opencode serve` running from anywhere else is never touched. * Linux-only (reads /proc); a no-op elsewhere. */ function sweepStaleServes(serveCwd: string): void { let target: string; try { target = realpathSync(serveCwd); } catch { return; // dir gone → nothing can be running from it } let entries: string[]; try { entries = readdirSync('/proc'); } catch { return; // no procfs (non-Linux) → skip } for (const pid of entries) { if (!/^\d+$/.test(pid) || Number(pid) === process.pid) continue; try { // argv is NUL-separated; require an `opencode … serve` invocation. const argv = readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0'); if (!argv.some((a) => a.includes('opencode')) || !argv.includes('serve')) continue; // The scope guard: only sweep serves whose real cwd matches ours. const cwd = realpathSync(`/proc/${pid}/cwd`); if (cwd !== target) continue; process.kill(Number(pid), 'SIGTERM'); console.log(`[opencode] swept stale serve pid ${pid} (cwd=${cwd})`); } catch { /* pid vanished mid-scan or /proc entry unreadable — skip */ } } } /** Grab an ephemeral free port by briefly binding one and releasing it. */ function getFreePort(): number { const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); const port = probe.port; probe.stop(true); if (port == null) throw new Error('failed to acquire a free port'); return port; } async function waitHealthy(baseUrl: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { const res = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(2000) }); if (res.ok) return true; } catch { /* not up yet */ } await new Promise((r) => setTimeout(r, 200)); } return false; } // ── Start the OpenCode server (cwd = DATA_PATH/opencode_server) ── mkdirSync(SERVE_CWD, { recursive: true }); // An AGENTS.md used to be seeded here, telling the agent to read its working directory from the // "Working directory for this session" line in its system prompt. It was deleted on 2026-08-10 because // it pointed at nothing: the run path (runner.ts) sends NO system prompt at all, so there was no such // line to read, and the instruction had been inert since turns moved off the serve. // // What it was standing in for, `--dir` does properly. Tested against the installed binary: `--dir` // anchors the agent's own file operations, not merely the process cwd, and the anchor survives a // multi-step turn including a write (docs/opencode-phase0-review.md). Nothing replaces this. // Sweep any orphaned serve from a previous unclean exit (scoped strictly to SERVE_CWD) so we never // end up with two serves for this directory. sweepStaleServes(SERVE_CWD); const port = getFreePort(); const baseUrl = `http://127.0.0.1:${port}`; console.log(`[opencode] starting serve on ${baseUrl} (cwd=${SERVE_CWD})`); const serve = Bun.spawn([OPENCODE_BIN, 'serve', '--port', String(port), '--hostname', '127.0.0.1'], { cwd: SERVE_CWD, stdout: 'inherit', stderr: 'inherit', }); if (!(await waitHealthy(baseUrl, HEALTH_TIMEOUT_MS))) { console.error('[opencode] serve failed its health check'); try { serve.kill(); } catch { /* already gone */ } process.exit(1); } console.log(`[opencode] serve healthy on port ${port}`); // ── Command handlers ── type ReplyFn = (msg: SidecarEvent) => void; const RUNNER_CONFIG = { bin: OPENCODE_BIN, fallbackCwd: SERVE_CWD }; function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { switch (cmd.type) { case 'ping': reply({ type: 'pong', id: cmd.id }); break; case 'opencode:run-streaming': { const { sessionKey, durable = true } = cmd.params; // Turn output goes through the session log: translated to TurnMessages and committed to // chat_session_events here, in the process that produced it. Officer being down during a turn // no longer costs the transcript — the browser replays it from its cursor. runOpenCodeTurn(cmd.params, RUNNER_CONFIG, (msg) => { if (msg.type === 'opencode:event') { sessionLog.push(sessionKey, msg.event, durable); return; } // opencode:session is a routing fact (which `ses_…` to resume), not transcript — send it live. connection.send(msg); }); reply({ type: 'opencode:spawned', id: cmd.id, sessionKey }); break; } case 'opencode:kill': killOpenCodeTurn(cmd.sessionKey); sessionLog.drop(cmd.sessionKey); break; default: reply({ type: 'error', id: (cmd as SidecarCommand).id, error: `Unknown command type: ${(cmd as Record).type}`, }); } } // ── Register with the API server ── const connection = createSidecarConnector({ apiUrl: `${API_URL}/api/sidecar/register`, name: 'opencode', capabilities: ['opencode'], onCommand(cmd, reply) { handleCommand(cmd as SidecarCommand, reply as ReplyFn); }, onConnected() { // Tell the API where our OpenCode HTTP server is listening, so it can route requests there. connection.send({ type: 'opencode:server', port }); console.log(`[opencode] reported server port ${port} to API`); }, }); // Translate → commit → deliver, in that order and one at a time per session. Shared with the agent // sidecar (`claude/session-log.ts`): both harnesses speak ChatEvents, so the translation and the write // are the same code, and only the wire event type differs. const sessionLog = createSessionLogStore((d) => connection.send({ type: 'opencode:message', sessionKey: d.sessionId, msg: d.msg, seq: d.seq }), ); // ── Graceful shutdown ── function shutdown(signal: string) { console.log(`[opencode] ${signal} received, stopping serve...`); connection.destroy(); try { serve.kill(); } catch { /* already gone */ } process.exit(0); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));