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(); const starting = new Map>(); // 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 { try { const res = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(3000) }); return res.ok; } catch { return false; } } export async function isServerHealthy(): Promise { return isHealthy(OPENCODE_SERVER_URL); } async function waitHealthy(baseUrl: string, timeoutMs: number): Promise { 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 { 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()); }