diff --git a/src/servers/api/chat/opencode/client.ts b/src/servers/api/chat/opencode/client.ts index e4a278d1..9f671bc2 100644 --- a/src/servers/api/chat/opencode/client.ts +++ b/src/servers/api/chat/opencode/client.ts @@ -105,13 +105,10 @@ class ServerConnection { return (await res.json()) as T; } - async createSession(directory?: string, title?: string): Promise { - // `directory` binds the session's working dir (e.g. an email account dir). Omit it for the general - // /chat, so the session lives in the fixed server's own project (its cwd). - const body: Record = {}; - if (directory) body.directory = directory; - if (title) body.title = title; - const session = await this.postJson<{ id?: string }>('/session', body); + async createSession(title?: string): Promise { + // OpenCode has no per-session `directory` — the working dir is the server's cwd (server-manager + // roots the server at the desired dir). So we only optionally set a title here. + const session = await this.postJson<{ id?: string }>('/session', title ? { title } : {}); if (!session.id) throw new Error('opencode POST /session returned no id'); return session.id; } diff --git a/src/servers/api/chat/opencode/server-manager.ts b/src/servers/api/chat/opencode/server-manager.ts index c0772429..d6d0f79f 100644 --- a/src/servers/api/chat/opencode/server-manager.ts +++ b/src/servers/api/chat/opencode/server-manager.ts @@ -1,19 +1,124 @@ -// The OpenCode server is a fixed, pm2-managed process (see officer-opencode in ecosystem.config.cjs) -// listening on a known port — not spawned per-cwd by us. All OpenCode chat + session traffic goes to -// this one server; sessions live in its project store. Override the URL with OPENCODE_SERVER_URL. +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'); -export async function isServerHealthy(): Promise { +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>(); + +async function isHealthy(baseUrl: string): Promise { try { - const res = await fetch(`${OPENCODE_SERVER_URL}/api/health`, { signal: AbortSignal.timeout(3000) }); + const res = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(3000) }); return res.ok; } catch { return false; } } -/** The base URL of the fixed OpenCode server. */ -export async function ensureServer(): Promise<{ baseUrl: string }> { - return { baseUrl: OPENCODE_SERVER_URL }; +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()); } diff --git a/src/servers/channels/send-opencode.ts b/src/servers/channels/send-opencode.ts index 92946f24..e9ff8210 100644 --- a/src/servers/channels/send-opencode.ts +++ b/src/servers/channels/send-opencode.ts @@ -4,6 +4,7 @@ import { ensureServer } from '@@/api/chat/opencode/server-manager'; import { getConnection } from '@@/api/chat/opencode/client'; import { createEventMapper } from '@@/api/chat/opencode/event-mapper'; import { getOpenCodeSession, setOpenCodeSession } from '@@/api/chat/opencode/state'; +import { getHomeDirForRole } from '../data-path'; // The OpenCode analog of send-claude-code.ts's streaming path. Drives a turn against a warm // `opencode serve` over HTTP + SSE, mapping events to the shared ChatEvent contract. @@ -33,9 +34,11 @@ function splitModel(model: string): { providerID: string; modelID: string } { } export async function sendOpenCodeStreaming(params: OpenCodeStreamingParams): Promise { - logger.info('OpenCode streaming exec', { sessionKey: params.sessionKey, model: params.model }); + logger.info('OpenCode streaming exec', { sessionKey: params.sessionKey, model: params.model, cwd: params.cwd }); - const { baseUrl } = await ensureServer(); + // params.cwd set (email/project) → a server rooted there; unset (/chat) → the fixed pm2 server. + const home = getHomeDirForRole(params.email, params.role ?? ''); + const { baseUrl } = await ensureServer(params.cwd, home); const conn = getConnection(baseUrl); // Resolve the OpenCode session: a known mapping, or — when resuming from history — the sessionKey is @@ -45,7 +48,8 @@ export async function sendOpenCodeStreaming(params: OpenCodeStreamingParams): Pr (params.sessionKey.startsWith('ses_') ? params.sessionKey : undefined) ?? params.resumeSessionId; if (!opencodeSessionId) { - opencodeSessionId = await conn.createSession(params.cwd); + // The working dir comes from the server (rooted at cwd above), not a per-session param. + opencodeSessionId = await conn.createSession(); } setOpenCodeSession(params.sessionKey, opencodeSessionId); const sessionId = opencodeSessionId;