Merge remote-tracking branch 'gitea/master' into sidecar-app-store
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// Hand the serve's NEW api surface the provider key it cannot find on its own.
|
||||
//
|
||||
// ── The problem this exists to prevent ──
|
||||
//
|
||||
// opencode keeps credentials in two unrelated places. The CLI, `opencode run` and the legacy
|
||||
// `/session/*` surface read `~/.local/share/opencode/auth.json`. The newer `/api/session/*` surface —
|
||||
// the one with `delivery: "steer" | "queue"`, `/interrupt` and a resumable per-session event stream —
|
||||
// reads its own integration store instead (`/api/integration`, `/api/credential`), and knows nothing
|
||||
// about that file.
|
||||
//
|
||||
// With no credential the new pipeline does not fail. It falls back to whatever needs none, which is the
|
||||
// free tier, and a request for a paid model is simply never executed: prompt accepted, `prompt.admitted`
|
||||
// and `prompted` emitted, no step, no error, no assistant message, forever. That silence cost most of an
|
||||
// afternoon to diagnose (docs/opencode-fork-decision.md) and would cost it again on every new machine.
|
||||
//
|
||||
// So the sidecar connects it at start-up rather than relying on somebody having run a curl by hand.
|
||||
//
|
||||
// ── Deliberately best-effort ──
|
||||
//
|
||||
// Never throws and never blocks start-up. Turns run through `opencode run`, which uses `auth.json` and
|
||||
// is unaffected by any of this; failing here costs the new pipeline only, and the sidecar is far more
|
||||
// useful up than down. The connection persists in opencode's own store, so this is a no-op on every
|
||||
// start after the first.
|
||||
|
||||
const AUTH_PATH = join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'opencode', 'auth.json');
|
||||
|
||||
const ATTEMPTS = 6;
|
||||
const RETRY_DELAY_MS = 1_500;
|
||||
|
||||
/** The provider key opencode already holds for itself, or null. Never logged, never returned to callers. */
|
||||
function readProviderKey(providerId: string): string | null {
|
||||
try {
|
||||
const auth = JSON.parse(readFileSync(AUTH_PATH, 'utf8')) as Record<string, { type?: string; key?: string }>;
|
||||
const entry = auth[providerId];
|
||||
return entry?.type === 'api' && typeof entry.key === 'string' && entry.key ? entry.key : null;
|
||||
} catch {
|
||||
return null; // no auth file, unreadable, or not JSON — nothing to connect
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect `auth.json`'s key for one provider to the serve's integration store.
|
||||
*
|
||||
* `providerId` doubles as the integration id: opencode names them the same, so the Zen key stored under
|
||||
* `opencode` connects to integration `opencode`.
|
||||
*/
|
||||
export async function connectProviderCredential(baseUrl: string, providerId = 'opencode'): Promise<void> {
|
||||
const key = readProviderKey(providerId);
|
||||
if (!key) {
|
||||
console.log(`[opencode] no ${providerId} key in auth.json; the new API surface will only reach free models`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Retried, because `/api/health` answers before the integration store is ready: connecting immediately
|
||||
// after the health check returns 500, and the identical request succeeds seconds later. Measured, not
|
||||
// assumed — the first version of this shipped without the retry and failed on its first real boot.
|
||||
//
|
||||
// Only 5xx is retried. A 4xx means the request itself is wrong (bad key, unknown integration) and
|
||||
// repeating it just prints the same complaint five times.
|
||||
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/integration/${providerId}/connect/key`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ key, label: 'officer-opencode sidecar' }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
console.log(`[opencode] connected the ${providerId} credential to the api surface`);
|
||||
return;
|
||||
}
|
||||
// The body is deliberately not logged: a credential endpoint's error may quote what it was given.
|
||||
if (res.status < 500) {
|
||||
console.error(`[opencode] could not connect the ${providerId} credential: HTTP ${res.status}`);
|
||||
return;
|
||||
}
|
||||
if (attempt === ATTEMPTS) {
|
||||
console.error(
|
||||
`[opencode] could not connect the ${providerId} credential after ${ATTEMPTS} attempts: HTTP ${res.status}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (attempt === ATTEMPTS) {
|
||||
console.error(
|
||||
`[opencode] could not connect the ${providerId} credential:`,
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await Bun.sleep(RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,19 @@ import { join } from 'node:path';
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { sweepRecordedServe } from './serve-sweep';
|
||||
import { connectProviderCredential } from './connect-credential';
|
||||
import { createSessionLogStore } from '../claude/session-log';
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { runOpenCodeTurn, killOpenCodeTurn, listRunningOpenCodeTurns, stopAllOpenCodeTurns } from './runner';
|
||||
import type { RunnerMessage } from './serve-runner';
|
||||
import { runOpenCodeTurnOnServe, killServeTurn, listRunningServeTurns, stopAllServeTurns } from './serve-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. 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, and that is now ALL it is: turns do not go
|
||||
// through the serve, they are `opencode run --dir <cwd>` subprocesses (runner.ts). The serve is used for
|
||||
// session CRUD and model enumeration only.
|
||||
// The serve runs EVERYTHING: turns (serve-runner.ts), session CRUD and model enumeration. It used to be
|
||||
// CRUD only, with turns spawned as `opencode run --dir <cwd>` subprocesses — that path was deleted on
|
||||
// 2026-08-10 once the serve had streaming, mid-turn injection and interrupt working end to end.
|
||||
|
||||
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');
|
||||
@@ -132,11 +134,16 @@ if (!(await waitHealthy(baseUrl, HEALTH_TIMEOUT_MS))) {
|
||||
}
|
||||
console.log(`[opencode] serve healthy on port ${port}`);
|
||||
|
||||
// The new /api surface keeps credentials separately from auth.json and would otherwise reach free models
|
||||
// only — silently. Best-effort and not awaited for correctness: turns go through `opencode run`, which
|
||||
// reads auth.json directly and does not depend on this.
|
||||
void connectProviderCredential(baseUrl);
|
||||
|
||||
// ── Command handlers ──
|
||||
|
||||
type ReplyFn = (msg: SidecarEvent) => void;
|
||||
|
||||
const RUNNER_CONFIG = { bin: OPENCODE_BIN, fallbackCwd: SERVE_CWD };
|
||||
const SERVE_RUNNER_CONFIG = { baseUrl, fallbackCwd: SERVE_CWD };
|
||||
|
||||
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
switch (cmd.type) {
|
||||
@@ -148,23 +155,29 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
// Turn output goes through the session log: translated to TurnMessages and committed to
|
||||
// chat_session_events here, in the process that produced it. Officer being down during a turn
|
||||
// no longer costs the transcript — the browser replays it from its cursor.
|
||||
runOpenCodeTurn(cmd.params, RUNNER_CONFIG, (msg) => {
|
||||
// Same emit contract either way, which is what makes the switch a switch: the durable commit and
|
||||
// the routing fact behave identically whether a subprocess or the serve produced the event.
|
||||
const onMessage = (msg: RunnerMessage) => {
|
||||
if (msg.type === 'opencode:event') {
|
||||
sessionLog.push(sessionKey, msg.event, durable);
|
||||
return;
|
||||
}
|
||||
// opencode:session is a routing fact (which `ses_…` to resume), not transcript — send it live.
|
||||
connection.send(msg);
|
||||
});
|
||||
};
|
||||
|
||||
void runOpenCodeTurnOnServe(cmd.params, SERVE_RUNNER_CONFIG, onMessage);
|
||||
reply({ type: 'opencode:spawned', id: cmd.id, sessionKey });
|
||||
break;
|
||||
}
|
||||
case 'opencode:list':
|
||||
reply({ type: 'opencode:sessions', id: cmd.id, sessions: listRunningOpenCodeTurns() });
|
||||
reply({ type: 'opencode:sessions', id: cmd.id, sessions: listRunningServeTurns() });
|
||||
break;
|
||||
|
||||
case 'opencode:kill':
|
||||
killOpenCodeTurn(cmd.sessionKey);
|
||||
// An INTERRUPT, not a kill: the turn stops and the session survives, so the conversation can be
|
||||
// continued rather than only re-opened.
|
||||
void killServeTurn(cmd.sessionKey, SERVE_RUNNER_CONFIG);
|
||||
sessionLog.drop(cmd.sessionKey);
|
||||
break;
|
||||
default:
|
||||
@@ -215,7 +228,8 @@ async function shutdown(signal: string) {
|
||||
// Before the connection goes: killing a turn produces an event, and that event's durable write travels
|
||||
// over this socket. Tearing it down first would stop every turn silently — the exact outcome this is
|
||||
// here to prevent.
|
||||
const stopped = stopAllOpenCodeTurns(`The OpenCode sidecar restarted (${signal}), so this turn stopped.`);
|
||||
const message = `The OpenCode sidecar restarted (${signal}), so this turn stopped.`;
|
||||
const stopped = stopAllServeTurns(message);
|
||||
if (stopped > 0) {
|
||||
console.log(`[opencode] ${signal} received, stopping ${stopped} in-flight turn(s)...`);
|
||||
await Promise.race([sessionLog.flush(), Bun.sleep(SHUTDOWN_FLUSH_MS)]);
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
import { afterAll, describe, expect, it } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { RunnerMessage } from './runner';
|
||||
import {
|
||||
killOpenCodeTurn,
|
||||
listRunningOpenCodeTurns,
|
||||
mapRunLine,
|
||||
runOpenCodeTurn,
|
||||
stopAllOpenCodeTurns,
|
||||
} from './runner';
|
||||
|
||||
// The first tests on the OpenCode path, which had none.
|
||||
//
|
||||
// This covers the NDJSON → ChatEvent mapping from `opencode run --format json`, which is the piece most
|
||||
// likely to break against a new release: the event shape is not a documented contract, and this project
|
||||
// already runs two different opencode versions across two machines (1.18.11 on the Mac, 1.17.9 on alpha
|
||||
// — measured 2026-08-10; this file previously had them the wrong way round).
|
||||
// Before this, a shape change would have surfaced as a silently empty or malformed turn.
|
||||
//
|
||||
// The fixtures below are the shapes the live 1.17.9 binary emits. If one of these tests fails after an
|
||||
// upgrade, re-read the real NDJSON from the installed binary before editing the expectation — the test
|
||||
// failing is the feature.
|
||||
|
||||
describe('mapRunLine — what it forwards', () => {
|
||||
it('forwards a text part as final text, because run emits whole blocks not deltas', () => {
|
||||
const out = mapRunLine(JSON.stringify({ type: 'text', sessionID: 'ses_1', part: { type: 'text', text: 'hello' } }));
|
||||
|
||||
expect(out?.events).toEqual([{ type: 'text', text: 'hello' }]);
|
||||
expect(out?.sessionId).toBe('ses_1');
|
||||
});
|
||||
|
||||
it('drops an empty text part rather than emitting a blank message', () => {
|
||||
expect(mapRunLine(JSON.stringify({ type: 'text', part: { type: 'text', text: '' } }))?.events).toEqual([]);
|
||||
});
|
||||
|
||||
it('splits one resolved tool part into start then result', () => {
|
||||
// `run` hands over the tool already finished, unlike the SSE path which saw it transition. Both
|
||||
// events are still emitted so the UI renders a call and its output rather than output alone.
|
||||
const out = mapRunLine(
|
||||
JSON.stringify({
|
||||
type: 'tool_use',
|
||||
sessionID: 'ses_1',
|
||||
part: {
|
||||
type: 'tool',
|
||||
tool: 'bash',
|
||||
callID: 'call_1',
|
||||
state: { status: 'completed', input: { cmd: 'ls' }, output: 'a\nb' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(out?.events).toEqual([
|
||||
{ type: 'tool:start', toolCallId: 'call_1', toolName: 'bash', toolInput: { cmd: 'ls' } },
|
||||
{ type: 'tool:result', toolCallId: 'call_1', output: 'a\nb', isError: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports a failed tool with its error as the output', () => {
|
||||
const out = mapRunLine(
|
||||
JSON.stringify({
|
||||
type: 'tool_use',
|
||||
part: {
|
||||
type: 'tool',
|
||||
tool: 'bash',
|
||||
callID: 'call_2',
|
||||
state: { status: 'error', error: 'boom', output: 'ignored' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// The error replaces the output rather than sitting beside it: a failed call has nothing useful in
|
||||
// `output`, and showing both would put a stale value under an error.
|
||||
expect(out?.events[1]).toEqual({ type: 'tool:result', toolCallId: 'call_2', output: 'boom', isError: true });
|
||||
});
|
||||
|
||||
it('ignores a tool part with no callID, which cannot be correlated to a result', () => {
|
||||
const out = mapRunLine(JSON.stringify({ type: 'tool_use', part: { type: 'tool', tool: 'bash' } }));
|
||||
expect(out?.events).toEqual([]);
|
||||
});
|
||||
|
||||
it('says nothing about step_start and other unknown types', () => {
|
||||
expect(mapRunLine(JSON.stringify({ type: 'step_start', sessionID: 'ses_1' }))?.events).toEqual([]);
|
||||
expect(mapRunLine(JSON.stringify({ type: 'something_new_in_1_19' }))?.events).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns null for a non-JSON line, because opencode interleaves plain logs with the stream', () => {
|
||||
expect(mapRunLine('Shell cwd was reset to /somewhere')).toBeNull();
|
||||
expect(mapRunLine('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapRunLine — cost', () => {
|
||||
it('reports per-step tokens and cost as a delta for the caller to accumulate', () => {
|
||||
const out = mapRunLine(
|
||||
JSON.stringify({ type: 'step_finish', part: { tokens: { input: 10, output: 4 }, cost: 0.002 } }),
|
||||
);
|
||||
|
||||
expect(out?.costDelta).toEqual({ inputTokens: 10, outputTokens: 4, totalUSD: 0.002 });
|
||||
});
|
||||
|
||||
it('is zero for every line that is not a step_finish, so accumulation is unconditional', () => {
|
||||
const text = mapRunLine(JSON.stringify({ type: 'text', part: { type: 'text', text: 'x' } }));
|
||||
expect(text?.costDelta).toEqual({ inputTokens: 0, outputTokens: 0, totalUSD: 0 });
|
||||
});
|
||||
|
||||
it('treats missing tokens and a missing cost as zero rather than NaN', () => {
|
||||
// A NaN here would propagate into the turn total and render as an empty or broken cost in the UI.
|
||||
const out = mapRunLine(JSON.stringify({ type: 'step_finish', part: {} }));
|
||||
expect(out?.costDelta).toEqual({ inputTokens: 0, outputTokens: 0, totalUSD: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapRunLine — session id', () => {
|
||||
it('reports the session id from any line that carries one', () => {
|
||||
// The caller emits it only the first time; this function has no memory, which is what makes it
|
||||
// testable line by line.
|
||||
expect(mapRunLine(JSON.stringify({ type: 'step_start', sessionID: 'ses_abc' }))?.sessionId).toBe('ses_abc');
|
||||
expect(mapRunLine(JSON.stringify({ type: 'text', part: { text: 'hi' } }))?.sessionId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── The turn lifecycle, which `mapRunLine`'s extraction deliberately left with the caller ──
|
||||
//
|
||||
// Emitting the session id once and accumulating cost were kept out of the pure mapper because they span
|
||||
// lines. That was right, and it left them as the only untested logic on this path — which is exactly
|
||||
// where the supersede defect lived: a replaced turn's `proc.exited` fired late and ran `finish()` against
|
||||
// the turn that had replaced it.
|
||||
//
|
||||
// No real `opencode` needed. `RunnerConfig.bin` is the only injection point, so a shell script that
|
||||
// outlives the test stands in for a turn that is still generating.
|
||||
|
||||
const stubDir = mkdtempSync(join(tmpdir(), 'oc-runner-test-'));
|
||||
|
||||
/** Stands in for a turn that is still generating. */
|
||||
const STUB_BIN = join(stubDir, 'fake-opencode');
|
||||
writeFileSync(STUB_BIN, '#!/bin/sh\nsleep 30\n');
|
||||
chmodSync(STUB_BIN, 0o755);
|
||||
|
||||
/** Stands in for a turn that fails on its own. A script, not `/bin/false` — that is `/usr/bin/false` on macOS. */
|
||||
const FAILING_BIN = join(stubDir, 'failing-opencode');
|
||||
writeFileSync(FAILING_BIN, '#!/bin/sh\necho "boom" >&2\nexit 1\n');
|
||||
chmodSync(FAILING_BIN, 0o755);
|
||||
|
||||
const CONFIG = { bin: STUB_BIN, fallbackCwd: stubDir };
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(stubDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Wait for something to BECOME true, rather than sleeping a guessed interval and hoping.
|
||||
*
|
||||
* These tests spawn real processes, so every "has it happened yet" is at the mercy of machine load —
|
||||
* and a fixed `sleep(750)` duly failed once on a box that was busy running opencode probes. Polling
|
||||
* makes a slow machine slow instead of red. Absence assertions still need a fixed wait, since there is
|
||||
* no event to wait for; those are marked where they appear.
|
||||
*/
|
||||
async function waitFor(what: () => boolean, timeoutMs = 8000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (what()) return;
|
||||
await Bun.sleep(25);
|
||||
}
|
||||
}
|
||||
|
||||
describe('runOpenCodeTurn — a second turn on a live session', () => {
|
||||
it('lets the replacement keep the session: no error, still listed, still killable', async () => {
|
||||
const sessionKey = 'sess-supersede';
|
||||
const messages: RunnerMessage[] = [];
|
||||
const emit = (msg: RunnerMessage) => messages.push(msg);
|
||||
|
||||
runOpenCodeTurn({ sessionKey, prompt: 'first', cwd: stubDir }, CONFIG, emit);
|
||||
runOpenCodeTurn({ sessionKey, prompt: 'second', cwd: stubDir }, CONFIG, emit);
|
||||
|
||||
// Wait for the superseded child to actually die — its exit handler is what used to reach across.
|
||||
// Absence assertion, so there is no event to wait for: give it a generous fixed window instead, and
|
||||
// wait on something observable (the kill landing) rather than purely on the clock.
|
||||
await waitFor(() => listRunningOpenCodeTurns().length === 1);
|
||||
await Bun.sleep(1500);
|
||||
|
||||
// 1. Nothing is emitted for a turn the system replaced on purpose. This one mattered most: the emit
|
||||
// is committed to chat_session_events by the sidecar, so a false "OpenCode exited" became history.
|
||||
expect(messages.filter((m) => m.type === 'opencode:event' && m.event.type === 'error')).toEqual([]);
|
||||
|
||||
// 2. The replacement is still enumerable — the Live panel's whole purpose.
|
||||
expect(listRunningOpenCodeTurns()).toContainEqual({ sessionKey });
|
||||
|
||||
// 3. And still reachable by the stop button, rather than orphaned with no handle.
|
||||
killOpenCodeTurn(sessionKey);
|
||||
await waitFor(() => messages.some((m) => m.type === 'opencode:event' && m.event.type === 'stopped'));
|
||||
expect(listRunningOpenCodeTurns()).not.toContainEqual({ sessionKey });
|
||||
expect(messages.some((m) => m.type === 'opencode:event' && m.event.type === 'stopped')).toBe(true);
|
||||
});
|
||||
|
||||
it('stops every in-flight turn on shutdown, and says why in the transcript', async () => {
|
||||
// `opencode run` is spawned, not supervised, so a sidecar restart used to leave these alive:
|
||||
// reparented, still spending tokens and still writing files, with nothing reading their output.
|
||||
const messages: RunnerMessage[] = [];
|
||||
const emit = (m: RunnerMessage) => messages.push(m);
|
||||
|
||||
runOpenCodeTurn({ sessionKey: 'sess-x', prompt: 'a', cwd: stubDir }, CONFIG, emit);
|
||||
runOpenCodeTurn({ sessionKey: 'sess-y', prompt: 'b', cwd: stubDir }, CONFIG, emit);
|
||||
expect(listRunningOpenCodeTurns()).toHaveLength(2);
|
||||
|
||||
const stopped = stopAllOpenCodeTurns('sidecar restarted');
|
||||
|
||||
// Synchronous on purpose: the caller is about to call process.exit, so nothing that waits for
|
||||
// `proc.exited` would ever run. A turn killed that way just trails off mid-tool-call.
|
||||
expect(stopped).toBe(2);
|
||||
expect(listRunningOpenCodeTurns()).toEqual([]);
|
||||
const errors = messages.filter((m) => m.type === 'opencode:event' && m.event.type === 'error');
|
||||
expect(errors).toHaveLength(2);
|
||||
|
||||
// And the late exits must not add a second, worse ending on top of the one just written.
|
||||
await Bun.sleep(500);
|
||||
expect(messages.filter((m) => m.type === 'opencode:event' && m.event.type === 'error')).toHaveLength(2);
|
||||
expect(messages.some((m) => m.type === 'opencode:event' && m.event.type === 'stopped')).toBe(false);
|
||||
});
|
||||
|
||||
it('still reports a turn that dies on its own, rather than swallowing every exit', async () => {
|
||||
// The guard must not overreach: an ordinary failure is still an error the user needs to see.
|
||||
const sessionKey = 'sess-solo';
|
||||
const messages: RunnerMessage[] = [];
|
||||
|
||||
runOpenCodeTurn({ sessionKey, prompt: 'only', cwd: stubDir }, { ...CONFIG, bin: FAILING_BIN }, (m) =>
|
||||
messages.push(m),
|
||||
);
|
||||
await waitFor(() => messages.some((m) => m.type === 'opencode:event' && m.event.type === 'error'));
|
||||
|
||||
expect(messages.some((m) => m.type === 'opencode:event' && m.event.type === 'error')).toBe(true);
|
||||
expect(listRunningOpenCodeTurns()).not.toContainEqual({ sessionKey });
|
||||
});
|
||||
});
|
||||
@@ -1,388 +0,0 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import type { Subprocess } from 'bun';
|
||||
import type { ChatEvent, MessageCost } from '../../api/chat/types';
|
||||
import type { OpenCodeRunParams } 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
|
||||
};
|
||||
|
||||
// What a turn reports to the sidecar it runs in. `opencode:event` is deliberately not a wire event any
|
||||
// more: the sidecar translates each one into a TurnMessage and commits it before officer sees anything,
|
||||
// so the durable record does not depend on officer being up (see index.ts).
|
||||
export type RunnerMessage =
|
||||
| { type: 'opencode:event'; sessionKey: string; event: ChatEvent }
|
||||
| { type: 'opencode:session'; sessionKey: string; sessionId: string };
|
||||
|
||||
type Emit = (msg: RunnerMessage) => void;
|
||||
|
||||
type RunHandle = {
|
||||
proc: Subprocess;
|
||||
killedByUser: boolean;
|
||||
/**
|
||||
* Set when a newer turn has taken this sessionKey over.
|
||||
*
|
||||
* A killed process dies asynchronously, so a replaced turn's `proc.exited` fires LONG after its
|
||||
* replacement is already running and registered under the same key. Without this flag that late
|
||||
* handler ran the full completion path against the wrong turn: it emitted `OpenCode exited with code
|
||||
* 143` — which the sidecar commits to `chat_session_events`, so a false failure became permanent
|
||||
* history — and then deleted its replacement from `running`, which blinded the Live panel, made the
|
||||
* stop button a no-op, and orphaned a process nothing could reach.
|
||||
*/
|
||||
superseded: boolean;
|
||||
/**
|
||||
* End this turn from outside the closure that owns it, with a reason.
|
||||
*
|
||||
* `killOpenCodeTurn` can kill a process and let `proc.exited` do the rest, because it has time.
|
||||
* Shutdown does not: the sidecar is about to call `process.exit`, so nothing asynchronous will ever
|
||||
* run again and a turn killed that way would simply stop mid-sentence, leaving a transcript that
|
||||
* trails off. Settling synchronously is what puts the explanation in the log before we go.
|
||||
*/
|
||||
finish: (event: ChatEvent) => void;
|
||||
};
|
||||
|
||||
// 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 opencode 1.18.11 (this Mac) and 1.17.9 (alpha) — MEASURED on 2026-08-10, having
|
||||
// previously been recorded the other way round here: the "this server" in the original note meant alpha,
|
||||
// and the comment was copied to a machine where it was false. Nothing enforces a version anyway; the
|
||||
// binary is whatever is installed, and the two machines in this project already differ.
|
||||
//
|
||||
// `runner.test.ts` pins the mapping so a shape change fails a test rather than a turn; if it starts
|
||||
// failing, re-read the NDJSON from the installed binary before editing the test.
|
||||
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. Mark it BEFORE killing: the flag is what tells its
|
||||
// own exit handler that this death was intentional and belongs to nobody.
|
||||
const stale = running.get(sessionKey);
|
||||
if (stale) {
|
||||
stale.superseded = true;
|
||||
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',
|
||||
});
|
||||
|
||||
// `finish` is a placeholder for the few synchronous lines until the real one below exists — it closes
|
||||
// over `handle`, so the two cannot both be defined first. Nothing can call it in between.
|
||||
const handle: RunHandle = { proc, killedByUser: false, superseded: false, finish: () => {} };
|
||||
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;
|
||||
|
||||
/**
|
||||
* Retire this turn: stop its watchdogs, release its slot, and optionally say why it ended.
|
||||
*
|
||||
* The delete is identity-checked because `sessionKey` is not this turn's to own once it has been
|
||||
* superseded — the map may already hold a live replacement under that key, and deleting by name alone
|
||||
* removed it. `null` retires silently, which is what a superseded turn needs: it must still clear its
|
||||
* timers (an armed 10-minute `hardTimer` would otherwise fire an error at whichever turn holds the key
|
||||
* by then, reproducing the same cross-talk on a delay) while emitting nothing at all.
|
||||
*/
|
||||
const settle = (event: ChatEvent | null) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(hardTimer);
|
||||
if (inactivityTimer) clearTimeout(inactivityTimer);
|
||||
if (running.get(sessionKey) === handle) running.delete(sessionKey);
|
||||
if (event) emitEvent(event);
|
||||
};
|
||||
|
||||
const finish = (event: ChatEvent) => settle(event);
|
||||
handle.finish = finish;
|
||||
|
||||
// ── 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 {
|
||||
// A retired turn says nothing more. Stdout is drained asynchronously, so a killed process can still
|
||||
// have buffered lines in flight — and for a superseded turn those would be emitted under a
|
||||
// sessionKey that now belongs to its replacement, interleaving one turn's output into another's.
|
||||
if (done) return;
|
||||
|
||||
const mapped = mapRunLine(line);
|
||||
if (!mapped) return;
|
||||
|
||||
// Report the OpenCode session id once, so the API can resume it (`--session`) next turn.
|
||||
if (!reportedSession && mapped.sessionId) {
|
||||
reportedSession = true;
|
||||
emit({ type: 'opencode:session', sessionKey, sessionId: mapped.sessionId });
|
||||
}
|
||||
|
||||
cost = {
|
||||
inputTokens: cost.inputTokens + mapped.costDelta.inputTokens,
|
||||
outputTokens: cost.outputTokens + mapped.costDelta.outputTokens,
|
||||
totalUSD: cost.totalUSD + mapped.costDelta.totalUSD,
|
||||
};
|
||||
|
||||
for (const event of mapped.events) emitEvent(event);
|
||||
}
|
||||
|
||||
// ── Completion: process exit is the authoritative turn-done signal ──
|
||||
void proc.exited.then((code) => {
|
||||
if (done) return;
|
||||
// Replaced on purpose: not a result, not an error, and not this turn's session any more.
|
||||
if (handle.superseded) {
|
||||
settle(null);
|
||||
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}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The turns this process is running right now.
|
||||
*
|
||||
* The OpenCode analog of `claude-manager.listSessions`, and deliberately thinner. Claude holds a warm
|
||||
* session that outlives a turn, so it can report one that is merely open; OpenCode spawns a subprocess
|
||||
* per turn and has nothing between them. So a session appears here only while it is generating — which
|
||||
* is exactly the state the Live panel exists to show, and the state that was invisible for OpenCode.
|
||||
*
|
||||
* No `pendingTasks`: `opencode run` has no background-task concept, so reporting 0 would suggest a
|
||||
* capability that does not exist rather than an empty one.
|
||||
*/
|
||||
export function listRunningOpenCodeTurns(): { sessionKey: string }[] {
|
||||
return Array.from(running.keys()).map((sessionKey) => ({ sessionKey }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill every turn this process is running, because the process itself is going away.
|
||||
*
|
||||
* A turn is a child of this sidecar only in the bookkeeping sense: `opencode run` is spawned, not
|
||||
* supervised, so `pm2 restart officer-opencode` used to leave every in-flight turn ALIVE — reparented,
|
||||
* still spending tokens, and still writing files as the agent, while the only reader of its stdout had
|
||||
* exited. The turn's output went nowhere and the transcript simply stopped mid-tool-call, which is
|
||||
* indistinguishable from the agent hanging.
|
||||
*
|
||||
* Both halves matter. Killing the children stops the invisible work; settling them synchronously writes
|
||||
* a reason into the transcript, so a reload after a restart explains itself instead of trailing off.
|
||||
* Returns how many were stopped, so the caller can skip the flush wait when there were none.
|
||||
*/
|
||||
export function stopAllOpenCodeTurns(message: string): number {
|
||||
const handles = [...running.values()];
|
||||
for (const handle of handles) {
|
||||
// Suppress the exit handler's own error: this death is accounted for, and `finish` below is the
|
||||
// account. Without it a late `proc.exited` would be a second, less accurate ending.
|
||||
handle.killedByUser = true;
|
||||
try {
|
||||
handle.proc.kill();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
handle.finish({ type: 'error', message });
|
||||
}
|
||||
return handles.length;
|
||||
}
|
||||
|
||||
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' }).
|
||||
}
|
||||
|
||||
// ── The NDJSON → ChatEvent mapping, as a pure function ──
|
||||
//
|
||||
// Extracted from `handleLine` so it can be tested without spawning a binary. This is the piece most
|
||||
// likely to break against a new OpenCode release — the event shape is not a stable contract and this
|
||||
// project already runs two different versions across two machines — and it was the only untested part
|
||||
// of the path. `runner.test.ts` pins it.
|
||||
//
|
||||
// Pure by construction: it takes a line and returns what should happen, holding no state. The caller
|
||||
// owns the two stateful concerns, because they span lines rather than belonging to one: emitting the
|
||||
// session id exactly once, and accumulating cost across steps.
|
||||
|
||||
export type MappedRunLine = {
|
||||
/** Present on any event that names a session; the caller emits it only the first time. */
|
||||
sessionId?: string;
|
||||
/** Events to forward, in order. A tool part yields `tool:start` then `tool:result`. */
|
||||
events: ChatEvent[];
|
||||
/** Per-step tokens/cost to add to the turn total. Zeroes for every non-`step_finish` line. */
|
||||
costDelta: { inputTokens: number; outputTokens: number; totalUSD: number };
|
||||
};
|
||||
|
||||
const NO_COST = { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
|
||||
|
||||
/** `null` for a line that is not JSON at all — `opencode` interleaves plain log lines with the stream. */
|
||||
export function mapRunLine(line: string): MappedRunLine | null {
|
||||
let evt: RunEvent;
|
||||
try {
|
||||
evt = JSON.parse(line) as RunEvent;
|
||||
} catch {
|
||||
return null; // non-JSON log line
|
||||
}
|
||||
|
||||
const sessionId = evt.sessionID;
|
||||
const events: ChatEvent[] = [];
|
||||
|
||||
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) events.push({ type: 'text', text });
|
||||
return { sessionId, events, costDelta: NO_COST };
|
||||
}
|
||||
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 { sessionId, events, costDelta: NO_COST };
|
||||
const st = part.state ?? {};
|
||||
events.push({
|
||||
type: 'tool:start',
|
||||
toolCallId: part.callID,
|
||||
toolName: part.tool ?? 'tool',
|
||||
toolInput: (st.input as Record<string, unknown>) ?? {},
|
||||
});
|
||||
const isError = st.status === 'error';
|
||||
events.push({
|
||||
type: 'tool:result',
|
||||
toolCallId: part.callID,
|
||||
output: String((isError ? st.error : st.output) ?? ''),
|
||||
isError,
|
||||
});
|
||||
return { sessionId, events, costDelta: NO_COST };
|
||||
}
|
||||
case 'step_finish': {
|
||||
const t = evt.part?.tokens;
|
||||
return {
|
||||
sessionId,
|
||||
events,
|
||||
costDelta: {
|
||||
inputTokens: t?.input ?? 0,
|
||||
outputTokens: t?.output ?? 0,
|
||||
totalUSD: typeof evt.part?.cost === 'number' ? evt.part.cost : 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { sessionId, events, costDelta: NO_COST }; // step_start etc. — nothing to forward
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { isKnownServeEvent, mapServeEvent } from './serve-events';
|
||||
|
||||
// Fixtures are VERBATIM captures from opencode 1.18.16 — one real turn that ran `echo hello-from-tool`
|
||||
// through the bash tool and then answered in prose. Ids and timestamps are as they arrived.
|
||||
//
|
||||
// They are real for the same reason `runner.test.ts`'s are: this mapping's only job is to match a shape
|
||||
// nobody documents, so a hand-written fixture would test my imagination rather than the binary. If one
|
||||
// of these fails after an upgrade, re-capture before editing the expectation.
|
||||
|
||||
const SESSION = 'ses_01346bde5ffeB4hWdcQiq1BQUG';
|
||||
const ASSISTANT = 'msg_fecb96b3e0010o8smEyjxT2noD';
|
||||
const CALL = 'toolu_01TyLVFSSXus2eoyANdjieWW';
|
||||
|
||||
describe('mapServeEvent — text', () => {
|
||||
it('turns a text delta into a delta, because that is what makes output appear as it is typed', () => {
|
||||
const out = mapServeEvent({
|
||||
type: 'session.next.text.delta',
|
||||
data: { timestamp: 1, sessionID: SESSION, assistantMessageID: ASSISTANT, textID: 'text-0', delta: 'It' },
|
||||
} as never);
|
||||
|
||||
expect(out?.events).toEqual([{ type: 'delta', text: 'It' }]);
|
||||
expect(out?.sessionId).toBe(SESSION);
|
||||
});
|
||||
|
||||
it('turns the ended text into the committed block', () => {
|
||||
const out = mapServeEvent({
|
||||
type: 'session.next.text.ended',
|
||||
data: { sessionID: SESSION, textID: 'text-0', text: 'It printed **hello-from-tool**.' },
|
||||
} as never);
|
||||
|
||||
expect(out?.events).toEqual([{ type: 'text', text: 'It printed **hello-from-tool**.' }]);
|
||||
});
|
||||
|
||||
it('says nothing for text.started, which carries no text at all', () => {
|
||||
// Verified against the capture: `text.started` has `{textID}` and nothing else. Emitting an empty
|
||||
// text here would put a blank assistant bubble on screen before a single token arrived.
|
||||
const out = mapServeEvent({
|
||||
type: 'session.next.text.started',
|
||||
data: { sessionID: SESSION, textID: 'text-0' },
|
||||
} as never);
|
||||
expect(out?.events).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops an empty delta rather than emitting nothing-shaped events', () => {
|
||||
expect(mapServeEvent({ type: 'session.next.text.delta', data: { delta: '' } } as never)?.events).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapServeEvent — tools', () => {
|
||||
it('starts the tool row on tool.called, where the input is finally a real object', () => {
|
||||
const out = mapServeEvent({
|
||||
type: 'session.next.tool.called',
|
||||
data: {
|
||||
sessionID: SESSION,
|
||||
assistantMessageID: ASSISTANT,
|
||||
callID: CALL,
|
||||
tool: 'bash',
|
||||
input: { command: 'echo hello-from-tool' },
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(out?.events).toEqual([
|
||||
{ type: 'tool:start', toolCallId: CALL, toolName: 'bash', toolInput: { command: 'echo hello-from-tool' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('stays silent through the input stream, which is half-parsed JSON', () => {
|
||||
// This is the reason `tool:start` waits for `tool.called`. The real delta below is a fragment of a
|
||||
// JSON object; rendering a tool row with `{"comman` as its arguments would be worse than waiting.
|
||||
const started = mapServeEvent({
|
||||
type: 'session.next.tool.input.started',
|
||||
data: { sessionID: SESSION, callID: CALL, name: 'bash' },
|
||||
} as never);
|
||||
const delta = mapServeEvent({
|
||||
type: 'session.next.tool.input.delta',
|
||||
data: { sessionID: SESSION, callID: CALL, delta: '{"comman' },
|
||||
} as never);
|
||||
|
||||
expect(started?.events).toEqual([]);
|
||||
expect(delta?.events).toEqual([]);
|
||||
});
|
||||
|
||||
it('flattens the success content blocks into one output string', () => {
|
||||
const out = mapServeEvent({
|
||||
type: 'session.next.tool.success',
|
||||
data: {
|
||||
sessionID: SESSION,
|
||||
callID: CALL,
|
||||
structured: { exit: 0, truncated: false },
|
||||
content: [{ type: 'text', text: 'hello-from-tool' }],
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(out?.events).toEqual([{ type: 'tool:result', toolCallId: CALL, output: 'hello-from-tool', isError: false }]);
|
||||
});
|
||||
|
||||
it('reports a failed tool with its error as the output', () => {
|
||||
const out = mapServeEvent({
|
||||
type: 'session.next.tool.failed',
|
||||
data: { sessionID: SESSION, callID: CALL, error: { message: 'command not found' } },
|
||||
} as never);
|
||||
|
||||
expect(out?.events).toEqual([
|
||||
{ type: 'tool:result', toolCallId: CALL, output: 'command not found', isError: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores a tool event with no callID, which cannot be correlated', () => {
|
||||
expect(mapServeEvent({ type: 'session.next.tool.called', data: { tool: 'bash' } } as never)?.events).toEqual([]);
|
||||
expect(mapServeEvent({ type: 'session.next.tool.success', data: {} } as never)?.events).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapServeEvent — accounting', () => {
|
||||
it('reports per-step tokens and cost for the caller to accumulate', () => {
|
||||
const out = mapServeEvent({
|
||||
type: 'session.next.step.ended',
|
||||
data: {
|
||||
sessionID: SESSION,
|
||||
finish: 'tool-calls',
|
||||
cost: 0.0042,
|
||||
tokens: { input: 3, output: 57, reasoning: 0, cache: { read: 3850, write: 0 } },
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(out?.costDelta).toEqual({ inputTokens: 3, outputTokens: 57, totalUSD: 0.0042 });
|
||||
});
|
||||
|
||||
it('emits nothing terminal for a step that merely ended in tool calls', () => {
|
||||
// `finish: "tool-calls"` is a step boundary MID-turn — the model stopped to run a tool and will
|
||||
// continue. Treating it as the end of the turn would cut every tool-using conversation in half.
|
||||
const out = mapServeEvent({
|
||||
type: 'session.next.step.ended',
|
||||
data: { sessionID: SESSION, finish: 'tool-calls', cost: 0, tokens: { input: 3, output: 57 } },
|
||||
} as never);
|
||||
|
||||
expect(out?.events).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats missing tokens and cost as zero rather than NaN', () => {
|
||||
const out = mapServeEvent({ type: 'session.next.step.ended', data: { sessionID: SESSION } } as never);
|
||||
expect(out?.costDelta).toEqual({ inputTokens: 0, outputTokens: 0, totalUSD: 0 });
|
||||
});
|
||||
|
||||
it('surfaces a failed step as an error carrying the provider message', () => {
|
||||
// The real one, from a probe where the provider was down.
|
||||
const out = mapServeEvent({
|
||||
type: 'session.next.step.failed',
|
||||
data: { sessionID: SESSION, error: { type: 'unknown', message: 'Provider request failed with HTTP 503' } },
|
||||
} as never);
|
||||
|
||||
expect(out?.events).toEqual([{ type: 'error', message: 'Provider request failed with HTTP 503' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapServeEvent — envelope', () => {
|
||||
it('carries the durable cursor when reading the durable stream, and not otherwise', () => {
|
||||
// The per-session stream stamps `durable.seq`; the global live stream does not. The caller needs
|
||||
// that difference to know what it may commit and replay.
|
||||
const durable = mapServeEvent({
|
||||
type: 'session.next.text.ended',
|
||||
durable: { seq: 12 },
|
||||
data: { sessionID: SESSION, text: 'hi' },
|
||||
} as never);
|
||||
const live = mapServeEvent({ type: 'session.next.text.ended', data: { sessionID: SESSION, text: 'hi' } } as never);
|
||||
|
||||
expect(durable?.seq).toBe(12);
|
||||
expect(live?.seq).toBeUndefined();
|
||||
});
|
||||
|
||||
it('always reports the session id, because the live stream is global', () => {
|
||||
// Not a detail: `/api/event` carries EVERY session's events, so a consumer that forgets to filter
|
||||
// splices one conversation into another.
|
||||
for (const type of ['session.next.text.delta', 'session.next.tool.called', 'session.next.step.ended']) {
|
||||
expect(mapServeEvent({ type, data: { sessionID: SESSION, delta: 'x', callID: CALL } } as never)?.sessionId).toBe(
|
||||
SESSION,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null for something that is not an event at all', () => {
|
||||
expect(mapServeEvent(null)).toBeNull();
|
||||
expect(mapServeEvent(undefined)).toBeNull();
|
||||
expect(mapServeEvent({} as never)).toBeNull();
|
||||
});
|
||||
|
||||
it('is quiet about an unknown future type, but does not claim to know it', () => {
|
||||
// Forward compatibility with a silent tell: an unrecognised type produces no events, and
|
||||
// `isKnownServeEvent` is how a caller notices a release added something worth mapping.
|
||||
expect(mapServeEvent({ type: 'session.next.something_new_in_1_19', data: {} } as never)?.events).toEqual([]);
|
||||
expect(isKnownServeEvent('session.next.something_new_in_1_19')).toBe(false);
|
||||
expect(isKnownServeEvent('session.next.text.delta')).toBe(true);
|
||||
expect(isKnownServeEvent(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import type { ChatEvent } from '../../api/chat/types';
|
||||
|
||||
// `session.next.*` → ChatEvent. Phase A of the serve migration (docs/opencode-serve-migration-plan.md).
|
||||
//
|
||||
// NOTHING ROUTES THROUGH THIS YET. Turns still run as `opencode run` subprocesses via runner.ts; this is
|
||||
// the mapping half of the serve path, written and pinned first so the switch-over is not also the moment
|
||||
// the parsing is discovered to be wrong.
|
||||
//
|
||||
// ── The two streams, which is the part worth understanding ──
|
||||
//
|
||||
// The serve publishes the same turn twice, and the split maps exactly onto what officer already does for
|
||||
// Claude:
|
||||
//
|
||||
// • `GET /api/session/{id}/event?after=<seq>` — DURABLE, per session, replayable. Every event carries
|
||||
// `durable.seq`. Carries whole values (`text.ended` with the full text) and NO deltas. This is the
|
||||
// transcript: what belongs in `chat_session_events`, and what a reconnecting browser replays.
|
||||
//
|
||||
// • `GET /api/event` — LIVE, GLOBAL, ephemeral. Carries the deltas
|
||||
// (`text.delta`, `tool.input.delta`) and no cursor. This is what makes text appear as it is typed.
|
||||
//
|
||||
// Measured, not inferred: the same turn produced 13 events on the durable stream and 21 on the live one,
|
||||
// the difference being 3 `text.delta` and 5 `tool.input.delta`. Reading only the durable stream — which
|
||||
// is what I did first — makes it look like the serve cannot stream at all, and would have quietly killed
|
||||
// the main reason for migrating.
|
||||
//
|
||||
// The live stream being GLOBAL is the real cost of this design: it carries every session's events, so a
|
||||
// consumer must filter on `sessionID` and cannot assume it owns the socket.
|
||||
//
|
||||
// ── Fixture provenance ──
|
||||
//
|
||||
// Every shape here was captured from opencode 1.18.16 running a real turn that used the bash tool and
|
||||
// then answered in prose. See `serve-events.test.ts`, whose fixtures are verbatim captures.
|
||||
|
||||
/** The envelope every `session.next.*` event arrives in. `durable` is present only on the durable stream. */
|
||||
export type ServeEvent = {
|
||||
type?: string;
|
||||
durable?: { seq?: number };
|
||||
data?: {
|
||||
sessionID?: string;
|
||||
messageID?: string;
|
||||
assistantMessageID?: string;
|
||||
callID?: string;
|
||||
textID?: string;
|
||||
name?: string;
|
||||
tool?: string;
|
||||
input?: Record<string, unknown>;
|
||||
delta?: string;
|
||||
text?: string;
|
||||
content?: { type?: string; text?: string }[];
|
||||
structured?: Record<string, unknown>;
|
||||
error?: { message?: string; type?: string };
|
||||
finish?: string;
|
||||
cost?: number;
|
||||
tokens?: { input?: number; output?: number };
|
||||
model?: { id?: string; providerID?: string };
|
||||
};
|
||||
};
|
||||
|
||||
export type MappedServeEvent = {
|
||||
/** The session this belongs to. The live stream is global, so a consumer MUST filter on it. */
|
||||
sessionId?: string;
|
||||
/** The durable cursor, when this came from the durable stream. Absent on live events. */
|
||||
seq?: number;
|
||||
events: ChatEvent[];
|
||||
costDelta: { inputTokens: number; outputTokens: number; totalUSD: number };
|
||||
/**
|
||||
* Present only on `step.ended`, and the answer to "is the turn over".
|
||||
*
|
||||
* A subprocess turn ends by exiting; a serve turn has no such moment, so this is the only signal.
|
||||
* `"tool-calls"` means the model paused to run a tool and WILL continue — treating it as terminal cuts
|
||||
* every tool-using conversation in half. `"stop"` is the real end. Anything else (e.g. `"length"`) is
|
||||
* also an ending, so the caller should test for "not tool-calls" rather than for "stop".
|
||||
*/
|
||||
stepFinish?: string;
|
||||
};
|
||||
|
||||
const NO_COST = { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
|
||||
|
||||
/** `tool.success` returns content blocks; officer's `tool:result` wants one string. */
|
||||
function textOfContent(content: { type?: string; text?: string }[] | undefined): string {
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.map((c) => (typeof c?.text === 'string' ? c.text : ''))
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* One serve event in, whatever officer should do about it out.
|
||||
*
|
||||
* Pure by construction, exactly like `mapRunLine`: no accumulation, no memory, no I/O. The caller owns
|
||||
* anything that spans events — summing cost across steps, and deciding whether it is reading the live or
|
||||
* the durable stream.
|
||||
*
|
||||
* Returns `null` for an event that is not ours to interpret (another session's, or an envelope with no
|
||||
* type). Everything recognised but deliberately ignored returns an empty `events` array instead, so
|
||||
* "we saw it and had nothing to say" stays distinguishable from "we did not understand it".
|
||||
*/
|
||||
export function mapServeEvent(evt: ServeEvent | null | undefined): MappedServeEvent | null {
|
||||
if (!evt || typeof evt.type !== 'string') return null;
|
||||
|
||||
const d = evt.data ?? {};
|
||||
const base = { sessionId: d.sessionID, seq: evt.durable?.seq };
|
||||
const nothing = (): MappedServeEvent => ({ ...base, events: [], costDelta: NO_COST });
|
||||
|
||||
switch (evt.type) {
|
||||
// ── Text ──
|
||||
case 'session.next.text.delta': {
|
||||
// Live stream only. `delta` is the increment, never the whole value.
|
||||
const text = d.delta;
|
||||
if (typeof text !== 'string' || text.length === 0) return nothing();
|
||||
return { ...base, events: [{ type: 'delta', text }], costDelta: NO_COST };
|
||||
}
|
||||
case 'session.next.text.ended': {
|
||||
// The committed block. Appears on BOTH streams, which is intended: officer's client treats `text`
|
||||
// as the value that supersedes whatever the deltas built up, the same as the Claude path.
|
||||
const text = d.text;
|
||||
if (typeof text !== 'string' || text.length === 0) return nothing();
|
||||
return { ...base, events: [{ type: 'text', text }], costDelta: NO_COST };
|
||||
}
|
||||
|
||||
// ── Tools ──
|
||||
//
|
||||
// `tool:start` is emitted on `tool.called`, NOT on `tool.input.started`, because only `tool.called`
|
||||
// carries the resolved `input` object. `tool.input.started` knows the name but the arguments are
|
||||
// still being streamed a few characters at a time (`tool.input.delta`: `{"comman`), and a tool row
|
||||
// rendered with half-parsed JSON as its arguments is worse than one that appears a moment later.
|
||||
case 'session.next.tool.called': {
|
||||
const toolCallId = d.callID;
|
||||
if (!toolCallId) return nothing();
|
||||
return {
|
||||
...base,
|
||||
events: [
|
||||
{
|
||||
type: 'tool:start',
|
||||
toolCallId,
|
||||
toolName: d.tool ?? d.name ?? 'tool',
|
||||
toolInput: (d.input as Record<string, unknown>) ?? {},
|
||||
},
|
||||
],
|
||||
costDelta: NO_COST,
|
||||
};
|
||||
}
|
||||
case 'session.next.tool.success': {
|
||||
const toolCallId = d.callID;
|
||||
if (!toolCallId) return nothing();
|
||||
return {
|
||||
...base,
|
||||
events: [{ type: 'tool:result', toolCallId, output: textOfContent(d.content), isError: false }],
|
||||
costDelta: NO_COST,
|
||||
};
|
||||
}
|
||||
case 'session.next.tool.failed': {
|
||||
const toolCallId = d.callID;
|
||||
if (!toolCallId) return nothing();
|
||||
// The error replaces the output rather than sitting beside it — same call the run path makes.
|
||||
const message = d.error?.message ?? textOfContent(d.content) ?? '';
|
||||
return {
|
||||
...base,
|
||||
events: [{ type: 'tool:result', toolCallId, output: String(message), isError: true }],
|
||||
costDelta: NO_COST,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Step accounting ──
|
||||
case 'session.next.step.ended': {
|
||||
// Per-step tokens and cost, to be summed by the caller. `finish: "tool-calls"` is a step boundary
|
||||
// mid-turn, not the end of the turn — the turn has ended when the prompt is fully answered, which
|
||||
// is a caller-level question, so nothing terminal is emitted here.
|
||||
const t = d.tokens;
|
||||
return {
|
||||
...base,
|
||||
events: [],
|
||||
stepFinish: typeof d.finish === 'string' ? d.finish : undefined,
|
||||
costDelta: {
|
||||
inputTokens: t?.input ?? 0,
|
||||
outputTokens: t?.output ?? 0,
|
||||
totalUSD: typeof d.cost === 'number' ? d.cost : 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'session.next.step.failed': {
|
||||
const message = d.error?.message ?? 'OpenCode step failed';
|
||||
return { ...base, events: [{ type: 'error', message }], costDelta: NO_COST };
|
||||
}
|
||||
|
||||
// ── Seen and deliberately silent ──
|
||||
//
|
||||
// Named rather than swept into `default` so that a genuinely NEW event type still lands in the
|
||||
// unknown bucket, where it can be noticed.
|
||||
case 'session.next.prompt.admitted':
|
||||
case 'session.next.prompted':
|
||||
case 'session.next.step.started':
|
||||
case 'session.next.text.started':
|
||||
case 'session.next.tool.input.started':
|
||||
case 'session.next.tool.input.delta':
|
||||
case 'session.next.tool.input.ended':
|
||||
case 'session.next.tool.progress':
|
||||
case 'session.next.model.switched':
|
||||
case 'session.next.agent.switched':
|
||||
case 'server.connected':
|
||||
return nothing();
|
||||
|
||||
default:
|
||||
return nothing();
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a type is one this mapper knows about — for logging what a new opencode release added. */
|
||||
export const isKnownServeEvent = (type: string | undefined): boolean =>
|
||||
typeof type === 'string' && (type === 'server.connected' || KNOWN.has(type));
|
||||
|
||||
const KNOWN = new Set([
|
||||
'session.next.text.delta',
|
||||
'session.next.text.ended',
|
||||
'session.next.text.started',
|
||||
'session.next.tool.called',
|
||||
'session.next.tool.success',
|
||||
'session.next.tool.failed',
|
||||
'session.next.tool.input.started',
|
||||
'session.next.tool.input.delta',
|
||||
'session.next.tool.input.ended',
|
||||
'session.next.tool.progress',
|
||||
'session.next.step.started',
|
||||
'session.next.step.ended',
|
||||
'session.next.step.failed',
|
||||
'session.next.prompt.admitted',
|
||||
'session.next.prompted',
|
||||
'session.next.model.switched',
|
||||
'session.next.agent.switched',
|
||||
]);
|
||||
@@ -0,0 +1,351 @@
|
||||
import type { ChatEvent, MessageCost } from '../../api/chat/types';
|
||||
import type { OpenCodeRunParams } from '../protocol';
|
||||
import { mapServeEvent } from './serve-events';
|
||||
|
||||
// How an OpenCode turn runs. The only way, since 2026-08-10.
|
||||
//
|
||||
// It used to be `opencode run --format json`, a subprocess per turn with `stdin: 'ignore'`. Everything
|
||||
// that path could not do followed from that one closed pipe: no token streaming, no mid-turn injection,
|
||||
// no queue, and a stop that could only kill the session rather than interrupt it. The serve offers all
|
||||
// four as primitives, each verified end to end before the subprocess was deleted
|
||||
// (docs/opencode-fork-decision.md, docs/opencode-serve-migration-plan.md).
|
||||
//
|
||||
// There is no fallback engine any more. If this path breaks, the recovery is git, not a config flag —
|
||||
// a deliberate choice made while nothing depended on OpenCode.
|
||||
//
|
||||
// ── One global stream, demultiplexed ──
|
||||
//
|
||||
// The serve publishes each turn on two streams (docs/opencode-serve-migration-plan.md). This reads the
|
||||
// LIVE one, `GET /api/event`, because it is a strict superset of the durable stream's content — same
|
||||
// `tool.called`, `tool.success`, `step.ended`, `text.ended`, PLUS the deltas — and deltas are the point.
|
||||
//
|
||||
// It is GLOBAL: one socket carries every session on the box, so everything here filters on `sessionID`.
|
||||
// Forgetting that would splice one conversation into another. There is exactly one subscription for the
|
||||
// process, opened on the first turn and shared, because opening one per turn would multiply the same
|
||||
// firehose by the number of turns.
|
||||
//
|
||||
// What this loses versus the durable stream is `durable.seq`, i.e. replay-after-the-fact. That matters
|
||||
// for surviving an officer restart mid-turn and is deliberately NOT in Phase B: the sidecar commits
|
||||
// every event to `chat_session_events` as it arrives (unchanged from the subprocess path), which is the
|
||||
// same durability guarantee the subprocess had. Reading the durable stream to recover a turn this
|
||||
// process never saw is its own change.
|
||||
|
||||
type ServeConfig = {
|
||||
/** The serve's base URL, e.g. http://127.0.0.1:53100 */
|
||||
baseUrl: string;
|
||||
/** Used when a turn names no cwd — same fallback the subprocess runner applies. */
|
||||
fallbackCwd: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* What a turn reports to the sidecar it runs in.
|
||||
*
|
||||
* Lived in `runner.ts` until the subprocess path was deleted (Phase D). `opencode:event` is not a wire
|
||||
* event: the sidecar translates each one into a TurnMessage and commits it to `chat_session_events`
|
||||
* before officer sees anything, so the durable record does not depend on officer being up.
|
||||
* `opencode:session` is the routing fact — which `ses_…` to resume — and goes over the wire live.
|
||||
*/
|
||||
export type RunnerMessage =
|
||||
| { type: 'opencode:event'; sessionKey: string; event: ChatEvent }
|
||||
| { type: 'opencode:session'; sessionKey: string; sessionId: string };
|
||||
|
||||
type Emit = (msg: RunnerMessage) => void;
|
||||
|
||||
type ServeTurn = {
|
||||
sessionKey: string;
|
||||
openCodeSessionId: string;
|
||||
cost: MessageCost;
|
||||
done: boolean;
|
||||
emit: Emit;
|
||||
finish: (event: ChatEvent) => void;
|
||||
};
|
||||
|
||||
/** Live turns, by OpenCode session id — the id the event stream speaks. */
|
||||
const byOpenCodeId = new Map<string, ServeTurn>();
|
||||
/** The same turns by officer's key, which is what `kill` and the Live panel use. */
|
||||
const bySessionKey = new Map<string, ServeTurn>();
|
||||
|
||||
// ── The one shared subscription ──
|
||||
|
||||
let streamStarted = false;
|
||||
|
||||
/**
|
||||
* Read `/api/event` forever, routing each event to the turn that owns it.
|
||||
*
|
||||
* Reconnects on drop with a fixed delay. A serve restart, a network blip or the stream simply ending
|
||||
* must not permanently deafen the sidecar — every subsequent turn would hang with no output, which is
|
||||
* the worst failure this path has, because it looks exactly like a slow model.
|
||||
*/
|
||||
function ensureEventStream(config: ServeConfig): void {
|
||||
if (streamStarted) return;
|
||||
streamStarted = true;
|
||||
|
||||
void (async () => {
|
||||
for (;;) {
|
||||
try {
|
||||
const res = await fetch(`${config.baseUrl}/api/event`, { headers: { accept: 'text/event-stream' } });
|
||||
if (!res.ok || !res.body) throw new Error(`event stream → ${res.status}`);
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) continue;
|
||||
try {
|
||||
handleServeEvent(JSON.parse(line.slice(5).trim()));
|
||||
} catch {
|
||||
/* a frame we could not parse is not worth killing the stream over */
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[opencode] event stream dropped, reconnecting:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
await Bun.sleep(1_000);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function handleServeEvent(raw: unknown): void {
|
||||
const mapped = mapServeEvent(raw as never);
|
||||
if (!mapped?.sessionId) return;
|
||||
|
||||
const turn = byOpenCodeId.get(mapped.sessionId);
|
||||
if (!turn || turn.done) return; // another session's, or one we have already finished
|
||||
|
||||
turn.cost = {
|
||||
inputTokens: turn.cost.inputTokens + mapped.costDelta.inputTokens,
|
||||
outputTokens: turn.cost.outputTokens + mapped.costDelta.outputTokens,
|
||||
totalUSD: turn.cost.totalUSD + mapped.costDelta.totalUSD,
|
||||
};
|
||||
|
||||
for (const event of mapped.events) {
|
||||
// An error from the harness ends the turn: nothing follows a failed step, and leaving the turn open
|
||||
// would strand the UI on a spinner.
|
||||
if (event.type === 'error') {
|
||||
turn.finish(event);
|
||||
return;
|
||||
}
|
||||
turn.emit({ type: 'opencode:event', sessionKey: turn.sessionKey, event });
|
||||
}
|
||||
|
||||
// `tool-calls` means the model paused to run a tool and will continue. Anything else is the end.
|
||||
if (mapped.stepFinish && mapped.stepFinish !== 'tool-calls') {
|
||||
turn.finish({ type: 'result', cost: turn.cost });
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTTP helpers ──
|
||||
|
||||
async function serveJson<T>(config: ServeConfig, path: string, init: RequestInit & { cwd: string }): Promise<T | null> {
|
||||
const { cwd, ...rest } = init;
|
||||
const res = await fetch(`${config.baseUrl}${path}`, {
|
||||
...rest,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
// The location is per REQUEST on this surface, not a property of the session. A call without it
|
||||
// runs against the serve's own directory, which is not where the user's files are.
|
||||
'x-opencode-directory': cwd,
|
||||
...(rest.headers ?? {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${path} → ${res.status}`);
|
||||
const text = await res.text();
|
||||
if (!text) return null;
|
||||
// This surface wraps everything in `{data: …}`; the legacy one does not. Reading `.id` off the
|
||||
// envelope silently yields undefined, which is how an entire afternoon disappeared once.
|
||||
const body = JSON.parse(text) as { data?: T } | T;
|
||||
return (body as { data?: T }).data ?? (body as T);
|
||||
}
|
||||
|
||||
// ── The turn ──
|
||||
|
||||
export async function runOpenCodeTurnOnServe(
|
||||
params: OpenCodeRunParams,
|
||||
config: ServeConfig,
|
||||
emit: Emit,
|
||||
): Promise<void> {
|
||||
const { sessionKey } = params;
|
||||
const cwd = params.cwd || config.fallbackCwd;
|
||||
|
||||
ensureEventStream(config);
|
||||
|
||||
// ── A message arriving while a turn is running is an INJECTION, not a new turn ──
|
||||
//
|
||||
// This is where the subprocess and the serve genuinely part company. `opencode run` had no input
|
||||
// channel, so a second message could only supersede: kill the process, start again, lose the turn.
|
||||
// The serve takes another prompt into the RUNNING turn, so the right move is to hand it over and keep
|
||||
// the existing turn exactly as it is.
|
||||
//
|
||||
// Keeping the same turn object is the load-bearing part. Retiring it and registering a replacement —
|
||||
// which is what this did at first — stops officer routing the events the serve is still producing,
|
||||
// while the serve carries on regardless. The output goes nowhere and the turn looks hung.
|
||||
//
|
||||
// `steer` because the user typed it during the turn and means it now; officer's composer already
|
||||
// treats a send-while-generating as "add this to what you are doing". A prompt sent when nothing is
|
||||
// running takes `queue`, which is a no-op with an empty queue but never accidentally merges two
|
||||
// messages into one turn.
|
||||
const live = bySessionKey.get(sessionKey);
|
||||
if (live && !live.done) {
|
||||
try {
|
||||
await serveJson(config, `/api/session/${live.openCodeSessionId}/prompt`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
prompt: { text: params.prompt, files: promptFiles(params.images) },
|
||||
delivery: 'steer',
|
||||
}),
|
||||
cwd,
|
||||
});
|
||||
} catch (err) {
|
||||
// The turn itself is unharmed — only the injection failed — so say so and leave it running.
|
||||
emit({
|
||||
type: 'opencode:event',
|
||||
sessionKey,
|
||||
event: { type: 'error', message: `OpenCode would not take that mid-turn: ${errText(err)}` },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let openCodeSessionId = params.resumeSessionId ?? '';
|
||||
|
||||
try {
|
||||
if (!openCodeSessionId) {
|
||||
const created = await serveJson<{ id: string }>(config, '/api/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ location: { directory: cwd } }),
|
||||
cwd,
|
||||
});
|
||||
if (!created?.id) throw new Error('session create returned no id');
|
||||
openCodeSessionId = created.id;
|
||||
}
|
||||
|
||||
if (params.model) {
|
||||
// `providerID/modelID`, the same string the subprocess passes to `--model`.
|
||||
const slash = params.model.indexOf('/');
|
||||
const providerID = slash > 0 ? params.model.slice(0, slash) : 'opencode';
|
||||
const id = slash > 0 ? params.model.slice(slash + 1) : params.model;
|
||||
await serveJson(config, `/api/session/${openCodeSessionId}/model`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ model: { providerID, id } }),
|
||||
cwd,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
emit({
|
||||
type: 'opencode:event',
|
||||
sessionKey,
|
||||
event: { type: 'error', message: `Could not start an OpenCode session: ${errText(err)}` },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const turn: ServeTurn = {
|
||||
sessionKey,
|
||||
openCodeSessionId,
|
||||
cost: { inputTokens: 0, outputTokens: 0, totalUSD: 0 },
|
||||
done: false,
|
||||
emit,
|
||||
finish: (event) => retire(turn, event),
|
||||
};
|
||||
byOpenCodeId.set(openCodeSessionId, turn);
|
||||
bySessionKey.set(sessionKey, turn);
|
||||
|
||||
// Officer learns which `ses_…` to resume next time — a routing fact, not transcript.
|
||||
emit({ type: 'opencode:session', sessionKey, sessionId: openCodeSessionId });
|
||||
|
||||
try {
|
||||
await serveJson(config, `/api/session/${openCodeSessionId}/prompt`, {
|
||||
method: 'POST',
|
||||
// `delivery` is stated explicitly because it DEFAULTS to `"steer"`, which injects into a running
|
||||
// turn. For an ordinary send that is the wrong default — two quick messages would merge into one
|
||||
// turn instead of running in order. `steer` is Phase C's job, wired to the button that means it.
|
||||
body: JSON.stringify({
|
||||
prompt: { text: params.prompt, files: promptFiles(params.images) },
|
||||
delivery: 'queue',
|
||||
}),
|
||||
cwd,
|
||||
});
|
||||
} catch (err) {
|
||||
retire(turn, { type: 'error', message: `OpenCode refused the prompt: ${errText(err)}` });
|
||||
}
|
||||
}
|
||||
|
||||
function retire(turn: ServeTurn, event: ChatEvent | null): void {
|
||||
if (turn.done) return;
|
||||
turn.done = true;
|
||||
if (byOpenCodeId.get(turn.openCodeSessionId) === turn) byOpenCodeId.delete(turn.openCodeSessionId);
|
||||
if (bySessionKey.get(turn.sessionKey) === turn) bySessionKey.delete(turn.sessionKey);
|
||||
if (event) turn.emit({ type: 'opencode:event', sessionKey: turn.sessionKey, event });
|
||||
}
|
||||
|
||||
const errText = (err: unknown): string => (err instanceof Error ? err.message : String(err));
|
||||
|
||||
/**
|
||||
* Images as `prompt.files`, which this surface takes by URI.
|
||||
*
|
||||
* **`data:` URIs, not `file://`.** Measured, because the choice is not obvious and the wrong one fails
|
||||
* at the provider rather than at the API: a `file://` attachment is accepted with a 200 and then dies
|
||||
* inside the turn with `Anthropic Messages media must contain valid base64`. A `data:` URI round-trips
|
||||
* and the model describes the image.
|
||||
*
|
||||
* This is strictly better than the subprocess path, which has to spill each image to a temp file for
|
||||
* `--file` and delete it afterwards. Here the bytes go in the request and there is nothing to clean up.
|
||||
*
|
||||
* Silently dropping these is exactly defect B4 — the user sees their image in their own bubble and the
|
||||
* model never receives it — so this exists before the serve path is switched on for anyone, not after.
|
||||
*/
|
||||
function promptFiles(images: OpenCodeRunParams['images']): { uri: string; name: string }[] | undefined {
|
||||
if (!images?.length) return undefined;
|
||||
return images.map((image, index) => ({
|
||||
uri: `data:${image.mediaType || 'image/png'};base64,${image.data}`,
|
||||
name: `attachment-${index + 1}`,
|
||||
}));
|
||||
}
|
||||
|
||||
/** The turns running right now, for `opencode:list` and the Live panel. */
|
||||
export function listRunningServeTurns(): { sessionKey: string }[] {
|
||||
return [...bySessionKey.keys()].map((sessionKey) => ({ sessionKey }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a turn without destroying its session — the thing the subprocess path cannot do.
|
||||
*
|
||||
* `POST /interrupt` leaves the conversation intact and resumable, where killing a subprocess ended it.
|
||||
*/
|
||||
export async function killServeTurn(sessionKey: string, config: ServeConfig): Promise<void> {
|
||||
const turn = bySessionKey.get(sessionKey);
|
||||
if (!turn) return;
|
||||
try {
|
||||
await fetch(`${config.baseUrl}/api/session/${turn.openCodeSessionId}/interrupt`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-opencode-directory': config.fallbackCwd },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
} catch {
|
||||
/* interrupt is best-effort; the turn is retired either way so the UI is never stuck */
|
||||
}
|
||||
retire(turn, { type: 'stopped' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire every live turn, for shutdown.
|
||||
*
|
||||
* Note what this does NOT do, and why it is right: the turns keep running inside the serve, which is a
|
||||
* separate process and survives us. Officer stops routing them and says so in the transcript. When the
|
||||
* subprocess ran turns, shutdown had to kill children or they were orphaned; here the work is somebody
|
||||
* else's and killing it would be the wrong call.
|
||||
*/
|
||||
export function stopAllServeTurns(message: string): number {
|
||||
const turns = [...bySessionKey.values()];
|
||||
for (const turn of turns) retire(turn, { type: 'error', message });
|
||||
return turns.length;
|
||||
}
|
||||
@@ -26,8 +26,8 @@ export type LiveClaudeSession = {
|
||||
};
|
||||
|
||||
/**
|
||||
* An OpenCode turn in flight. Only ever the generating ones — see `listRunningOpenCodeTurns` for why
|
||||
* this carries neither `isGenerating` (it is always true) nor `pendingTasks` (no such concept).
|
||||
* An OpenCode turn in flight. Only ever the generating ones, which is why it carries neither
|
||||
* `isGenerating` (always true) nor `pendingTasks` (no such concept on this harness).
|
||||
*
|
||||
* It deliberately carries no id for OpenCode's own `ses_…` session, and does not need to: the sidecar
|
||||
* only ever knows its own `sessionKey`, while the `ses_…` is reported separately over `opencode:session`
|
||||
@@ -63,12 +63,13 @@ export type SidecarCommand =
|
||||
// records are in memory and die with `pm2 restart officer`, while the agent keeps running. Without it
|
||||
// a live session is invisible until a browser happens to reconnect to it by id.
|
||||
| { type: 'claude:list'; id: string }
|
||||
// OpenCode — drive a turn via `opencode run … --format json` (tools re-anchored to cwd via --dir)
|
||||
// OpenCode — drive a turn through the serve (POST /api/session/{id}/prompt), anchored to the chat cwd
|
||||
// by a per-request location header. Was an `opencode run` subprocess until 2026-08-10.
|
||||
| { type: 'opencode:run-streaming'; id: string; params: OpenCodeRunParams }
|
||||
| { type: 'opencode:kill'; id: string; sessionKey: string }
|
||||
// Which OpenCode turns are running right now. The counterpart of `claude:list`, and thinner for a
|
||||
// reason: OpenCode has no warm session between turns, so there is nothing to report but the running
|
||||
// ones. See `listRunningOpenCodeTurns`.
|
||||
// ones — see `listRunningServeTurns`.
|
||||
| { type: 'opencode:list'; id: string }
|
||||
// VNC
|
||||
| { type: 'vnc:start'; id: string; params: VncStartParams }
|
||||
@@ -197,6 +198,14 @@ export type OpenCodeRunParams = {
|
||||
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`)
|
||||
/**
|
||||
* Images for this turn, base64 as the browser sent them.
|
||||
*
|
||||
* `opencode run` takes attachments as PATHS (`-f`), not inline data, so the sidecar writes each one to
|
||||
* a temp file and deletes it when the turn ends. They travel as data because that is what crosses the
|
||||
* websocket from the browser, and because officer and the sidecar are not guaranteed to share a disk.
|
||||
*/
|
||||
images?: PromptImage[];
|
||||
durable?: boolean; // commit turn output to chat_session_events (default true) — see ClaudeSpawnStreamingParams
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user