route OpenCode chat through the officer-opencode sidecar

Turns now run in the sidecar via `opencode run --dir <cwd> --format json
--dangerously-skip-permissions [-s <ses_>]` instead of the serve's
`POST /session/{id}/message` path. That path was unreliable at reporting
tool completion — tools finished but the turn stayed status=running,
wedging the UI at "Working…". `run` re-anchors tools to the chat cwd via
--dir, reports completion faithfully, and exits when done.

- runner.ts (new): spawn `run`, map its JSON events (text/tool_use/
  step_finish) to ChatEvent, report the `ses_` id for resume, accumulate
  cost; inactivity (120s) + hard-cap (10min) watchdogs kill a hung turn
  and emit a clean error instead of hanging forever.
- protocol.ts: opencode:run-streaming/kill commands; opencode:spawned/
  event/session events; OpenCodeRunParams.
- sidecar index.ts: wire run/kill; sweepStaleServes() on startup kills
  only an `opencode serve` whose resolved /proc/<pid>/cwd == SERVE_CWD,
  so an unclean prior exit can't leave two.
- sidecar-registry.ts: spawnOpenCodeStreaming/killOpenCode/onOpenCodeEvent/
  onOpenCodeSession helpers.
- send-opencode.ts: rewritten to mirror send-claude-code (subscribe →
  resolve resume id → spawn → kill handle).
- sidecar-server.ts: persist reported ses_ id into state for resume.
- list-models/server-manager: route to the sidecar's reported serve URL.

The serve stays up only for read-only calls that never hung (model
listing, session history).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 14:54:52 +00:00
co-authored by Claude Opus 4.8
parent dcb23b0a86
commit 5d077a4a54
8 changed files with 428 additions and 74 deletions
+4 -2
View File
@@ -1,5 +1,5 @@
import type { ModelInfo } from './types';
import { OPENCODE_SERVER_URL } from './opencode/server-manager';
import { getOpenCodeServerUrl } from './opencode/sidecar-server';
// The Claude harness runs the `claude` CLI, so its tiers are a fixed set.
const CLAUDE_CODE_MODELS: ModelInfo[] = [
@@ -24,8 +24,10 @@ type ProvidersResponse = {
// defaults for now.
async function listOpenCodeModels(): Promise<ModelInfo[]> {
if (openCodeCache) return openCodeCache;
const baseUrl = getOpenCodeServerUrl();
if (!baseUrl) return []; // sidecar hasn't reported its server yet
try {
const res = await fetch(`${OPENCODE_SERVER_URL}/config/providers`, { signal: AbortSignal.timeout(5000) });
const res = await fetch(`${baseUrl}/config/providers`, { signal: AbortSignal.timeout(5000) });
if (!res.ok) return [];
const data = (await res.json()) as ProvidersResponse;
@@ -1,19 +1,23 @@
// 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.
import { getOpenCodeServerUrl } from './sidecar-server';
export const OPENCODE_SERVER_URL = process.env.OPENCODE_SERVER_URL || 'http://127.0.0.1:4096';
// The OpenCode server is owned by the officer-opencode sidecar, which starts `opencode serve` on a
// random port (cwd = DATA_PATH/opencode-sidecar) and reports it to the API (getOpenCodeServerUrl).
// All OpenCode HTTP traffic routes to whatever port the sidecar last reported.
export async function isServerHealthy(): Promise<boolean> {
const baseUrl = getOpenCodeServerUrl();
if (!baseUrl) return false;
try {
const res = await fetch(`${OPENCODE_SERVER_URL}/api/health`, { signal: AbortSignal.timeout(3000) });
const res = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(3000) });
return res.ok;
} catch {
return false;
}
}
/** The base URL of the fixed OpenCode server. */
/** The base URL of the OpenCode server the sidecar is running. Throws if it hasn't reported in yet. */
export async function ensureServer(): Promise<{ baseUrl: string }> {
return { baseUrl: OPENCODE_SERVER_URL };
const baseUrl = getOpenCodeServerUrl();
if (!baseUrl) throw new Error('OpenCode sidecar server not available yet');
return { baseUrl };
}
@@ -1,8 +1,11 @@
import * as sidecar from '@@/sidecar-registry';
import { logger } from '../logger';
import { setOpenCodeSession } from './state';
// The OpenCode sidecar (officer-opencode) starts its `opencode serve` on a random port and reports it
// here on connect. We remember it so the OpenCode harness always routes to the current server.
// here on connect. We remember it so the OpenCode harness always routes to the current server. It also
// reports the OpenCode `ses_…` id it created for each live turn, which we persist so the next turn can
// resume it (`opencode run --session …`).
let serverPort: number | null = null;
@@ -13,6 +16,10 @@ sidecar.on('opencode:server', (msg) => {
logger.info('OpenCode sidecar server registered', { port, url: getOpenCodeServerUrl() });
});
sidecar.onOpenCodeSession((sessionKey, sessionId) => {
setOpenCodeSession(sessionKey, sessionId);
});
/** The base URL of the sidecar's OpenCode server, or null if the sidecar hasn't reported in yet. */
export function getOpenCodeServerUrl(): string | null {
return serverPort ? `http://127.0.0.1:${serverPort}` : null;