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