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
+31 -58
View File
@@ -1,12 +1,13 @@
import type { ChatEvent } from '@@/api/chat/types';
import { logger } from '@@/api/chat/logger';
import { ensureServer } from '@@/api/chat/opencode/server-manager';
import { getConnection } from '@@/api/chat/opencode/client';
import { createEventMapper } from '@@/api/chat/opencode/event-mapper';
import { getOpenCodeSession, setOpenCodeSession } from '@@/api/chat/opencode/state';
import * as sidecar from '@@/sidecar-registry';
import { getOpenCodeSession } from '@@/api/chat/opencode/state';
// 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.
// The OpenCode analog of send-claude-code.ts: it drives a turn through the officer-opencode sidecar,
// which spawns `opencode run … --format json` (tools hard-anchored to the chat cwd via --dir) and
// streams mapped ChatEvents back over the sidecar WS. We subscribe to those events (filtered by
// sessionKey) and forward them to the caller's onEvent — the same shared contract the Claude harness
// uses, so createEventHandler and the whole UI pipeline are unchanged.
type OpenCodeStreamingParams = {
userId: number;
@@ -14,7 +15,7 @@ type OpenCodeStreamingParams = {
username: string;
prompt: string;
sessionKey: string;
cwd?: string; // the session's working dir: tags it (metadata.officer.cwd) + told to the model (system prompt)
cwd?: string;
model?: string;
role?: string;
resumeSessionId?: string;
@@ -25,68 +26,40 @@ type OpenCodeStreamingHandle = {
kill: () => void;
};
/** Split an OpenCode model id (`providerID/modelID`, e.g. `opencode/claude-opus-4-8`). */
function splitModel(model: string): { providerID: string; modelID: string } {
const slash = model.indexOf('/');
if (slash <= 0) return { providerID: 'opencode', modelID: model };
return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) };
}
// OpenCode can't set a real per-session cwd (every session runs in the fixed server's dir) — and its
// tools follow that server cwd, so relative paths/bare globs resolve to the wrong place. We instruct
// the model, via an appended (non-visible) system prompt sent every turn, to target the intended dir
// explicitly with absolute paths on every tool call.
function officerSystemPrompt(cwd: string): string {
return [
`Working directory for this session: ${cwd}`,
'',
`Your tools execute with a system working directory that is NOT \`${cwd}\`, so relative paths and bare globs (\`.\`, \`*\`, \`./x\`) resolve to the wrong place. To actually operate in \`${cwd}\`, target it explicitly on EVERY tool call:`,
`- File/search tools (read, write, edit, ls, glob, grep, …): always pass an ABSOLUTE path under \`${cwd}\` — e.g. \`${cwd}/notes/todo.md\`, or glob \`${cwd}/**/*\`. Never use a relative path or a bare \`.\`/\`*\`.`,
`- Shell/bash: start every command with \`cd ${cwd}\` (or use absolute paths beneath it).`,
'',
`Interpret "here", "this directory", "the current folder", or any relative path the user gives as a location inside \`${cwd}\`. Keep all your work within \`${cwd}\` and its subdirectories unless the user explicitly directs you elsewhere.`,
].join('\n');
}
export async function sendOpenCodeStreaming(params: OpenCodeStreamingParams): Promise<OpenCodeStreamingHandle> {
logger.info('OpenCode streaming exec', { sessionKey: params.sessionKey, model: params.model });
logger.info('OpenCode streaming exec (via sidecar)', { sessionKey: params.sessionKey, model: params.model });
const { baseUrl } = await ensureServer();
const conn = getConnection(baseUrl);
// Forward this session's turn events; unsubscribe on the terminal event.
const unsub = sidecar.onOpenCodeEvent((sessionKey, event) => {
if (sessionKey !== params.sessionKey) return;
params.onEvent(event);
if (event.type === 'result' || event.type === 'error' || event.type === 'stopped') unsub();
});
// Resolve the OpenCode session: a known mapping, or — when resuming from history — the sessionKey is
// itself the OpenCode session id (`ses_…`); otherwise create a new one, tagged with its cwd (for
// listing). The cwd rides every message from the client, so the system prompt below stays consistent.
let opencodeSessionId =
// Resume an existing OpenCode session when we know its id: a stored mapping (set from the sidecar's
// opencode:session report), the sessionKey itself when it's already a `ses_…` id (history resume), or
// an explicit resumeSessionId. Otherwise the sidecar's `run` creates a fresh session.
const resumeSessionId =
getOpenCodeSession(params.sessionKey) ??
(params.sessionKey.startsWith('ses_') ? params.sessionKey : undefined) ??
params.resumeSessionId;
if (!opencodeSessionId) {
opencodeSessionId = await conn.createSession(params.cwd ? { officer: { cwd: params.cwd } } : undefined);
}
setOpenCodeSession(params.sessionKey, opencodeSessionId);
const sessionId = opencodeSessionId;
let unsub = () => {};
const mapper = createEventMapper((event: ChatEvent) => {
params.onEvent(event);
if (event.type === 'result' || event.type === 'error') unsub();
});
unsub = conn.subscribe(sessionId, mapper);
const { providerID, modelID } = splitModel(params.model ?? '');
const system = params.cwd ? officerSystemPrompt(params.cwd) : undefined;
// Fire the turn; assistant tokens + tool calls stream back over the SSE subscription above.
conn.postMessage(sessionId, providerID, modelID, params.prompt, system).catch((err) => {
logger.error('OpenCode postMessage failed', { sessionKey: params.sessionKey, error: String(err) });
params.onEvent({ type: 'error', message: 'Failed to send message to OpenCode' });
try {
await sidecar.spawnOpenCodeStreaming({
sessionKey: params.sessionKey,
prompt: params.prompt,
cwd: params.cwd,
model: params.model,
resumeSessionId,
});
} catch (err) {
unsub();
});
throw err;
}
return {
kill: () => {
void conn.abort(sessionId);
sidecar.killOpenCode(params.sessionKey);
unsub();
},
};