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