import { mkdirSync, readdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { DATA_PATH } from '../../data-path'; import { createSidecarConnector } from '../connect'; import { sweepRecordedServe } from './serve-sweep'; import { connectProviderCredential } from './connect-credential'; import { createSessionLogStore } from '../claude/session-log'; import type { SidecarCommand, SidecarEvent } from '../protocol'; import type { RunnerMessage } from './serve-runner'; import { runOpenCodeTurnOnServe, killServeTurn, listRunningServeTurns, stopAllServeTurns } from './serve-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 runs EVERYTHING: turns (serve-runner.ts), session CRUD and model enumeration. It used to be // CRUD only, with turns spawned as `opencode run --dir ` subprocesses — that path was deleted on // 2026-08-10 once the serve had streaming, mid-turn injection and interrupt working end to end. const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '9000'}`; 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 so we never end up with two for this directory. // Two mechanisms, deliberately: the pidfile is portable and precise, the /proc scan is the Linux-only // backstop for a serve whose pidfile was lost (killed -9 mid-write, or predating the pidfile entirely). const SERVE_PID_FILE = join(SERVE_CWD, 'serve.pid'); sweepRecordedServe(SERVE_PID_FILE); 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', }); // Recorded before the health check, not after: an unhealthy serve is exactly the kind that gets left // behind, and it still needs sweeping next time. try { writeFileSync(SERVE_PID_FILE, String(serve.pid)); } catch (err) { console.error('[opencode] could not record the serve pid; sweeping will fall back to /proc', err); } 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}`); // The new /api surface keeps credentials separately from auth.json and would otherwise reach free models // only — silently. Best-effort and not awaited for correctness: turns go through `opencode run`, which // reads auth.json directly and does not depend on this. void connectProviderCredential(baseUrl); // ── Command handlers ── type ReplyFn = (msg: SidecarEvent) => void; const SERVE_RUNNER_CONFIG = { baseUrl, 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. // Same emit contract either way, which is what makes the switch a switch: the durable commit and // the routing fact behave identically whether a subprocess or the serve produced the event. const onMessage = (msg: RunnerMessage) => { 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); }; void runOpenCodeTurnOnServe(cmd.params, SERVE_RUNNER_CONFIG, onMessage); reply({ type: 'opencode:spawned', id: cmd.id, sessionKey }); break; } case 'opencode:list': reply({ type: 'opencode:sessions', id: cmd.id, sessions: listRunningServeTurns() }); break; case 'opencode:kill': // An INTERRUPT, not a kill: the turn stops and the session survives, so the conversation can be // continued rather than only re-opened. void killServeTurn(cmd.sessionKey, SERVE_RUNNER_CONFIG); 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 ── // PM2 sends SIGTERM and follows with SIGKILL shortly after, so everything below is on a budget. The // flush is bounded rather than awaited outright: losing the explanation is bad, hanging the restart is // worse, and an unbounded await on a wedged Postgres would do exactly that. const SHUTDOWN_FLUSH_MS = 1_000; let shuttingDown = false; async function shutdown(signal: string) { if (shuttingDown) return; // SIGINT after SIGTERM must not re-enter and cut the flush short shuttingDown = true; // Before the connection goes: killing a turn produces an event, and that event's durable write travels // over this socket. Tearing it down first would stop every turn silently — the exact outcome this is // here to prevent. const message = `The OpenCode sidecar restarted (${signal}), so this turn stopped.`; const stopped = stopAllServeTurns(message); if (stopped > 0) { console.log(`[opencode] ${signal} received, stopping ${stopped} in-flight turn(s)...`); await Promise.race([sessionLog.flush(), Bun.sleep(SHUTDOWN_FLUSH_MS)]); } console.log(`[opencode] ${signal} received, stopping serve...`); connection.destroy(); try { serve.kill(); } catch { /* already gone */ } // We killed it ourselves, so the record has done its job. Leaving it would make the next start check a // pid that is either gone or, worse, reused. try { unlinkSync(SERVE_PID_FILE); } catch { /* never written, or already gone */ } process.exit(0); } process.on('SIGTERM', () => void shutdown('SIGTERM')); process.on('SIGINT', () => void shutdown('SIGINT'));