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:
@@ -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;
|
||||
}
|
||||
@@ -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'));
|
||||
@@ -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 };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user