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:
+6
-11
@@ -1,5 +1,3 @@
|
|||||||
const { homedir } = require('os');
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
{
|
{
|
||||||
@@ -8,21 +6,18 @@ module.exports = {
|
|||||||
args: 'start',
|
args: 'start',
|
||||||
watch: false,
|
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',
|
name: 'officer-claude',
|
||||||
script: 'bun',
|
script: 'bun',
|
||||||
args: 'run src/servers/sidecar/claude/index.ts',
|
args: 'run src/servers/sidecar/claude/index.ts',
|
||||||
watch: false,
|
watch: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'officer-opencode',
|
||||||
|
script: 'bun',
|
||||||
|
args: 'run src/servers/sidecar/opencode/index.ts',
|
||||||
|
watch: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'officer-email',
|
name: 'officer-email',
|
||||||
script: 'bun',
|
script: 'bun',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { findEntryByProxyId, touchEntry } from './servers/api/dev-server/router'
|
|||||||
import officerWeb from './apps/officer-web/index.html';
|
import officerWeb from './apps/officer-web/index.html';
|
||||||
import { startBrowserRelay } from './servers/api/browser/relay';
|
import { startBrowserRelay } from './servers/api/browser/relay';
|
||||||
import { registerSidecar, unregisterSidecar, handleSidecarMessage } from './servers/sidecar-registry';
|
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 { broadcastEmailNew } from './servers/api/email/email';
|
||||||
import type { SidecarRegistration } from './servers/sidecar/registration-protocol';
|
import type { SidecarRegistration } from './servers/sidecar/registration-protocol';
|
||||||
import { toShellUsername } from './servers/data-path';
|
import { toShellUsername } from './servers/data-path';
|
||||||
|
|||||||
@@ -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 }
|
| { type: 'vnc:error'; id: string; error: string }
|
||||||
// Email
|
// Email
|
||||||
| { type: 'email:new'; userEmail: string }
|
| { 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
|
// Generic
|
||||||
| { type: 'error'; id?: string; error: string };
|
| { type: 'error'; id?: string; error: string };
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user