move opencode's turn output into its own sidecar

The second copy of the same problem. The opencode sidecar reported raw
ChatEvents and officer translated them, buffered the assistant text and wrote
every durable message to chat_session_events — so an officer restart mid-turn
lost whatever the model had produced since the last write, and `connect.ts`
dropped the events that arrived while it was down without a word.

Both harnesses speak ChatEvents, so the sidecar reuses the agent's session log
verbatim: translate, commit, then deliver the finished message with its cursor
id as `opencode:message`. Officer folds it into the in-memory transcript and
relays it, exactly as it now does for claude — `createEventHandler` (166 lines,
a duplicate of turn-stream.ts) and `emitToSession` are gone, and nothing in
officer writes to chat_session_events any more.

`opencode:event` stops being a wire event; it is the runner's internal report to
the sidecar it runs in, typed as such so it cannot leak back onto the socket.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 05:18:09 +00:00
co-authored by Claude Opus 4.8
parent 238c3b8097
commit 71e39b7639
8 changed files with 93 additions and 224 deletions
+26 -8
View File
@@ -3,6 +3,7 @@ import { homedir } from 'node:os';
import { join } from 'node:path';
import { DATA_PATH } from '../../data-path';
import { createSidecarConnector } from '../connect';
import { createSessionLogStore } from '../claude/session-log';
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { runOpenCodeTurn, killOpenCodeTurn } from './runner';
@@ -131,22 +132,33 @@ console.log(`[opencode] serve healthy on port ${port}`);
// ── Command handlers ──
type ReplyFn = (msg: SidecarEvent) => void;
type SendFn = (msg: SidecarEvent) => void;
const RUNNER_CONFIG = { bin: OPENCODE_BIN, fallbackCwd: SERVE_CWD };
function handleCommand(cmd: SidecarCommand, reply: ReplyFn, send: SendFn) {
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
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 });
case 'opencode:run-streaming': {
const { sessionKey, durable = true } = cmd.params;
// 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) => {
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);
});
reply({ type: 'opencode:spawned', id: cmd.id, sessionKey });
break;
}
case 'opencode:kill':
killOpenCodeTurn(cmd.sessionKey);
sessionLog.drop(cmd.sessionKey);
break;
default:
reply({
@@ -164,8 +176,7 @@ const connection = createSidecarConnector({
name: 'opencode',
capabilities: ['opencode'],
onCommand(cmd, reply) {
// 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));
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
},
onConnected() {
// Tell the API where our OpenCode HTTP server is listening, so it can route requests there.
@@ -174,6 +185,13 @@ const connection = createSidecarConnector({
},
});
// Translate → commit → deliver, in that order and one at a time per session. Shared with the agent
// sidecar (`claude/session-log.ts`): both harnesses speak ChatEvents, so the translation and the write
// are the same code, and only the wire event type differs.
const sessionLog = createSessionLogStore((d) =>
connection.send({ type: 'opencode:message', sessionKey: d.sessionId, msg: d.msg, seq: d.seq }),
);
// ── Graceful shutdown ──
function shutdown(signal: string) {