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
+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 };
}