Files
platform/src/servers/sidecar/opencode/index.ts
T
pastilhasandClaude Opus 5 8b409e8af8 finish phase 1: correct the stale comments, and put the ndjson mapping under test
Path names: the serve's cwd is DATA_PATH/opencode_server, not opencode-sidecar. Two comments said
otherwise and would send the next reader to a directory that does not exist.

Version pin: the comment claimed "verified live against 1.17.9" as though that were a property of the
code. It is a property of whichever binary is installed, and this project already runs two — 1.17.9 here,
1.18.11 on the other machine. Says so now, and points at the test as the thing that actually enforces it.

Tests, the first on the OpenCode path. `runner.ts`'s NDJSON → ChatEvent mapping was described as pure and
untested; it was untested but not pure — it lived inside `handleLine` as a closure over `emit`, the
accumulated cost and a reported-session flag, so it could not be called without spawning a binary.

Extracted as `mapRunLine`, genuinely pure: line in, {sessionId, events, costDelta} out. The two concerns
that span lines stay with the caller, because they are not properties of a line — emitting the session id
exactly once, and accumulating cost across steps. Behaviour is unchanged.

11 tests over what the mapping forwards, what it drops and what it must not turn into NaN. The last one
matters: a missing `cost` on a step_finish would otherwise propagate NaN into the turn total.

Phase 1 is complete: dead code deleted (previous commit), comments corrected, tests added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 02:58:11 +00:00

201 lines
7.5 KiB
TypeScript

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 <cwd>` 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<boolean> {
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<string, unknown>).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'));