chat: point OpenCode at a fixed pm2-managed server (fixes empty model picker)

The per-cwd `opencode serve` spawning is replaced by a single fixed server
(http://127.0.0.1:4096, OPENCODE_SERVER_URL) managed by pm2 — added as
`officer-opencode` in ecosystem.config.cjs (cwd = home).

Root-cause fix for the empty model selector: list-models shelled out to
`opencode models`, which failed at runtime on the deployed server (the picker got
only Claude tiers). It now reads the fixed server's GET /config/providers over HTTP —
11ms and reliable — so the curated OpenCode models (Big Pickle, Claude Haiku) show up.

- server-manager.ts — drops spawning; exposes OPENCODE_SERVER_URL + a health check.
- client.ts — createSession no longer binds a directory (sessions live in the one
  server's project).
- send-opencode.ts / opencode-sessions.ts / chat.ts — use the fixed server; drop the
  cwd/home plumbing. Session list/load/delete/rename now hit :4096.

Verified end-to-end against the live server: model list, streaming turn, session
list, and transcript load all work with no spawning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 17:12:53 +00:00
co-authored by Claude Opus 4.8
parent 059539a64a
commit 7b16f3bc4c
7 changed files with 72 additions and 170 deletions
+5 -12
View File
@@ -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 });
+25 -25
View File
@@ -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<string, unknown> }>;
};
// 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<ModelInfo[]> {
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;
+16 -16
View File
@@ -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<ClaudeSessionSummary[]> {
/** List OpenCode sessions. Never throws — returns [] if the server is unavailable. */
export async function listOpenCodeSessions(): Promise<ClaudeSessionSummary[]> {
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<C
return {
id: s.id,
title: s.title || '(untitled)',
cwd,
cwd: s.location?.directory ?? '',
createdAt: new Date(created).toISOString(),
updatedAt: new Date(updated).toISOString(),
messageCount: 0, // the session list endpoint doesn't include a turn count
@@ -27,15 +27,15 @@ export async function listOpenCodeSessions(cwd: string, home: string): Promise<C
} satisfies ClaudeSessionSummary;
});
} catch (err) {
logger.warn('Failed to list OpenCode sessions', { cwd, error: String(err) });
logger.warn('Failed to list OpenCode sessions', { error: String(err) });
return [];
}
}
/** Load one OpenCode session's transcript, rebuilt into the shared display message shape. */
export async function loadOpenCodeSession(cwd: string, home: string, sessionId: string): Promise<ClaudeSessionDetail | null> {
export async function loadOpenCodeSession(sessionId: string): Promise<ClaudeSessionDetail | null> {
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<boolean> {
export async function deleteOpenCodeSession(sessionId: string): Promise<boolean> {
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<boolean> {
export async function renameOpenCodeSession(sessionId: string, title: string): Promise<boolean> {
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) });
+3 -2
View File
@@ -105,8 +105,9 @@ class ServerConnection {
return (await res.json()) as T;
}
async createSession(directory: string, title?: string): Promise<string> {
const session = await this.postJson<{ id?: string }>('/session', { directory, title });
async createSession(title?: string): Promise<string> {
// 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;
}
+9 -108
View File
@@ -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<string, OpenCodeServer>();
const starting = new Map<string, Promise<OpenCodeServer>>();
/** 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<boolean> {
export async function isServerHealthy(): Promise<boolean> {
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<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;
}
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 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 };
}
+3 -7
View File
@@ -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<OpenCodeStreamingHandle> {
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;