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:
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user