phase B: run a turn through the serve, behind a switch

OPENCODE_TURNS=serve picks the new engine; unset keeps the subprocess, which is the default
and stays the default until this has been lived with. A bad evening should cost one restart,
not a revert. Claude is a different sidecar and is untouched.

Verified end to end through the real chat socket:

  session:init -> tool:start(bash) -> tool:result -> assistant:delta x3 -> assistant:text
  -> result, cost in=304 out=73

Those deltas are the first token streaming an opencode turn has ever produced in officer.
Stop is now an INTERRUPT: the turn ends and the session survives — verified by sending a
second prompt to the same session afterwards and getting an answer, which killing a
subprocess could never do.

Reads the LIVE global stream rather than the durable per-session one, because it is a strict
superset — same tool.called, tool.success, step.ended, text.ended, plus the deltas that are
the whole point. Global means one socket carries every session, so everything filters on
sessionID; one subscription is shared for the process rather than one per turn.

A turn ends on step.ended with finish != tool-calls. tool-calls is a step boundary MID-turn,
and treating it as terminal would cut every tool-using conversation in half.

delivery is stated explicitly as queue because it DEFAULTS to steer, which injects into a
running turn — wrong for an ordinary send, where two quick messages would merge into one.
Wiring steer to the button that means it is phase C.

What phase B does not do: read the durable stream. The sidecar still commits every event to
chat_session_events as it arrives, so durability is unchanged, but recovering a turn this
process never saw needs the ?after= cursor and is its own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 19:12:49 +01:00
co-authored by Claude Opus 5
parent feb9010097
commit a06422bd4c
3 changed files with 319 additions and 5 deletions
+31 -5
View File
@@ -7,7 +7,9 @@ import { sweepRecordedServe } from './serve-sweep';
import { connectProviderCredential } from './connect-credential';
import { createSessionLogStore } from '../claude/session-log';
import type { SidecarCommand, SidecarEvent } from '../protocol';
import type { RunnerMessage } from './runner';
import { runOpenCodeTurn, killOpenCodeTurn, listRunningOpenCodeTurns, stopAllOpenCodeTurns } from './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
@@ -143,6 +145,17 @@ void connectProviderCredential(baseUrl);
type ReplyFn = (msg: SidecarEvent) => void;
const RUNNER_CONFIG = { bin: OPENCODE_BIN, fallbackCwd: SERVE_CWD };
const SERVE_RUNNER_CONFIG = { baseUrl, fallbackCwd: SERVE_CWD };
// Which engine runs a turn. `subprocess` (the default) spawns `opencode run`; `serve` drives the
// serve's /api/session surface, which is the only way to get streaming, steer, queue and a stop that
// leaves the session alive.
//
// A switch rather than a replacement, and defaulted to the old path on purpose: the subprocess has
// worked all day and the serve path has not been lived with yet. A bad evening should cost one restart
// with OPENCODE_TURNS unset, not a revert.
const USE_SERVE_TURNS = (process.env.OPENCODE_TURNS ?? 'subprocess').toLowerCase() === 'serve';
console.log(`[opencode] turn engine: ${USE_SERVE_TURNS ? 'serve (/api/session)' : 'subprocess (opencode run)'}`);
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
switch (cmd.type) {
@@ -154,23 +167,35 @@ 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);
});
};
if (USE_SERVE_TURNS) void runOpenCodeTurnOnServe(cmd.params, SERVE_RUNNER_CONFIG, onMessage);
else runOpenCodeTurn(cmd.params, 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: USE_SERVE_TURNS ? listRunningServeTurns() : listRunningOpenCodeTurns(),
});
break;
case 'opencode:kill':
killOpenCodeTurn(cmd.sessionKey);
// On the serve this is an INTERRUPT: the turn stops and the session survives, so the conversation
// can be continued rather than only re-opened.
if (USE_SERVE_TURNS) void killServeTurn(cmd.sessionKey, SERVE_RUNNER_CONFIG);
else killOpenCodeTurn(cmd.sessionKey);
sessionLog.drop(cmd.sessionKey);
break;
default:
@@ -221,7 +246,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 = USE_SERVE_TURNS ? stopAllServeTurns(message) : stopAllOpenCodeTurns(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)]);