diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 5eb642fb..17a93b15 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -1,5 +1,3 @@ -const { homedir } = require('os'); - module.exports = { apps: [ { @@ -8,21 +6,18 @@ module.exports = { args: 'start', watch: false, }, - { - // The fixed OpenCode server the chat harness talks to (http://127.0.0.1:4096). - name: 'officer-opencode', - script: homedir() + '/.opencode/bin/opencode', - args: 'serve --port 4096 --hostname 127.0.0.1', - cwd: homedir(), - env: { HOME: homedir() }, - watch: false, - }, { name: 'officer-claude', script: 'bun', args: 'run src/servers/sidecar/claude/index.ts', watch: false, }, + { + name: 'officer-opencode', + script: 'bun', + args: 'run src/servers/sidecar/opencode/index.ts', + watch: false, + }, { name: 'officer-email', script: 'bun', diff --git a/src/server.tsx b/src/server.tsx index 0299b04f..ad4ddf29 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -15,6 +15,7 @@ import { findEntryByProxyId, touchEntry } from './servers/api/dev-server/router' import officerWeb from './apps/officer-web/index.html'; import { startBrowserRelay } from './servers/api/browser/relay'; import { registerSidecar, unregisterSidecar, handleSidecarMessage } from './servers/sidecar-registry'; +import './servers/api/chat/opencode/sidecar-server'; // subscribe to the opencode sidecar's port report import { broadcastEmailNew } from './servers/api/email/email'; import type { SidecarRegistration } from './servers/sidecar/registration-protocol'; import { toShellUsername } from './servers/data-path'; diff --git a/src/servers/api/chat/opencode/sidecar-server.ts b/src/servers/api/chat/opencode/sidecar-server.ts new file mode 100644 index 00000000..39786467 --- /dev/null +++ b/src/servers/api/chat/opencode/sidecar-server.ts @@ -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; +} diff --git a/src/servers/sidecar/opencode/index.ts b/src/servers/sidecar/opencode/index.ts new file mode 100644 index 00000000..8d3cd8a9 --- /dev/null +++ b/src/servers/sidecar/opencode/index.ts @@ -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 { + 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).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')); diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index d1b6c257..7268af1a 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -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 };