diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 9b5d99c6..5eb642fb 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -1,3 +1,5 @@ +const { homedir } = require('os'); + module.exports = { apps: [ { @@ -6,6 +8,15 @@ module.exports = { args: 'start', 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', script: 'bun', diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index c0955352..54d4c466 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -18,7 +18,6 @@ import { } from './opencode-sessions'; import { listChatModels } from './list-models'; import { logger } from './logger'; -import { getHomeDirForRole } from '../../data-path'; import { readSttConfig } from '../server-settings/stt'; import { transcribeAudio } from '../stt/transcribe'; @@ -26,11 +25,9 @@ export const chatRouter = createRouter(); // The working directory a request operates on: an explicit ?cwd= (a chosen pwd), else the default // claude_sessions dir. Claude groups sessions by cwd, so this selects which project group we read. +// (OpenCode sessions all live in the one fixed server and ignore cwd.) const cwdOf = (ctx: Context, email: string): string => ctx.req.query('cwd')?.trim() || getClaudeSessionsCwd(email); -// The home dir an OpenCode `serve` runs under (so it reads the user's ~/.local/share/opencode auth). -const homeOf = (ctx: Context): string => getHomeDirForRole(ctx.get('user').email, ctx.get('user').role); - // GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions. chatRouter.get('/pwds', (ctx) => { const email = ctx.get('user').email; @@ -43,7 +40,7 @@ chatRouter.get('/sessions', async (ctx) => { const email = ctx.get('user').email; const cwd = cwdOf(ctx, email); const claude = listClaudeSessions(email, cwd).map((s) => ({ ...s, harness: 'claude' as const })); - const opencode = await listOpenCodeSessions(cwd, homeOf(ctx)); + const opencode = await listOpenCodeSessions(); const sessions = [...claude, ...opencode].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); return ctx.json({ sessions }); }); @@ -53,9 +50,7 @@ chatRouter.get('/sessions/:id', async (ctx) => { const email = ctx.get('user').email; const id = ctx.req.param('id'); const cwd = cwdOf(ctx, email); - const detail = isOpenCodeSessionId(id) - ? await loadOpenCodeSession(cwd, homeOf(ctx), id) - : loadClaudeSession(email, cwd, id); + const detail = isOpenCodeSessionId(id) ? await loadOpenCodeSession(id) : loadClaudeSession(email, cwd, id); if (!detail) return ctx.text('Not found', 404); return ctx.json(detail); }); @@ -65,9 +60,7 @@ chatRouter.delete('/sessions/:id', async (ctx) => { const email = ctx.get('user').email; const id = ctx.req.param('id'); const cwd = cwdOf(ctx, email); - const ok = isOpenCodeSessionId(id) - ? await deleteOpenCodeSession(cwd, homeOf(ctx), id) - : deleteClaudeSession(email, cwd, id); + const ok = isOpenCodeSessionId(id) ? await deleteOpenCodeSession(id) : deleteClaudeSession(email, cwd, id); if (!ok) return ctx.text('Not found', 404); return ctx.json({ ok: true }); }); @@ -80,7 +73,7 @@ chatRouter.patch('/sessions/:id/title', async (ctx) => { const { title } = await ctx.req.json<{ title?: string }>(); if (!title?.trim()) return ctx.text('title is required', 400); const ok = isOpenCodeSessionId(id) - ? await renameOpenCodeSession(cwd, homeOf(ctx), id, title.trim()) + ? await renameOpenCodeSession(id, title.trim()) : renameClaudeSession(email, cwd, id, title.trim()); if (!ok) return ctx.text('Not found', 404); return ctx.json({ ok: true }); diff --git a/src/servers/api/chat/list-models.ts b/src/servers/api/chat/list-models.ts index 1cb8172e..fc30ee08 100644 --- a/src/servers/api/chat/list-models.ts +++ b/src/servers/api/chat/list-models.ts @@ -1,6 +1,5 @@ import type { ModelInfo } from './types'; -import { homedir } from 'os'; -import { join } from 'path'; +import { OPENCODE_SERVER_URL } from './opencode/server-manager'; // The Claude harness runs the `claude` CLI, so its tiers are a fixed set. const CLAUDE_CODE_MODELS: ModelInfo[] = [ @@ -9,46 +8,47 @@ const CLAUDE_CODE_MODELS: ModelInfo[] = [ { id: 'claude-code/haiku', name: 'haiku', provider: 'claude-code', contextWindow: 200000, maxTokens: 8192, reasoning: false, images: true }, ]; -const OPENCODE_BIN = process.env.OPENCODE_BIN || join(homedir(), '.opencode', 'bin', 'opencode'); - -// Curated OpenCode models to surface in the picker (the full `opencode models` catalog is ~58 entries). +// Curated OpenCode models to surface in the picker (the full catalog is ~58 entries). const OPENCODE_ALLOWLIST = new Set(['opencode/big-pickle', 'opencode/claude-haiku-4-5']); -// Cache the OpenCode catalog; it's stable for a session and `opencode models` costs a subprocess. +// Cache the OpenCode catalog; it's stable for a session. let openCodeCache: ModelInfo[] | null = null; export function invalidateModelCache(): void { openCodeCache = null; } -// Enumerate OpenCode models via `opencode models` (ids are `providerID/modelID`, e.g. -// `opencode/claude-opus-4-8`). Metadata (context/tokens/modalities) is left at neutral defaults for -// now — it can be enriched later from a serve's GET /config/providers. +type ProvidersResponse = { + providers?: Array<{ id?: string; models?: Record }>; +}; + +// Enumerate OpenCode models from the fixed server's GET /config/providers (reliable — no subprocess). +// Model ids are `providerID/modelID` (e.g. `opencode/claude-haiku-4-5`); metadata is left at neutral +// defaults for now. async function listOpenCodeModels(): Promise { if (openCodeCache) return openCodeCache; try { - const proc = Bun.spawn([OPENCODE_BIN, 'models'], { stdout: 'pipe', stderr: 'ignore' }); - const out = await new Response(proc.stdout).text(); - await proc.exited; + const res = await fetch(`${OPENCODE_SERVER_URL}/config/providers`, { signal: AbortSignal.timeout(5000) }); + if (!res.ok) return []; + const data = (await res.json()) as ProvidersResponse; - const models: ModelInfo[] = out - // eslint-disable-next-line no-control-regex - .replace(/\x1b\[[0-9;]*m/g, '') - .split('\n') - .map((line) => line.trim()) - .filter((line) => OPENCODE_ALLOWLIST.has(line)) - .map((full) => { - const slash = full.indexOf('/'); - return { + const models: ModelInfo[] = []; + for (const provider of data.providers ?? []) { + const providerId = provider.id ?? ''; + for (const modelId of Object.keys(provider.models ?? {})) { + const full = `${providerId}/${modelId}`; + if (!OPENCODE_ALLOWLIST.has(full)) continue; + models.push({ id: full, - name: full.slice(slash + 1), - provider: full.slice(0, slash), + name: modelId, + provider: providerId, contextWindow: 200000, maxTokens: 8192, reasoning: false, images: true, - } satisfies ModelInfo; - }); + }); + } + } openCodeCache = models; return models; diff --git a/src/servers/api/chat/opencode-sessions.ts b/src/servers/api/chat/opencode-sessions.ts index 9ca8d38e..8b8e4884 100644 --- a/src/servers/api/chat/opencode-sessions.ts +++ b/src/servers/api/chat/opencode-sessions.ts @@ -3,15 +3,15 @@ import { ensureServer } from './opencode/server-manager'; import { getConnection } from './opencode/client'; import { logger } from './logger'; -// The OpenCode analog of claude-sessions.ts. OpenCode's own SQLite store is the source of truth, read -// via its HTTP API (never the DB directly). A `serve` is directory-scoped, so listing sessions for a -// cwd just means asking the serve rooted at that cwd. Returns the same shapes as the Claude reader, +// The OpenCode analog of claude-sessions.ts. OpenCode's own store is the source of truth, read via the +// fixed pm2-managed server's HTTP API (never the DB directly). All OpenCode /chat sessions live in that +// one server's project, so listing is just GET /session. Returns the same shapes as the Claude reader, // tagged harness:'opencode', so chat.ts can merge both harnesses transparently. -/** List OpenCode sessions for a working directory. Never throws — returns [] if OpenCode is unavailable. */ -export async function listOpenCodeSessions(cwd: string, home: string): Promise { +/** List OpenCode sessions. Never throws — returns [] if the server is unavailable. */ +export async function listOpenCodeSessions(): Promise { try { - const { baseUrl } = await ensureServer(cwd, home); + const { baseUrl } = await ensureServer(); const sessions = await getConnection(baseUrl).listSessions(); return sessions.map((s) => { const created = s.time?.created ?? Date.now(); @@ -19,7 +19,7 @@ export async function listOpenCodeSessions(cwd: string, home: string): Promise { +export async function loadOpenCodeSession(sessionId: string): Promise { try { - const { baseUrl } = await ensureServer(cwd, home); + const { baseUrl } = await ensureServer(); const stored = await getConnection(baseUrl).getMessages(sessionId); const messages: ClaudeChatMessage[] = []; @@ -70,21 +70,21 @@ export async function loadOpenCodeSession(cwd: string, home: string, sessionId: isError: Boolean(p.state?.error), }); } - // text-part deltas already excluded reasoning; skip reasoning/step-* parts here too. + // skip reasoning/step-* parts (parity with the streaming delta filter). } } } - return { id: sessionId, model: modelId ? `opencode/${modelId}` : 'opencode', cwd, messages }; + return { id: sessionId, model: modelId ? `opencode/${modelId}` : 'opencode', cwd: '', messages }; } catch (err) { logger.warn('Failed to load OpenCode session', { sessionId, error: String(err) }); return null; } } -export async function deleteOpenCodeSession(cwd: string, home: string, sessionId: string): Promise { +export async function deleteOpenCodeSession(sessionId: string): Promise { try { - const { baseUrl } = await ensureServer(cwd, home); + const { baseUrl } = await ensureServer(); return await getConnection(baseUrl).deleteSession(sessionId); } catch (err) { logger.warn('Failed to delete OpenCode session', { sessionId, error: String(err) }); @@ -92,9 +92,9 @@ export async function deleteOpenCodeSession(cwd: string, home: string, sessionId } } -export async function renameOpenCodeSession(cwd: string, home: string, sessionId: string, title: string): Promise { +export async function renameOpenCodeSession(sessionId: string, title: string): Promise { try { - const { baseUrl } = await ensureServer(cwd, home); + const { baseUrl } = await ensureServer(); return await getConnection(baseUrl).renameSession(sessionId, title); } catch (err) { logger.warn('Failed to rename OpenCode session', { sessionId, error: String(err) }); diff --git a/src/servers/api/chat/opencode/client.ts b/src/servers/api/chat/opencode/client.ts index 1150fce6..d529ddd0 100644 --- a/src/servers/api/chat/opencode/client.ts +++ b/src/servers/api/chat/opencode/client.ts @@ -105,8 +105,9 @@ class ServerConnection { return (await res.json()) as T; } - async createSession(directory: string, title?: string): Promise { - const session = await this.postJson<{ id?: string }>('/session', { directory, title }); + async createSession(title?: string): Promise { + // No `directory` — the session lives in the fixed server's own project (its cwd). + 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 4706e0c1..c0772429 100644 --- a/src/servers/api/chat/opencode/server-manager.ts +++ b/src/servers/api/chat/opencode/server-manager.ts @@ -1,118 +1,19 @@ -import type { Subprocess } from 'bun'; -import { homedir } from 'os'; -import { join } from 'path'; -import { mkdirSync } from 'fs'; -import { logger } from '../logger'; +// 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. -// The `opencode` binary. Pinned (like CLAUDE_BIN) rather than resolved from PATH; override with -// OPENCODE_BIN. NOTE: the installed binary is 1.17.9 — the 1.18.4 upgrade never landed on disk. -const OPENCODE_BIN = process.env.OPENCODE_BIN || join(homedir(), '.opencode', 'bin', 'opencode'); +export const OPENCODE_SERVER_URL = process.env.OPENCODE_SERVER_URL || 'http://127.0.0.1:4096'; -const HEALTH_TIMEOUT_MS = 20_000; -const HEALTH_POLL_MS = 200; -const START_ATTEMPTS = 3; - -type OpenCodeServer = { - baseUrl: string; - proc: Subprocess; - port: number; -}; - -// One warm `opencode serve` per working directory (sessions bind to a directory at creation). -const servers = new Map(); -const starting = new Map>(); - -/** 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 isHealthy(baseUrl: string): Promise { +export async function isServerHealthy(): Promise { try { - const res = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(2000) }); + const res = await fetch(`${OPENCODE_SERVER_URL}/api/health`, { signal: AbortSignal.timeout(3000) }); return res.ok; } catch { return false; } } -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; -} - -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 OPENCODE_SERVER_PASSWORD → the server is open on 127.0.0.1 (single-user box). - // HOME is set to the caller's home so `opencode` reads that user's ~/.local/share/opencode auth. - 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) => { - // Drop the cached entry on exit so the next turn respawns. - if (servers.get(cwd)?.proc === proc) servers.delete(cwd); - logger.warn('opencode serve exited', { cwd, code }); - }); - return { baseUrl, proc, port }; - } - - 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}`); -} - -/** Ensure a healthy `opencode serve` for `cwd`, returning its base URL. Dedupes concurrent starts. */ -export async function ensureServer(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) { - const s = await inflight; - return { baseUrl: s.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 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 b2f690ff..c1a84ceb 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,22 +33,19 @@ function splitModel(model: string): { providerID: string; modelID: string } { } export async function sendOpenCodeStreaming(params: OpenCodeStreamingParams): Promise { - const home = getHomeDirForRole(params.email, params.role ?? ''); - const cwd = params.cwd || home; - logger.info('OpenCode streaming exec', { sessionKey: params.sessionKey, model: params.model }); - const { baseUrl } = await ensureServer(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 - // itself the OpenCode session id (`ses_…`); otherwise create one bound to the cwd. + // itself the OpenCode session id (`ses_…`); otherwise create a new one. let opencodeSessionId = getOpenCodeSession(params.sessionKey) ?? (params.sessionKey.startsWith('ses_') ? params.sessionKey : undefined) ?? params.resumeSessionId; if (!opencodeSessionId) { - opencodeSessionId = await conn.createSession(cwd); + opencodeSessionId = await conn.createSession(); } setOpenCodeSession(params.sessionKey, opencodeSessionId); const sessionId = opencodeSessionId;