diff --git a/src/servers/api/chat/opencode/client.ts b/src/servers/api/chat/opencode/client.ts index 9f671bc2..e4a278d1 100644 --- a/src/servers/api/chat/opencode/client.ts +++ b/src/servers/api/chat/opencode/client.ts @@ -105,10 +105,13 @@ class ServerConnection { return (await res.json()) as T; } - 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 } : {}); + 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); 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 d6d0f79f..c0772429 100644 --- a/src/servers/api/chat/opencode/server-manager.ts +++ b/src/servers/api/chat/opencode/server-manager.ts @@ -1,124 +1,19 @@ -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. +// 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. 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>(); - -async function isHealthy(baseUrl: string): Promise { +export async function isServerHealthy(): Promise { try { - const res = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(3000) }); + const res = await fetch(`${OPENCODE_SERVER_URL}/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()); +/** The base URL of the fixed OpenCode server. */ +export async function ensureServer(): Promise<{ baseUrl: string }> { + return { baseUrl: OPENCODE_SERVER_URL }; } diff --git a/src/servers/channels/send-opencode.ts b/src/servers/channels/send-opencode.ts index e9ff8210..92946f24 100644 --- a/src/servers/channels/send-opencode.ts +++ b/src/servers/channels/send-opencode.ts @@ -4,7 +4,6 @@ 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. @@ -34,11 +33,9 @@ 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, cwd: params.cwd }); + logger.info('OpenCode streaming exec', { sessionKey: params.sessionKey, model: params.model }); - // 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 { baseUrl } = await ensureServer(); const conn = getConnection(baseUrl); // Resolve the OpenCode session: a known mapping, or — when resuming from history — the sessionKey is @@ -48,8 +45,7 @@ export async function sendOpenCodeStreaming(params: OpenCodeStreamingParams): Pr (params.sessionKey.startsWith('ses_') ? params.sessionKey : undefined) ?? params.resumeSessionId; if (!opencodeSessionId) { - // The working dir comes from the server (rooted at cwd above), not a per-session param. - opencodeSessionId = await conn.createSession(); + opencodeSessionId = await conn.createSession(params.cwd); } setOpenCodeSession(params.sessionKey, opencodeSessionId); const sessionId = opencodeSessionId;