Files
platform/src/servers/sidecar/opencode/index.ts
T
pastilhasandClaude Opus 5 e72cae4830 one default port, and it is 9000
Every PORT fallback in the tree now says 9000. There were three answers to one
question, each defensible where it was written and none of them visible from the
others:

  server.tsx and 19 sidecars   5000    a default from before there was an installer
  user-instance.ts             9010    what scripts/setup-old/setup.sh really wrote
  .env.example                 9000    what we told people to write

5000 goes first because macOS binds it — AirPlay Receiver has owned it since
Monterey, so a dev server there fails to bind or gets shadowed by something that
answers.

All 22 sites moved together, which is the point. Changing the app alone would have
turned a consistent-but-wrong default into a split one: the app on 9000 while
nineteen sidecars still dialled 5000.

9010 was the interesting one. It was the only value that ever matched a real
machine, because it is what the old installer wrote — and it was in the single
file whose disagreement would have broken chat alone, with nothing else looking
wrong. Its own comment records the same bug being fixed once already, within the
file, by a change that left it disagreeing with everything outside it.

Note what these defaults actually are: the sidecars bind nothing. user-instance.ts
has no listener at all — it builds ws:// and http:// URLs that both address the
app's single listener. So every one of these numbers is a guess at where the app
is, for a value that .env always supplies. Worth removing rather than aligning,
which is a separate change.

Not typechecked (empty node_modules, frozen installs). Every edited file parses
under `bun build --no-bundle`; the pm2 profile loads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:38:15 +00:00

257 lines
10 KiB
TypeScript

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 <cwd>` 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<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 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<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 ──
// 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'));