opencode: add the OpenCode sidecar (step 1 — serve lifecycle + port report)

New officer-opencode sidecar (same philosophy as officer-claude): a singleton that owns
an `opencode serve` running from DATA_PATH/opencode-sidecar (created if missing) on a
random port, registers with the API as capability 'opencode', and reports its port via a
new `opencode:server` protocol event. The API stores it (sidecar-server.ts, wired in
server.tsx via getOpenCodeServerUrl). ecosystem.config.cjs runs the sidecar instead of a
bare pm2 serve. Turn-running + API rewiring come in later steps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 13:43:57 +00:00
co-authored by Claude Opus 4.8
parent 6fb48af402
commit 203a8b0708
5 changed files with 141 additions and 11 deletions
+113
View File
@@ -0,0 +1,113 @@
import { mkdirSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { DATA_PATH } from '../../data-path';
import { createSidecarConnector } from '../connect';
import type { SidecarCommand, SidecarEvent } from '../protocol';
// 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.
// The serve's working directory is DATA_PATH/opencode-sidecar (cwd matters: OpenCode's tools follow the
// server cwd). It listens on a random port, reported to the API on connect so it can route there.
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-sidecar');
const HEALTH_TIMEOUT_MS = 20_000;
/** 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-sidecar) ──
mkdirSync(SERVE_CWD, { recursive: true });
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;
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
switch (cmd.type) {
case 'ping':
reply({ type: 'pong', id: cmd.id });
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`);
},
});
// ── 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'));