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:
@@ -1,3 +1,5 @@
|
|||||||
|
const { homedir } = require('os');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
{
|
{
|
||||||
@@ -6,6 +8,15 @@ module.exports = {
|
|||||||
args: 'start',
|
args: 'start',
|
||||||
watch: false,
|
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',
|
name: 'officer-claude',
|
||||||
script: 'bun',
|
script: 'bun',
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
} from './opencode-sessions';
|
} from './opencode-sessions';
|
||||||
import { listChatModels } from './list-models';
|
import { listChatModels } from './list-models';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
import { getHomeDirForRole } from '../../data-path';
|
|
||||||
import { readSttConfig } from '../server-settings/stt';
|
import { readSttConfig } from '../server-settings/stt';
|
||||||
import { transcribeAudio } from '../stt/transcribe';
|
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
|
// 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.
|
// 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);
|
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.
|
// GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions.
|
||||||
chatRouter.get('/pwds', (ctx) => {
|
chatRouter.get('/pwds', (ctx) => {
|
||||||
const email = ctx.get('user').email;
|
const email = ctx.get('user').email;
|
||||||
@@ -43,7 +40,7 @@ chatRouter.get('/sessions', async (ctx) => {
|
|||||||
const email = ctx.get('user').email;
|
const email = ctx.get('user').email;
|
||||||
const cwd = cwdOf(ctx, email);
|
const cwd = cwdOf(ctx, email);
|
||||||
const claude = listClaudeSessions(email, cwd).map((s) => ({ ...s, harness: 'claude' as const }));
|
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));
|
const sessions = [...claude, ...opencode].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||||
return ctx.json({ sessions });
|
return ctx.json({ sessions });
|
||||||
});
|
});
|
||||||
@@ -53,9 +50,7 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
|||||||
const email = ctx.get('user').email;
|
const email = ctx.get('user').email;
|
||||||
const id = ctx.req.param('id');
|
const id = ctx.req.param('id');
|
||||||
const cwd = cwdOf(ctx, email);
|
const cwd = cwdOf(ctx, email);
|
||||||
const detail = isOpenCodeSessionId(id)
|
const detail = isOpenCodeSessionId(id) ? await loadOpenCodeSession(id) : loadClaudeSession(email, cwd, id);
|
||||||
? await loadOpenCodeSession(cwd, homeOf(ctx), id)
|
|
||||||
: loadClaudeSession(email, cwd, id);
|
|
||||||
if (!detail) return ctx.text('Not found', 404);
|
if (!detail) return ctx.text('Not found', 404);
|
||||||
return ctx.json(detail);
|
return ctx.json(detail);
|
||||||
});
|
});
|
||||||
@@ -65,9 +60,7 @@ chatRouter.delete('/sessions/:id', async (ctx) => {
|
|||||||
const email = ctx.get('user').email;
|
const email = ctx.get('user').email;
|
||||||
const id = ctx.req.param('id');
|
const id = ctx.req.param('id');
|
||||||
const cwd = cwdOf(ctx, email);
|
const cwd = cwdOf(ctx, email);
|
||||||
const ok = isOpenCodeSessionId(id)
|
const ok = isOpenCodeSessionId(id) ? await deleteOpenCodeSession(id) : deleteClaudeSession(email, cwd, id);
|
||||||
? await deleteOpenCodeSession(cwd, homeOf(ctx), id)
|
|
||||||
: deleteClaudeSession(email, cwd, id);
|
|
||||||
if (!ok) return ctx.text('Not found', 404);
|
if (!ok) return ctx.text('Not found', 404);
|
||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
});
|
});
|
||||||
@@ -80,7 +73,7 @@ chatRouter.patch('/sessions/:id/title', async (ctx) => {
|
|||||||
const { title } = await ctx.req.json<{ title?: string }>();
|
const { title } = await ctx.req.json<{ title?: string }>();
|
||||||
if (!title?.trim()) return ctx.text('title is required', 400);
|
if (!title?.trim()) return ctx.text('title is required', 400);
|
||||||
const ok = isOpenCodeSessionId(id)
|
const ok = isOpenCodeSessionId(id)
|
||||||
? await renameOpenCodeSession(cwd, homeOf(ctx), id, title.trim())
|
? await renameOpenCodeSession(id, title.trim())
|
||||||
: renameClaudeSession(email, cwd, id, title.trim());
|
: renameClaudeSession(email, cwd, id, title.trim());
|
||||||
if (!ok) return ctx.text('Not found', 404);
|
if (!ok) return ctx.text('Not found', 404);
|
||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { ModelInfo } from './types';
|
import type { ModelInfo } from './types';
|
||||||
import { homedir } from 'os';
|
import { OPENCODE_SERVER_URL } from './opencode/server-manager';
|
||||||
import { join } from 'path';
|
|
||||||
|
|
||||||
// The Claude harness runs the `claude` CLI, so its tiers are a fixed set.
|
// The Claude harness runs the `claude` CLI, so its tiers are a fixed set.
|
||||||
const CLAUDE_CODE_MODELS: ModelInfo[] = [
|
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 },
|
{ 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 catalog is ~58 entries).
|
||||||
|
|
||||||
// Curated OpenCode models to surface in the picker (the full `opencode models` catalog is ~58 entries).
|
|
||||||
const OPENCODE_ALLOWLIST = new Set(['opencode/big-pickle', 'opencode/claude-haiku-4-5']);
|
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;
|
let openCodeCache: ModelInfo[] | null = null;
|
||||||
|
|
||||||
export function invalidateModelCache(): void {
|
export function invalidateModelCache(): void {
|
||||||
openCodeCache = null;
|
openCodeCache = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enumerate OpenCode models via `opencode models` (ids are `providerID/modelID`, e.g.
|
type ProvidersResponse = {
|
||||||
// `opencode/claude-opus-4-8`). Metadata (context/tokens/modalities) is left at neutral defaults for
|
providers?: Array<{ id?: string; models?: Record<string, unknown> }>;
|
||||||
// now — it can be enriched later from a serve's GET /config/providers.
|
};
|
||||||
|
|
||||||
|
// 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[]> {
|
async function listOpenCodeModels(): Promise<ModelInfo[]> {
|
||||||
if (openCodeCache) return openCodeCache;
|
if (openCodeCache) return openCodeCache;
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn([OPENCODE_BIN, 'models'], { stdout: 'pipe', stderr: 'ignore' });
|
const res = await fetch(`${OPENCODE_SERVER_URL}/config/providers`, { signal: AbortSignal.timeout(5000) });
|
||||||
const out = await new Response(proc.stdout).text();
|
if (!res.ok) return [];
|
||||||
await proc.exited;
|
const data = (await res.json()) as ProvidersResponse;
|
||||||
|
|
||||||
const models: ModelInfo[] = out
|
const models: ModelInfo[] = [];
|
||||||
// eslint-disable-next-line no-control-regex
|
for (const provider of data.providers ?? []) {
|
||||||
.replace(/\x1b\[[0-9;]*m/g, '')
|
const providerId = provider.id ?? '';
|
||||||
.split('\n')
|
for (const modelId of Object.keys(provider.models ?? {})) {
|
||||||
.map((line) => line.trim())
|
const full = `${providerId}/${modelId}`;
|
||||||
.filter((line) => OPENCODE_ALLOWLIST.has(line))
|
if (!OPENCODE_ALLOWLIST.has(full)) continue;
|
||||||
.map((full) => {
|
models.push({
|
||||||
const slash = full.indexOf('/');
|
|
||||||
return {
|
|
||||||
id: full,
|
id: full,
|
||||||
name: full.slice(slash + 1),
|
name: modelId,
|
||||||
provider: full.slice(0, slash),
|
provider: providerId,
|
||||||
contextWindow: 200000,
|
contextWindow: 200000,
|
||||||
maxTokens: 8192,
|
maxTokens: 8192,
|
||||||
reasoning: false,
|
reasoning: false,
|
||||||
images: true,
|
images: true,
|
||||||
} satisfies ModelInfo;
|
});
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
openCodeCache = models;
|
openCodeCache = models;
|
||||||
return models;
|
return models;
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ import { ensureServer } from './opencode/server-manager';
|
|||||||
import { getConnection } from './opencode/client';
|
import { getConnection } from './opencode/client';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
|
||||||
// The OpenCode analog of claude-sessions.ts. OpenCode's own SQLite store is the source of truth, read
|
// The OpenCode analog of claude-sessions.ts. OpenCode's own store is the source of truth, read via the
|
||||||
// via its HTTP API (never the DB directly). A `serve` is directory-scoped, so listing sessions for a
|
// fixed pm2-managed server's HTTP API (never the DB directly). All OpenCode /chat sessions live in that
|
||||||
// cwd just means asking the serve rooted at that cwd. Returns the same shapes as the Claude reader,
|
// 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.
|
// 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. */
|
/** List OpenCode sessions. Never throws — returns [] if the server is unavailable. */
|
||||||
export async function listOpenCodeSessions(cwd: string, home: string): Promise<ClaudeSessionSummary[]> {
|
export async function listOpenCodeSessions(): Promise<ClaudeSessionSummary[]> {
|
||||||
try {
|
try {
|
||||||
const { baseUrl } = await ensureServer(cwd, home);
|
const { baseUrl } = await ensureServer();
|
||||||
const sessions = await getConnection(baseUrl).listSessions();
|
const sessions = await getConnection(baseUrl).listSessions();
|
||||||
return sessions.map((s) => {
|
return sessions.map((s) => {
|
||||||
const created = s.time?.created ?? Date.now();
|
const created = s.time?.created ?? Date.now();
|
||||||
@@ -19,7 +19,7 @@ export async function listOpenCodeSessions(cwd: string, home: string): Promise<C
|
|||||||
return {
|
return {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
title: s.title || '(untitled)',
|
title: s.title || '(untitled)',
|
||||||
cwd,
|
cwd: s.location?.directory ?? '',
|
||||||
createdAt: new Date(created).toISOString(),
|
createdAt: new Date(created).toISOString(),
|
||||||
updatedAt: new Date(updated).toISOString(),
|
updatedAt: new Date(updated).toISOString(),
|
||||||
messageCount: 0, // the session list endpoint doesn't include a turn count
|
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;
|
} satisfies ClaudeSessionSummary;
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Failed to list OpenCode sessions', { cwd, error: String(err) });
|
logger.warn('Failed to list OpenCode sessions', { error: String(err) });
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load one OpenCode session's transcript, rebuilt into the shared display message shape. */
|
/** 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 {
|
try {
|
||||||
const { baseUrl } = await ensureServer(cwd, home);
|
const { baseUrl } = await ensureServer();
|
||||||
const stored = await getConnection(baseUrl).getMessages(sessionId);
|
const stored = await getConnection(baseUrl).getMessages(sessionId);
|
||||||
|
|
||||||
const messages: ClaudeChatMessage[] = [];
|
const messages: ClaudeChatMessage[] = [];
|
||||||
@@ -70,21 +70,21 @@ export async function loadOpenCodeSession(cwd: string, home: string, sessionId:
|
|||||||
isError: Boolean(p.state?.error),
|
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) {
|
} catch (err) {
|
||||||
logger.warn('Failed to load OpenCode session', { sessionId, error: String(err) });
|
logger.warn('Failed to load OpenCode session', { sessionId, error: String(err) });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteOpenCodeSession(cwd: string, home: string, sessionId: string): Promise<boolean> {
|
export async function deleteOpenCodeSession(sessionId: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const { baseUrl } = await ensureServer(cwd, home);
|
const { baseUrl } = await ensureServer();
|
||||||
return await getConnection(baseUrl).deleteSession(sessionId);
|
return await getConnection(baseUrl).deleteSession(sessionId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Failed to delete OpenCode session', { sessionId, error: String(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 {
|
try {
|
||||||
const { baseUrl } = await ensureServer(cwd, home);
|
const { baseUrl } = await ensureServer();
|
||||||
return await getConnection(baseUrl).renameSession(sessionId, title);
|
return await getConnection(baseUrl).renameSession(sessionId, title);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Failed to rename OpenCode session', { sessionId, error: String(err) });
|
logger.warn('Failed to rename OpenCode session', { sessionId, error: String(err) });
|
||||||
|
|||||||
@@ -105,8 +105,9 @@ class ServerConnection {
|
|||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSession(directory: string, title?: string): Promise<string> {
|
async createSession(title?: string): Promise<string> {
|
||||||
const session = await this.postJson<{ id?: string }>('/session', { directory, title });
|
// 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');
|
if (!session.id) throw new Error('opencode POST /session returned no id');
|
||||||
return session.id;
|
return session.id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,118 +1,19 @@
|
|||||||
import type { Subprocess } from 'bun';
|
// The OpenCode server is a fixed, pm2-managed process (see officer-opencode in ecosystem.config.cjs)
|
||||||
import { homedir } from 'os';
|
// listening on a known port — not spawned per-cwd by us. All OpenCode chat + session traffic goes to
|
||||||
import { join } from 'path';
|
// this one server; sessions live in its project store. Override the URL with OPENCODE_SERVER_URL.
|
||||||
import { mkdirSync } from 'fs';
|
|
||||||
import { logger } from '../logger';
|
|
||||||
|
|
||||||
// The `opencode` binary. Pinned (like CLAUDE_BIN) rather than resolved from PATH; override with
|
export const OPENCODE_SERVER_URL = process.env.OPENCODE_SERVER_URL || 'http://127.0.0.1:4096';
|
||||||
// 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');
|
|
||||||
|
|
||||||
const HEALTH_TIMEOUT_MS = 20_000;
|
export async function isServerHealthy(): Promise<boolean> {
|
||||||
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> {
|
|
||||||
try {
|
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;
|
return res.ok;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitHealthy(baseUrl: string, timeoutMs: number): Promise<boolean> {
|
/** The base URL of the fixed OpenCode server. */
|
||||||
const deadline = Date.now() + timeoutMs;
|
export async function ensureServer(): Promise<{ baseUrl: string }> {
|
||||||
while (Date.now() < deadline) {
|
return { baseUrl: OPENCODE_SERVER_URL };
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { ensureServer } from '@@/api/chat/opencode/server-manager';
|
|||||||
import { getConnection } from '@@/api/chat/opencode/client';
|
import { getConnection } from '@@/api/chat/opencode/client';
|
||||||
import { createEventMapper } from '@@/api/chat/opencode/event-mapper';
|
import { createEventMapper } from '@@/api/chat/opencode/event-mapper';
|
||||||
import { getOpenCodeSession, setOpenCodeSession } from '@@/api/chat/opencode/state';
|
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
|
// 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.
|
// `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> {
|
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 });
|
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);
|
const conn = getConnection(baseUrl);
|
||||||
|
|
||||||
// Resolve the OpenCode session: a known mapping, or — when resuming from history — the sessionKey is
|
// 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 =
|
let opencodeSessionId =
|
||||||
getOpenCodeSession(params.sessionKey) ??
|
getOpenCodeSession(params.sessionKey) ??
|
||||||
(params.sessionKey.startsWith('ses_') ? params.sessionKey : undefined) ??
|
(params.sessionKey.startsWith('ses_') ? params.sessionKey : undefined) ??
|
||||||
params.resumeSessionId;
|
params.resumeSessionId;
|
||||||
if (!opencodeSessionId) {
|
if (!opencodeSessionId) {
|
||||||
opencodeSessionId = await conn.createSession(cwd);
|
opencodeSessionId = await conn.createSession();
|
||||||
}
|
}
|
||||||
setOpenCodeSession(params.sessionKey, opencodeSessionId);
|
setOpenCodeSession(params.sessionKey, opencodeSessionId);
|
||||||
const sessionId = opencodeSessionId;
|
const sessionId = opencodeSessionId;
|
||||||
|
|||||||
Reference in New Issue
Block a user