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
@@ -0,0 +1,19 @@
import * as sidecar from '@@/sidecar-registry';
import { logger } from '../logger';
// The OpenCode sidecar (officer-opencode) starts its `opencode serve` on a random port and reports it
// here on connect. We remember it so the OpenCode harness always routes to the current server.
let serverPort: number | null = null;
sidecar.on('opencode:server', (msg) => {
const port = (msg as { port?: number }).port;
if (typeof port !== 'number') return;
serverPort = port;
logger.info('OpenCode sidecar server registered', { port, url: getOpenCodeServerUrl() });
});
/** The base URL of the sidecar's OpenCode server, or null if the sidecar hasn't reported in yet. */
export function getOpenCodeServerUrl(): string | null {
return serverPort ? `http://127.0.0.1:${serverPort}` : null;
}
+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'));
+2
View File
@@ -41,6 +41,8 @@ export type SidecarEvent =
| { type: 'vnc:error'; id: string; error: string }
// Email
| { type: 'email:new'; userEmail: string }
// OpenCode — the sidecar reports where its `opencode serve` is listening (random port) on connect
| { type: 'opencode:server'; port: number }
// Generic
| { type: 'error'; id?: string; error: string };