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
+15 -10
View File
@@ -1,13 +1,13 @@
import type { ChatEvent } from '@@/api/chat/types';
import type { TurnMessage } from '@@/api/chat/types';
import { logger } from '@@/api/chat/logger';
import * as sidecar from '@@/sidecar-registry';
import { getOpenCodeSession } from '@@/api/chat/opencode/state';
// The OpenCode analog of send-claude-code.ts: it drives a turn through the officer-opencode sidecar,
// which spawns `opencode run … --format json` (tools hard-anchored to the chat cwd via --dir) and
// streams mapped ChatEvents back over the sidecar WS. We subscribe to those events (filtered by
// sessionKey) and forward them to the caller's onEvent — the same shared contract the Claude harness
// uses, so createEventHandler and the whole UI pipeline are unchanged.
// which spawns `opencode run … --format json` (tools hard-anchored to the chat cwd via --dir), maps its
// output to TurnMessages, commits each one to chat_session_events and streams the finished message plus
// its cursor id back over the sidecar WS. We subscribe (filtered by sessionKey) and forward to the
// caller's onMessage — the same contract sendClaudeCodeStreaming uses.
type OpenCodeStreamingParams = {
userId: number;
@@ -19,7 +19,9 @@ type OpenCodeStreamingParams = {
model?: string;
role?: string;
resumeSessionId?: string;
onEvent: (event: ChatEvent) => void;
durable?: boolean;
// Finished turn messages, already committed by the sidecar; `seq` is the cursor id to deliver them under.
onMessage: (msg: TurnMessage, seq?: number) => void;
};
type OpenCodeStreamingHandle = {
@@ -29,11 +31,13 @@ type OpenCodeStreamingHandle = {
export async function sendOpenCodeStreaming(params: OpenCodeStreamingParams): Promise<OpenCodeStreamingHandle> {
logger.info('OpenCode streaming exec (via sidecar)', { sessionKey: params.sessionKey, model: params.model });
// Forward this session's turn events; unsubscribe on the terminal event.
const unsub = sidecar.onOpenCodeEvent((sessionKey, event) => {
// Forward this session's turn messages; unsubscribe on the terminal one. Unlike the Claude harness
// there is nothing after `result` here — `opencode run` exits with the turn, so it has no background
// tasks that could report later.
const unsub = sidecar.onOpenCodeMessage((sessionKey, msg, seq) => {
if (sessionKey !== params.sessionKey) return;
params.onEvent(event);
if (event.type === 'result' || event.type === 'error' || event.type === 'stopped') unsub();
params.onMessage(msg, seq);
if (msg.type === 'result' || msg.type === 'error' || msg.type === 'stopped') unsub();
});
// Resume an existing OpenCode session when we know its id: a stored mapping (set from the sidecar's
@@ -51,6 +55,7 @@ export async function sendOpenCodeStreaming(params: OpenCodeStreamingParams): Pr
cwd: params.cwd,
model: params.model,
resumeSessionId,
durable: params.durable,
});
} catch (err) {
unsub();