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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user