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,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;
|
||||
|
||||
@@ -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();
|
||||
try {
|
||||
await sidecar.spawnOpenCodeStreaming({
|
||||
sessionKey: params.sessionKey,
|
||||
prompt: params.prompt,
|
||||
cwd: params.cwd,
|
||||
model: params.model,
|
||||
resumeSessionId,
|
||||
});
|
||||
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' });
|
||||
} catch (err) {
|
||||
unsub();
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
kill: () => {
|
||||
void conn.abort(sessionId);
|
||||
sidecar.killOpenCode(params.sessionKey);
|
||||
unsub();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ClaudeSpawnParams,
|
||||
ClaudeSpawnStreamingParams,
|
||||
ClaudeCodeResult,
|
||||
OpenCodeRunParams,
|
||||
PtyCommand,
|
||||
PtyEvent,
|
||||
VncStartParams,
|
||||
@@ -350,6 +351,41 @@ export function onClaudeEvent(handler: (sessionKey: string, event: ChatEvent) =>
|
||||
});
|
||||
}
|
||||
|
||||
// ── OpenCode Code (single sidecar, capability 'opencode') ──
|
||||
|
||||
export async function spawnOpenCodeStreaming(params: OpenCodeRunParams): Promise<void> {
|
||||
const res = await sendCommand('opencode', { type: 'opencode:run-streaming', id: nextId(), params });
|
||||
if (res.type === 'opencode:spawned') return;
|
||||
if (res.type === 'opencode:error') throw new Error(res.error);
|
||||
throw new Error('Unexpected response');
|
||||
}
|
||||
|
||||
export function killOpenCode(sessionKey: string): void {
|
||||
sendFire('opencode', { type: 'opencode:kill', id: nextId(), sessionKey });
|
||||
}
|
||||
|
||||
export function onOpenCodeEvent(handler: (sessionKey: string, event: ChatEvent) => void): () => void {
|
||||
return on('opencode:event', (msg) => {
|
||||
if (msg.type === 'opencode:event') {
|
||||
handler(
|
||||
(msg as SidecarEvent & { type: 'opencode:event' }).sessionKey,
|
||||
(msg as SidecarEvent & { type: 'opencode:event' }).event,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function onOpenCodeSession(handler: (sessionKey: string, sessionId: string) => void): () => void {
|
||||
return on('opencode:session', (msg) => {
|
||||
if (msg.type === 'opencode:session') {
|
||||
handler(
|
||||
(msg as SidecarEvent & { type: 'opencode:session' }).sessionKey,
|
||||
(msg as SidecarEvent & { type: 'opencode:session' }).sessionId,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Terminal (PTY sidecar) ──
|
||||
|
||||
export function sendPtyCommand(cmd: PtyCommand): void {
|
||||
|
||||
@@ -1,20 +1,78 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { mkdirSync, writeFileSync, existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { runOpenCodeTurn, killOpenCodeTurn } from './runner';
|
||||
|
||||
// The OpenCode sidecar (officer-opencode). Same philosophy as officer-claude: a singleton process that
|
||||
// OWNS its runtime — here, an `opencode serve` — registers with the API server, and answers commands.
|
||||
// The serve's working directory is DATA_PATH/opencode-sidecar (cwd matters: OpenCode's tools follow the
|
||||
// server cwd). It listens on a random port, reported to the API on connect so it can route there.
|
||||
// The serve's working directory is DATA_PATH/opencode_server, which holds an AGENTS.md OpenCode loads
|
||||
// as standing instructions (the per-chat system prompt just supplies the target cwd). It listens on a
|
||||
// random port, reported to the API on connect so it can route there.
|
||||
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
const OPENCODE_BIN = process.env.OPENCODE_BIN || join(homedir(), '.opencode', 'bin', 'opencode');
|
||||
const SERVE_CWD = join(DATA_PATH, 'opencode-sidecar');
|
||||
const SERVE_CWD = join(DATA_PATH, 'opencode_server');
|
||||
const HEALTH_TIMEOUT_MS = 20_000;
|
||||
|
||||
// Standing instructions OpenCode loads from the project root (SERVE_CWD/AGENTS.md). Tells the agent to
|
||||
// take its working directory from the "Working directory for this session" line in the system prompt.
|
||||
const AGENTS_MD = `# Officer OpenCode Agent
|
||||
|
||||
## Working Directory Rules
|
||||
|
||||
**CRITICAL**: Always use absolute paths for all file operations.
|
||||
|
||||
At the start of each session, read the "Working directory for this session" value from your system prompt. Use that path as the base for all file tool calls (read, write, edit, glob, grep). Never use relative paths or assume the current directory.
|
||||
|
||||
Example:
|
||||
- ✅ \`read /absolute/path/to/project/file.ts\`
|
||||
- ❌ \`read ./project/file.ts\`
|
||||
- ❌ \`read file.ts\`
|
||||
`;
|
||||
|
||||
/**
|
||||
* Kill any orphaned `opencode serve` whose working directory is EXACTLY serveCwd — e.g. one left behind
|
||||
* by a previous sidecar that exited uncleanly (SIGKILL/crash), where its SIGTERM shutdown handler never
|
||||
* ran. Strictly scoped by resolved cwd: an `opencode serve` running from anywhere else is never touched.
|
||||
* Linux-only (reads /proc); a no-op elsewhere.
|
||||
*/
|
||||
function sweepStaleServes(serveCwd: string): void {
|
||||
let target: string;
|
||||
try {
|
||||
target = realpathSync(serveCwd);
|
||||
} catch {
|
||||
return; // dir gone → nothing can be running from it
|
||||
}
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync('/proc');
|
||||
} catch {
|
||||
return; // no procfs (non-Linux) → skip
|
||||
}
|
||||
|
||||
for (const pid of entries) {
|
||||
if (!/^\d+$/.test(pid) || Number(pid) === process.pid) continue;
|
||||
try {
|
||||
// argv is NUL-separated; require an `opencode … serve` invocation.
|
||||
const argv = readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0');
|
||||
if (!argv.some((a) => a.includes('opencode')) || !argv.includes('serve')) continue;
|
||||
|
||||
// The scope guard: only sweep serves whose real cwd matches ours.
|
||||
const cwd = realpathSync(`/proc/${pid}/cwd`);
|
||||
if (cwd !== target) continue;
|
||||
|
||||
process.kill(Number(pid), 'SIGTERM');
|
||||
console.log(`[opencode] swept stale serve pid ${pid} (cwd=${cwd})`);
|
||||
} catch {
|
||||
/* pid vanished mid-scan or /proc entry unreadable — skip */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||
function getFreePort(): number {
|
||||
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||
@@ -41,6 +99,14 @@ async function waitHealthy(baseUrl: string, timeoutMs: number): Promise<boolean>
|
||||
// ── Start the OpenCode server (cwd = DATA_PATH/opencode-sidecar) ──
|
||||
|
||||
mkdirSync(SERVE_CWD, { recursive: true });
|
||||
// Seed the project AGENTS.md before starting the serve (if absent — don't clobber local edits).
|
||||
const agentsPath = join(SERVE_CWD, 'AGENTS.md');
|
||||
if (!existsSync(agentsPath)) writeFileSync(agentsPath, AGENTS_MD);
|
||||
|
||||
// Sweep any orphaned serve from a previous unclean exit (scoped strictly to SERVE_CWD) so we never
|
||||
// end up with two serves for this directory.
|
||||
sweepStaleServes(SERVE_CWD);
|
||||
|
||||
const port = getFreePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
|
||||
@@ -65,12 +131,23 @@ console.log(`[opencode] serve healthy on port ${port}`);
|
||||
// ── Command handlers ──
|
||||
|
||||
type ReplyFn = (msg: SidecarEvent) => void;
|
||||
type SendFn = (msg: SidecarEvent) => void;
|
||||
|
||||
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
const RUNNER_CONFIG = { bin: OPENCODE_BIN, fallbackCwd: SERVE_CWD };
|
||||
|
||||
function handleCommand(cmd: SidecarCommand, reply: ReplyFn, send: SendFn) {
|
||||
switch (cmd.type) {
|
||||
case 'ping':
|
||||
reply({ type: 'pong', id: cmd.id });
|
||||
break;
|
||||
case 'opencode:run-streaming':
|
||||
// Fire the turn; events stream back via `send` (opencode:event / opencode:session / terminal).
|
||||
runOpenCodeTurn(cmd.params, RUNNER_CONFIG, send);
|
||||
reply({ type: 'opencode:spawned', id: cmd.id, sessionKey: cmd.params.sessionKey });
|
||||
break;
|
||||
case 'opencode:kill':
|
||||
killOpenCodeTurn(cmd.sessionKey);
|
||||
break;
|
||||
default:
|
||||
reply({
|
||||
type: 'error',
|
||||
@@ -87,7 +164,8 @@ const connection = createSidecarConnector({
|
||||
name: 'opencode',
|
||||
capabilities: ['opencode'],
|
||||
onCommand(cmd, reply) {
|
||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||
// Streaming turn events use a stable send (always the current ws), not the per-command reply.
|
||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn, (msg) => connection.send(msg));
|
||||
},
|
||||
onConnected() {
|
||||
// Tell the API where our OpenCode HTTP server is listening, so it can route requests there.
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import type { Subprocess } from 'bun';
|
||||
import type { ChatEvent, MessageCost } from '../../api/chat/types';
|
||||
import type { OpenCodeRunParams, SidecarEvent } from '../protocol';
|
||||
|
||||
// Drives one chat turn by spawning `opencode run … --format json` and mapping its newline-delimited
|
||||
// JSON events to the shared ChatEvent contract. This is the reliable path: `--dir <cwd>` hard-anchors
|
||||
// every tool to the chat's working directory, and `run` (unlike serve + POST /message) reports tool
|
||||
// completion faithfully and exits when the turn is done — no wedged "running" tools. `--dangerously-
|
||||
// skip-permissions` auto-approves so tools never block on an approval we can't answer over this channel.
|
||||
//
|
||||
// A watchdog guards against a genuinely hung child (an interactive prompt, a `sleep`, a network stall):
|
||||
// an inactivity timer (reset on every stdout chunk) and an absolute per-turn ceiling both kill the
|
||||
// process and emit a clean `error`, so the UI never sits at "Working…" forever.
|
||||
|
||||
const INACTIVITY_MS = 120_000; // no stdout for this long → assume wedged, kill
|
||||
const HARD_CAP_MS = 10 * 60_000; // absolute per-turn ceiling
|
||||
|
||||
export type RunnerConfig = {
|
||||
bin: string; // absolute path to the opencode binary
|
||||
fallbackCwd: string; // used when params.cwd is missing/nonexistent
|
||||
};
|
||||
|
||||
type Emit = (event: SidecarEvent) => void;
|
||||
|
||||
type RunHandle = { proc: Subprocess; killedByUser: boolean };
|
||||
|
||||
// One turn per sessionKey; a new turn supersedes any stale process for that key.
|
||||
const running = new Map<string, RunHandle>();
|
||||
|
||||
// Shape of `opencode run --format json` events (verified live against 1.17.9).
|
||||
type RunPart = {
|
||||
type?: string;
|
||||
text?: string;
|
||||
tool?: string;
|
||||
callID?: string;
|
||||
state?: { status?: string; input?: unknown; output?: unknown; error?: unknown };
|
||||
tokens?: { input?: number; output?: number };
|
||||
cost?: number;
|
||||
};
|
||||
type RunEvent = { type?: string; sessionID?: string; part?: RunPart };
|
||||
|
||||
export function runOpenCodeTurn(params: OpenCodeRunParams, config: RunnerConfig, emit: Emit): void {
|
||||
const { sessionKey } = params;
|
||||
|
||||
// Supersede any lingering turn for this session.
|
||||
const stale = running.get(sessionKey);
|
||||
if (stale) {
|
||||
try {
|
||||
stale.proc.kill();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
running.delete(sessionKey);
|
||||
}
|
||||
|
||||
const args = ['run', '--format', 'json', '--dangerously-skip-permissions'];
|
||||
if (params.cwd) args.push('--dir', params.cwd);
|
||||
if (params.model) args.push('--model', params.model);
|
||||
if (params.resumeSessionId) args.push('--session', params.resumeSessionId);
|
||||
args.push(params.prompt);
|
||||
|
||||
const cwd = params.cwd && existsSync(params.cwd) ? params.cwd : config.fallbackCwd;
|
||||
|
||||
const proc = Bun.spawn([config.bin, ...args], {
|
||||
cwd,
|
||||
stdin: 'ignore', // == /dev/null: `run` hangs waiting on stdin otherwise
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
const handle: RunHandle = { proc, killedByUser: false };
|
||||
running.set(sessionKey, handle);
|
||||
|
||||
let done = false;
|
||||
let reportedSession = false;
|
||||
let cost: MessageCost = { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
|
||||
let stderrTail = '';
|
||||
|
||||
const emitEvent = (event: ChatEvent) => emit({ type: 'opencode:event', sessionKey, event });
|
||||
|
||||
let inactivityTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const finish = (event: ChatEvent) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(hardTimer);
|
||||
if (inactivityTimer) clearTimeout(inactivityTimer);
|
||||
running.delete(sessionKey);
|
||||
emitEvent(event);
|
||||
};
|
||||
|
||||
// ── Watchdogs ──
|
||||
const hardTimer = setTimeout(() => {
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
finish({ type: 'error', message: `OpenCode turn exceeded ${HARD_CAP_MS / 1000}s and was stopped` });
|
||||
}, HARD_CAP_MS);
|
||||
|
||||
const bumpInactivity = () => {
|
||||
if (done) return;
|
||||
if (inactivityTimer) clearTimeout(inactivityTimer);
|
||||
inactivityTimer = setTimeout(() => {
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
finish({ type: 'error', message: `OpenCode turn stalled (no output for ${INACTIVITY_MS / 1000}s) and was stopped` });
|
||||
}, INACTIVITY_MS);
|
||||
};
|
||||
bumpInactivity();
|
||||
|
||||
// ── Capture a tail of stderr for error reporting ──
|
||||
void (async () => {
|
||||
const dec = new TextDecoder();
|
||||
try {
|
||||
for await (const chunk of proc.stderr as unknown as AsyncIterable<Uint8Array>) {
|
||||
stderrTail = (stderrTail + dec.decode(chunk, { stream: true })).slice(-2000);
|
||||
}
|
||||
} catch {
|
||||
/* stream closed */
|
||||
}
|
||||
})();
|
||||
|
||||
// ── Parse stdout: newline-delimited JSON events ──
|
||||
void (async () => {
|
||||
const dec = new TextDecoder();
|
||||
let buf = '';
|
||||
try {
|
||||
for await (const chunk of proc.stdout as unknown as AsyncIterable<Uint8Array>) {
|
||||
bumpInactivity();
|
||||
buf += dec.decode(chunk, { stream: true });
|
||||
let nl: number;
|
||||
while ((nl = buf.indexOf('\n')) >= 0) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (line) handleLine(line);
|
||||
}
|
||||
}
|
||||
const last = buf.trim();
|
||||
if (last) handleLine(last);
|
||||
} catch {
|
||||
/* stream closed / process killed */
|
||||
}
|
||||
})();
|
||||
|
||||
function handleLine(line: string): void {
|
||||
let evt: RunEvent;
|
||||
try {
|
||||
evt = JSON.parse(line) as RunEvent;
|
||||
} catch {
|
||||
return; // non-JSON log line
|
||||
}
|
||||
|
||||
// Report the OpenCode session id once, so the API can resume it (`--session`) next turn.
|
||||
if (!reportedSession && evt.sessionID) {
|
||||
reportedSession = true;
|
||||
emit({ type: 'opencode:session', sessionKey, sessionId: evt.sessionID });
|
||||
}
|
||||
|
||||
switch (evt.type) {
|
||||
case 'text': {
|
||||
// `run` emits complete text parts (not token deltas) — forward each as final text.
|
||||
const text = evt.part?.text;
|
||||
if (typeof text === 'string' && text.length > 0) emitEvent({ type: 'text', text });
|
||||
return;
|
||||
}
|
||||
case 'tool_use': {
|
||||
// In `run`, the tool part arrives already resolved (status + output). Emit start then result.
|
||||
const part = evt.part;
|
||||
if (!part || part.type !== 'tool' || !part.callID) return;
|
||||
const st = part.state ?? {};
|
||||
emitEvent({
|
||||
type: 'tool:start',
|
||||
toolCallId: part.callID,
|
||||
toolName: part.tool ?? 'tool',
|
||||
toolInput: (st.input as Record<string, unknown>) ?? {},
|
||||
});
|
||||
const isError = st.status === 'error';
|
||||
emitEvent({
|
||||
type: 'tool:result',
|
||||
toolCallId: part.callID,
|
||||
output: String((isError ? st.error : st.output) ?? ''),
|
||||
isError,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'step_finish': {
|
||||
// Accumulate per-step tokens/cost into the turn's MessageCost.
|
||||
const part = evt.part;
|
||||
const t = part?.tokens;
|
||||
cost = {
|
||||
inputTokens: cost.inputTokens + (t?.input ?? 0),
|
||||
outputTokens: cost.outputTokens + (t?.output ?? 0),
|
||||
totalUSD: cost.totalUSD + (typeof part?.cost === 'number' ? part.cost : 0),
|
||||
};
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return; // step_start etc. — nothing to forward
|
||||
}
|
||||
}
|
||||
|
||||
// ── Completion: process exit is the authoritative turn-done signal ──
|
||||
void proc.exited.then((code) => {
|
||||
if (done) return;
|
||||
if (handle.killedByUser) {
|
||||
finish({ type: 'stopped' });
|
||||
return;
|
||||
}
|
||||
if (code === 0) {
|
||||
finish({ type: 'result', cost });
|
||||
return;
|
||||
}
|
||||
const tail = stderrTail.trim();
|
||||
finish({
|
||||
type: 'error',
|
||||
message: tail ? `OpenCode exited (${code}): ${tail.slice(-500)}` : `OpenCode exited with code ${code}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function killOpenCodeTurn(sessionKey: string): void {
|
||||
const handle = running.get(sessionKey);
|
||||
if (!handle) return;
|
||||
handle.killedByUser = true;
|
||||
try {
|
||||
handle.proc.kill();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
// proc.exited fires → finish({ type: 'stopped' }).
|
||||
}
|
||||
@@ -16,6 +16,9 @@ export type SidecarCommand =
|
||||
| { type: 'claude:spawn-streaming'; id: string; params: ClaudeSpawnStreamingParams }
|
||||
| { type: 'claude:kill'; id: string; sessionKey: string }
|
||||
| { type: 'claude:clear-session'; id: string; sessionKey: string }
|
||||
// OpenCode — drive a turn via `opencode run … --format json` (tools re-anchored to cwd via --dir)
|
||||
| { type: 'opencode:run-streaming'; id: string; params: OpenCodeRunParams }
|
||||
| { type: 'opencode:kill'; id: string; sessionKey: string }
|
||||
// VNC
|
||||
| { type: 'vnc:start'; id: string; params: VncStartParams }
|
||||
| { type: 'vnc:stop'; id: string; email: string }
|
||||
@@ -43,6 +46,11 @@ export type SidecarEvent =
|
||||
| { type: 'email:new'; userEmail: string }
|
||||
// OpenCode — the sidecar reports where its `opencode serve` is listening (random port) on connect
|
||||
| { type: 'opencode:server'; port: number }
|
||||
// OpenCode turn streaming (analog of claude:*): spawned ack, per-event stream, session id report
|
||||
| { type: 'opencode:spawned'; id: string; sessionKey: string }
|
||||
| { type: 'opencode:event'; sessionKey: string; event: ChatEvent }
|
||||
| { type: 'opencode:session'; sessionKey: string; sessionId: string }
|
||||
| { type: 'opencode:error'; id: string; error: string }
|
||||
// Generic
|
||||
| { type: 'error'; id?: string; error: string };
|
||||
|
||||
@@ -85,6 +93,16 @@ export type ClaudeCodeResult = {
|
||||
cost: MessageCost;
|
||||
};
|
||||
|
||||
// ── OpenCode turn params ──
|
||||
|
||||
export type OpenCodeRunParams = {
|
||||
sessionKey: string;
|
||||
prompt: string;
|
||||
cwd?: string; // passed to `opencode run --dir` — hard-re-anchors tools to this directory
|
||||
model?: string; // `providerID/modelID` (e.g. opencode/claude-haiku-4-5); passed to --model verbatim
|
||||
resumeSessionId?: string; // OpenCode `ses_…` id to continue (`--session`)
|
||||
};
|
||||
|
||||
// ── VNC types ──
|
||||
|
||||
export type VncStartParams = {
|
||||
|
||||
Reference in New Issue
Block a user