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
+84 -6
View File
@@ -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.