Each pooled per-cwd server is a ~0.5GB process; across officer restarts the previous instance's servers would orphan and pile up. On module load, kill any opencode serve on a non-fixed port (the fixed OPENCODE_SERVER_URL port is preserved). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
149 lines
5.1 KiB
TypeScript
149 lines
5.1 KiB
TypeScript
import type { Subprocess } from 'bun';
|
|
import { homedir } from 'os';
|
|
import { join } from 'path';
|
|
import { mkdirSync } from 'fs';
|
|
import { logger } from '../logger';
|
|
|
|
// OpenCode binds a session to the SERVER's working directory (POST /session has no per-session
|
|
// `directory`). So a chat that must run in a specific dir needs a server rooted there:
|
|
// - general /chat (no specific cwd) → the fixed, pm2-managed server on OPENCODE_SERVER_URL.
|
|
// - context-scoped chats (email account dir, project dir, …) → a pooled server spawned in that cwd.
|
|
|
|
export const OPENCODE_SERVER_URL = process.env.OPENCODE_SERVER_URL || 'http://127.0.0.1:4096';
|
|
const OPENCODE_BIN = process.env.OPENCODE_BIN || join(homedir(), '.opencode', 'bin', 'opencode');
|
|
|
|
const HEALTH_TIMEOUT_MS = 20_000;
|
|
const HEALTH_POLL_MS = 200;
|
|
const START_ATTEMPTS = 3;
|
|
|
|
type OpenCodeServer = { baseUrl: string; proc: Subprocess };
|
|
|
|
// Pooled per-cwd servers (one per working directory). The fixed server is not in here.
|
|
const servers = new Map<string, OpenCodeServer>();
|
|
const starting = new Map<string, Promise<OpenCodeServer>>();
|
|
|
|
// On startup, reap orphaned per-cwd serves left by a previous officer instance (each ~0.5GB) so they
|
|
// don't accumulate across restarts. The fixed server (OPENCODE_SERVER_URL) is preserved by port.
|
|
(function reapOrphanServers() {
|
|
const fixedPort = (() => {
|
|
try {
|
|
return new URL(OPENCODE_SERVER_URL).port || '4096';
|
|
} catch {
|
|
return '4096';
|
|
}
|
|
})();
|
|
try {
|
|
Bun.spawnSync([
|
|
'bash',
|
|
'-c',
|
|
`ss -tlnp 2>/dev/null | grep -F opencode | grep -oP '127\\.0\\.0\\.1:\\K[0-9]+' | sort -u | while read -r p; do ` +
|
|
`[ "$p" = "${fixedPort}" ] && continue; ` +
|
|
`pid=$(ss -tlnp 2>/dev/null | grep "127.0.0.1:$p " | grep -oP 'pid=\\K[0-9]+' | head -1); ` +
|
|
`[ -n "$pid" ] && kill "$pid" 2>/dev/null; done`,
|
|
]);
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
})();
|
|
|
|
async function isHealthy(baseUrl: string): Promise<boolean> {
|
|
try {
|
|
const res = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(3000) });
|
|
return res.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function isServerHealthy(): Promise<boolean> {
|
|
return isHealthy(OPENCODE_SERVER_URL);
|
|
}
|
|
|
|
async function waitHealthy(baseUrl: string, timeoutMs: number): Promise<boolean> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (await isHealthy(baseUrl)) return true;
|
|
await new Promise((r) => setTimeout(r, HEALTH_POLL_MS));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** 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 startServer(cwd: string, home: string): Promise<OpenCodeServer> {
|
|
mkdirSync(cwd, { recursive: true });
|
|
|
|
for (let attempt = 1; attempt <= START_ATTEMPTS; attempt += 1) {
|
|
const port = getFreePort();
|
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
// Loopback-only + no password → open on 127.0.0.1. HOME is the user's home so `opencode` reads
|
|
// that user's ~/.local/share/opencode auth. cwd roots the session working directory.
|
|
const proc = Bun.spawn([OPENCODE_BIN, 'serve', '--port', String(port), '--hostname', '127.0.0.1'], {
|
|
cwd,
|
|
env: { ...process.env, HOME: home },
|
|
stdout: 'ignore',
|
|
stderr: 'ignore',
|
|
});
|
|
|
|
if (await waitHealthy(baseUrl, HEALTH_TIMEOUT_MS)) {
|
|
logger.info('opencode serve started', { cwd, baseUrl });
|
|
proc.exited.then((code) => {
|
|
if (servers.get(cwd)?.proc === proc) servers.delete(cwd);
|
|
logger.warn('opencode serve exited', { cwd, code });
|
|
});
|
|
return { baseUrl, proc };
|
|
}
|
|
|
|
logger.warn('opencode serve failed health check, retrying', { cwd, baseUrl, attempt });
|
|
try {
|
|
proc.kill();
|
|
} catch {
|
|
/* already gone */
|
|
}
|
|
}
|
|
|
|
throw new Error(`opencode serve failed to start for cwd ${cwd}`);
|
|
}
|
|
|
|
async function ensureCwdServer(cwd: string, home: string): Promise<{ baseUrl: string }> {
|
|
const existing = servers.get(cwd);
|
|
if (existing && (await isHealthy(existing.baseUrl))) return { baseUrl: existing.baseUrl };
|
|
if (existing) {
|
|
try {
|
|
existing.proc.kill();
|
|
} catch {
|
|
/* already gone */
|
|
}
|
|
servers.delete(cwd);
|
|
}
|
|
|
|
const inflight = starting.get(cwd);
|
|
if (inflight) return { baseUrl: (await inflight).baseUrl };
|
|
|
|
const promise = startServer(cwd, home);
|
|
starting.set(cwd, promise);
|
|
try {
|
|
const server = await promise;
|
|
servers.set(cwd, server);
|
|
return { baseUrl: server.baseUrl };
|
|
} finally {
|
|
starting.delete(cwd);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The base URL of the OpenCode server a chat should use. With no cwd → the fixed pm2 server; with a
|
|
* cwd → a pooled server rooted at that directory (so the session's agent operates there).
|
|
*/
|
|
export async function ensureServer(cwd?: string, home?: string): Promise<{ baseUrl: string }> {
|
|
if (!cwd) return { baseUrl: OPENCODE_SERVER_URL };
|
|
return ensureCwdServer(cwd, home ?? homedir());
|
|
}
|