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,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' }).
|
||||
}
|
||||
Reference in New Issue
Block a user