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>
257 lines
10 KiB
TypeScript
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'));
|