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
+11 -5
View File
@@ -14,8 +14,8 @@ import type { ChatEvent, MessageCost } from '../types';
// are only distinguishable by the delta's part TYPE (a message.part.updated declaring the part as // are only distinguishable by the delta's part TYPE (a message.part.updated declaring the part as
// `reasoning` vs `text` always precedes that part's deltas). So we gate deltas on partID being a `text` // `reasoning` vs `text` always precedes that part's deltas). So we gate deltas on partID being a `text`
// part; reasoning-part deltas are dropped (parity with the Claude harness, which hides thinking). // part; reasoning-part deltas are dropped (parity with the Claude harness, which hides thinking).
// createEventHandler flushes the assistant text buffer on tool:start and result, so no explicit `text` // The turn translator (`sidecar/claude/turn-stream.ts`, shared by both harnesses) flushes the assistant
// event is needed — the streamed answer deltas are enough. // text buffer on tool:start and result, so no explicit `text` event is needed — the deltas are enough.
export type OpenCodeEvent = { export type OpenCodeEvent = {
id?: string; id?: string;
@@ -96,7 +96,12 @@ export function createEventMapper(onEvent: (event: ChatEvent) => void) {
if (status === 'completed' && !toolFinished.has(callID)) { if (status === 'completed' && !toolFinished.has(callID)) {
toolFinished.add(callID); toolFinished.add(callID);
onEvent({ type: 'tool:result', toolCallId: callID, output: String(part.state?.output ?? ''), isError: false }); onEvent({
type: 'tool:result',
toolCallId: callID,
output: String(part.state?.output ?? ''),
isError: false,
});
} else if (status === 'error' && !toolFinished.has(callID)) { } else if (status === 'error' && !toolFinished.has(callID)) {
toolFinished.add(callID); toolFinished.add(callID);
onEvent({ onEvent({
@@ -110,7 +115,7 @@ export function createEventMapper(onEvent: (event: ChatEvent) => void) {
} }
case 'message.updated': { case 'message.updated': {
const info = ((p.info as AssistantInfo | undefined) ?? (p as AssistantInfo)) ?? {}; const info = (p.info as AssistantInfo | undefined) ?? (p as AssistantInfo) ?? {};
if (info.role === 'assistant' && info.tokens) { if (info.role === 'assistant' && info.tokens) {
cost = { cost = {
inputTokens: info.tokens.input ?? 0, inputTokens: info.tokens.input ?? 0,
@@ -128,7 +133,8 @@ export function createEventMapper(onEvent: (event: ChatEvent) => void) {
case 'session.error': { case 'session.error': {
const error = p.error; const error = p.error;
const message = typeof error === 'string' ? error : ((error as { message?: string })?.message ?? 'OpenCode error'); const message =
typeof error === 'string' ? error : ((error as { message?: string })?.message ?? 'OpenCode error');
finish({ type: 'error', message }); finish({ type: 'error', message });
return; return;
} }
+12 -184
View File
@@ -1,6 +1,6 @@
import type { ServerWebSocket } from 'bun'; import type { ServerWebSocket } from 'bun';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import type { ClientMessage, ServerMessage, Message, ChatEvent, TurnMessage, UserSession } from './types'; import type { ClientMessage, ServerMessage, Message, TurnMessage, UserSession } from './types';
import { sessionManager } from './session-manager'; import { sessionManager } from './session-manager';
import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code'; import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
import { sendOpenCodeStreaming } from '@@/channels/send-opencode'; import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
@@ -8,7 +8,7 @@ import { ensureGeneralChatSessionsCwd } from './claude-sessions';
import * as sidecar from '@@/sidecar-registry'; import * as sidecar from '@@/sidecar-registry';
import { join } from 'path'; import { join } from 'path';
import { getOwnerHomeDir, getEmailAccountsDir } from '../../../servers/data-path'; import { getOwnerHomeDir, getEmailAccountsDir } from '../../../servers/data-path';
import { getUserSettings, getEmailAccounts, appendChatEvent, getChatEventsSince } from 'officerdb'; import { getUserSettings, getEmailAccounts, getChatEventsSince } from 'officerdb';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import { logger } from './logger'; import { logger } from './logger';
@@ -96,24 +96,11 @@ function sendToClient(ws: ServerWebSocket<WSData> | null, msg: ServerMessage, se
} }
} }
// Persist a durable session event to the Postgres queue (for replay across reconnects) and deliver it // Nothing in officer writes to chat_session_events any more. Both harnesses commit their own turn
// live to the attached socket with its cursor `seq`. Events are queued even while the client is // output in the sidecar that produced it (`sidecar/claude/session-log.ts`), which is the whole point:
// DISCONNECTED (ws null) — that's what lets a reconnecting client replay what it missed (e.g. a // the durable record does not travel over the socket between the two processes, so officer can restart
// background task:notification). Streaming deltas are ephemeral: delivered live, never persisted. // mid-turn without losing it. Officer reads the table on `resume` (getChatEventsSince) and relays what
async function emitToSession(sessionId: string, msg: ServerMessage): Promise<void> { // the sidecars send. The old `emitToSession` used to live here.
const ws = (sessionManager.getSession(sessionId)?.ws ?? null) as ServerWebSocket<WSData> | null;
if (msg.type === 'assistant:delta') {
sendToClient(ws, msg);
return;
}
let seq: number | undefined;
try {
seq = await appendChatEvent(sessionId, msg);
} catch (err) {
logger.error('Failed to persist chat event', { sessionId, error: String(err) });
}
sendToClient(ws, msg, seq);
}
export async function open(ws: ServerWebSocket<WSData>): Promise<void> { export async function open(ws: ServerWebSocket<WSData>): Promise<void> {
const timer = setInterval(() => { const timer = setInterval(() => {
@@ -228,7 +215,8 @@ function foldIntoSession(session: UserSession, msg: TurnMessage, model: string):
} }
} }
function createClaudeMessageHandler(sessionId: string, model: string) { // Shared by both harnesses now: whichever sidecar ran the turn has already translated and committed it.
function createMessageHandler(sessionId: string, model: string) {
return (msg: TurnMessage, seq?: number): void => { return (msg: TurnMessage, seq?: number): void => {
const session = sessionManager.getSession(sessionId); const session = sessionManager.getSession(sessionId);
if (!session) return; if (!session) return;
@@ -237,166 +225,6 @@ function createClaudeMessageHandler(sessionId: string, model: string) {
}; };
} }
// ── OpenCode: officer still translates and persists ──
// Unchanged from before the split, and still correct for OpenCode: that sidecar reports raw ChatEvents,
// so officer does the translation and owns the durable write. Moving it is the `sidecars-opencode` branch.
function createEventHandler(sessionId: string, model: string, cwd: string) {
return async (event: ChatEvent): Promise<void> => {
const session = sessionManager.getSession(sessionId);
if (!session) return;
// All durable sends go through emitToSession (persist + deliver with cursor seq). Deltas stay live-only.
switch (event.type) {
case 'delta': {
await emitToSession(sessionId, { type: 'assistant:delta', text: event.text });
session.streamBuffer += event.text;
break;
}
case 'text': {
// Flush streaming buffer as complete text
const text = event.text || session.streamBuffer;
if (text) {
await emitToSession(sessionId, { type: 'assistant:text', text });
const assistantMsg: Message = {
id: randomUUID(),
timestamp: Date.now(),
role: 'assistant',
text,
model,
};
session.messages.push(assistantMsg);
session.meta.messageCount += 1;
session.streamBuffer = '';
}
break;
}
case 'tool:start': {
// Flush any pending streaming text first
if (session.streamBuffer) {
await emitToSession(sessionId, { type: 'assistant:text', text: session.streamBuffer });
const assistantMsg: Message = {
id: randomUUID(),
timestamp: Date.now(),
role: 'assistant',
text: session.streamBuffer,
model,
};
session.messages.push(assistantMsg);
session.meta.messageCount += 1;
session.streamBuffer = '';
}
await emitToSession(sessionId, {
type: 'tool:start',
toolCallId: event.toolCallId,
toolName: event.toolName,
toolInput: event.toolInput,
});
const toolMsg: Message = {
id: randomUUID(),
timestamp: Date.now(),
role: 'tool',
toolCallId: event.toolCallId,
toolName: event.toolName,
toolInput: event.toolInput,
};
session.messages.push(toolMsg);
session.meta.messageCount += 1;
break;
}
case 'tool:result': {
await emitToSession(sessionId, {
type: 'tool:result',
toolCallId: event.toolCallId,
output: event.output,
isError: event.isError,
});
// Update existing tool message with output
for (let i = session.messages.length - 1; i >= 0; i--) {
const m = session.messages[i]!;
if (m.role === 'tool' && m.toolCallId === event.toolCallId) {
m.output = event.output;
m.isError = event.isError;
break;
}
}
break;
}
case 'result': {
// Flush any remaining streaming buffer
if (session.streamBuffer) {
await emitToSession(sessionId, { type: 'assistant:text', text: session.streamBuffer });
const assistantMsg: Message = {
id: randomUUID(),
timestamp: Date.now(),
role: 'assistant',
text: session.streamBuffer,
model,
cost: event.cost,
};
session.messages.push(assistantMsg);
session.meta.messageCount += 1;
session.streamBuffer = '';
}
await emitToSession(sessionId, { type: 'result', sessionId, cost: event.cost });
session.isGenerating = false;
session.meta.cost.inputTokens += event.cost.inputTokens;
session.meta.cost.outputTokens += event.cost.outputTokens;
session.meta.cost.totalUSD += event.cost.totalUSD;
session.meta.updatedAt = Date.now();
// No disk persistence — Claude's transcript is the record.
break;
}
case 'error': {
await emitToSession(sessionId, { type: 'error', message: event.message });
session.isGenerating = false;
break;
}
case 'stopped': {
await emitToSession(sessionId, { type: 'stopped' });
session.isGenerating = false;
break;
}
case 'task:started': {
// Background task launched (run_in_background / Monitor). Independent of turn state.
await emitToSession(sessionId, {
type: 'task:started',
taskId: event.taskId,
description: event.description,
taskType: event.taskType,
});
break;
}
case 'task:notification': {
// Background task finished — arrives AFTER the turn's 'result'; the whole point of the fix.
await emitToSession(sessionId, {
type: 'task:notification',
taskId: event.taskId,
status: event.status,
summary: event.summary,
});
break;
}
}
};
}
async function handleChat( async function handleChat(
ws: ServerWebSocket<WSData>, ws: ServerWebSocket<WSData>,
msg: { msg: {
@@ -487,7 +315,7 @@ async function handleClaudeCodeChat(
session.isGenerating = true; session.isGenerating = true;
const onMessage = createClaudeMessageHandler(sessionId, model); const onMessage = createMessageHandler(sessionId, model);
try { try {
if (!session._claudeKill) { if (!session._claudeKill) {
@@ -578,7 +406,7 @@ async function handleOpenCodeChat(
session.isGenerating = true; session.isGenerating = true;
const onEvent = createEventHandler(sessionId, model, cwd); const onMessage = createMessageHandler(sessionId, model);
try { try {
const handle = await sendOpenCodeStreaming({ const handle = await sendOpenCodeStreaming({
@@ -590,7 +418,7 @@ async function handleOpenCodeChat(
cwd, cwd,
model, model,
resumeSessionId: msg.resumeSessionId, resumeSessionId: msg.resumeSessionId,
onEvent, onMessage,
}); });
// Store the abort handle so handleStop can end the turn (OpenCode is aborted via this handle). // Store the abort handle so handleStop can end the turn (OpenCode is aborted via this handle).
+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 { logger } from '@@/api/chat/logger';
import * as sidecar from '@@/sidecar-registry'; import * as sidecar from '@@/sidecar-registry';
import { getOpenCodeSession } from '@@/api/chat/opencode/state'; import { getOpenCodeSession } from '@@/api/chat/opencode/state';
// The OpenCode analog of send-claude-code.ts: it drives a turn through the officer-opencode sidecar, // 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 // which spawns `opencode run … --format json` (tools hard-anchored to the chat cwd via --dir), maps its
// streams mapped ChatEvents back over the sidecar WS. We subscribe to those events (filtered by // output to TurnMessages, commits each one to chat_session_events and streams the finished message plus
// sessionKey) and forward them to the caller's onEvent — the same shared contract the Claude harness // its cursor id back over the sidecar WS. We subscribe (filtered by sessionKey) and forward to the
// uses, so createEventHandler and the whole UI pipeline are unchanged. // caller's onMessage — the same contract sendClaudeCodeStreaming uses.
type OpenCodeStreamingParams = { type OpenCodeStreamingParams = {
userId: number; userId: number;
@@ -19,7 +19,9 @@ type OpenCodeStreamingParams = {
model?: string; model?: string;
role?: string; role?: string;
resumeSessionId?: 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 = { type OpenCodeStreamingHandle = {
@@ -29,11 +31,13 @@ type OpenCodeStreamingHandle = {
export async function sendOpenCodeStreaming(params: OpenCodeStreamingParams): Promise<OpenCodeStreamingHandle> { export async function sendOpenCodeStreaming(params: OpenCodeStreamingParams): Promise<OpenCodeStreamingHandle> {
logger.info('OpenCode streaming exec (via sidecar)', { sessionKey: params.sessionKey, model: params.model }); logger.info('OpenCode streaming exec (via sidecar)', { sessionKey: params.sessionKey, model: params.model });
// Forward this session's turn events; unsubscribe on the terminal event. // Forward this session's turn messages; unsubscribe on the terminal one. Unlike the Claude harness
const unsub = sidecar.onOpenCodeEvent((sessionKey, event) => { // 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; if (sessionKey !== params.sessionKey) return;
params.onEvent(event); params.onMessage(msg, seq);
if (event.type === 'result' || event.type === 'error' || event.type === 'stopped') unsub(); 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 // 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, cwd: params.cwd,
model: params.model, model: params.model,
resumeSessionId, resumeSessionId,
durable: params.durable,
}); });
} catch (err) { } catch (err) {
unsub(); unsub();
+6 -9
View File
@@ -13,7 +13,7 @@ import type {
VncSessionInfo, VncSessionInfo,
} from './sidecar/protocol'; } from './sidecar/protocol';
import type { SidecarRegistration } from './sidecar/registration-protocol'; import type { SidecarRegistration } from './sidecar/registration-protocol';
import type { ChatEvent, TurnMessage } from './api/chat/types'; import type { TurnMessage } from './api/chat/types';
// ── Types ── // ── Types ──
@@ -298,14 +298,11 @@ export function killOpenCode(sessionKey: string): void {
sendFire('opencode', { type: 'opencode:kill', id: nextId(), sessionKey }); sendFire('opencode', { type: 'opencode:kill', id: nextId(), sessionKey });
} }
export function onOpenCodeEvent(handler: (sessionKey: string, event: ChatEvent) => void): () => void { export function onOpenCodeMessage(handler: (sessionKey: string, msg: TurnMessage, seq?: number) => void): () => void {
return on('opencode:event', (msg) => { return on('opencode:message', (ev) => {
if (msg.type === 'opencode:event') { if (ev.type !== 'opencode:message') return;
handler( const msg = ev as SidecarEvent & { type: 'opencode:message' };
(msg as SidecarEvent & { type: 'opencode:event' }).sessionKey, handler(msg.sessionKey, msg.msg, msg.seq);
(msg as SidecarEvent & { type: 'opencode:event' }).event,
);
}
}); });
} }
+3 -2
View File
@@ -1,7 +1,8 @@
import type { ChatEvent, TurnMessage } from '../../api/chat/types'; import type { ChatEvent, TurnMessage } from '../../api/chat/types';
// Translation from the parser's ChatEvent stream to the browser-facing turn messages, moved here from // Translation from a harness's ChatEvent stream to the browser-facing turn messages, moved here from the
// the main server (`chat/websocket.ts:createEventHandler`). It lives with the process that produces the // main server (it was `createEventHandler` in `chat/websocket.ts`, once per harness). Both the agent and
// the opencode sidecar use this one copy. It lives with the process that produces the
// stream because it is stateful: `delta` events accumulate into a buffer that has to be flushed as one // stream because it is stateful: `delta` events accumulate into a buffer that has to be flushed as one
// `assistant:text` at the next boundary (a tool call, or the end of the turn). A consumer downstream of // `assistant:text` at the next boundary (a tool call, or the end of the turn). A consumer downstream of
// a socket that can drop cannot hold that state correctly. // a socket that can drop cannot hold that state correctly.
+26 -8
View File
@@ -3,6 +3,7 @@ import { homedir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { DATA_PATH } from '../../data-path'; import { DATA_PATH } from '../../data-path';
import { createSidecarConnector } from '../connect'; import { createSidecarConnector } from '../connect';
import { createSessionLogStore } from '../claude/session-log';
import type { SidecarCommand, SidecarEvent } from '../protocol'; import type { SidecarCommand, SidecarEvent } from '../protocol';
import { runOpenCodeTurn, killOpenCodeTurn } from './runner'; import { runOpenCodeTurn, killOpenCodeTurn } from './runner';
@@ -131,22 +132,33 @@ console.log(`[opencode] serve healthy on port ${port}`);
// ── Command handlers ── // ── Command handlers ──
type ReplyFn = (msg: SidecarEvent) => void; type ReplyFn = (msg: SidecarEvent) => void;
type SendFn = (msg: SidecarEvent) => void;
const RUNNER_CONFIG = { bin: OPENCODE_BIN, fallbackCwd: SERVE_CWD }; 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) { switch (cmd.type) {
case 'ping': case 'ping':
reply({ type: 'pong', id: cmd.id }); reply({ type: 'pong', id: cmd.id });
break; break;
case 'opencode:run-streaming': case 'opencode:run-streaming': {
// Fire the turn; events stream back via `send` (opencode:event / opencode:session / terminal). const { sessionKey, durable = true } = cmd.params;
runOpenCodeTurn(cmd.params, RUNNER_CONFIG, send); // Turn output goes through the session log: translated to TurnMessages and committed to
reply({ type: 'opencode:spawned', id: cmd.id, sessionKey: cmd.params.sessionKey }); // 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; break;
}
case 'opencode:kill': case 'opencode:kill':
killOpenCodeTurn(cmd.sessionKey); killOpenCodeTurn(cmd.sessionKey);
sessionLog.drop(cmd.sessionKey);
break; break;
default: default:
reply({ reply({
@@ -164,8 +176,7 @@ const connection = createSidecarConnector({
name: 'opencode', name: 'opencode',
capabilities: ['opencode'], capabilities: ['opencode'],
onCommand(cmd, reply) { 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);
handleCommand(cmd as SidecarCommand, reply as ReplyFn, (msg) => connection.send(msg));
}, },
onConnected() { onConnected() {
// Tell the API where our OpenCode HTTP server is listening, so it can route requests there. // 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 ── // ── Graceful shutdown ──
function shutdown(signal: string) { function shutdown(signal: string) {
+13 -3
View File
@@ -1,7 +1,7 @@
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import type { Subprocess } from 'bun'; import type { Subprocess } from 'bun';
import type { ChatEvent, MessageCost } from '../../api/chat/types'; import type { ChatEvent, MessageCost } from '../../api/chat/types';
import type { OpenCodeRunParams, SidecarEvent } from '../protocol'; import type { OpenCodeRunParams } from '../protocol';
// Drives one chat turn by spawning `opencode run … --format json` and mapping its newline-delimited // 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 // JSON events to the shared ChatEvent contract. This is the reliable path: `--dir <cwd>` hard-anchors
@@ -21,7 +21,14 @@ export type RunnerConfig = {
fallbackCwd: string; // used when params.cwd is missing/nonexistent fallbackCwd: string; // used when params.cwd is missing/nonexistent
}; };
type Emit = (event: SidecarEvent) => void; // 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 }; type RunHandle = { proc: Subprocess; killedByUser: boolean };
@@ -108,7 +115,10 @@ export function runOpenCodeTurn(params: OpenCodeRunParams, config: RunnerConfig,
} catch { } catch {
/* already gone */ /* already gone */
} }
finish({ type: 'error', message: `OpenCode turn stalled (no output for ${INACTIVITY_MS / 1000}s) and was stopped` }); finish({
type: 'error',
message: `OpenCode turn stalled (no output for ${INACTIVITY_MS / 1000}s) and was stopped`,
});
}, INACTIVITY_MS); }, INACTIVITY_MS);
}; };
bumpInactivity(); bumpInactivity();
+7 -3
View File
@@ -1,4 +1,4 @@
import type { MessageCost, ChatEvent, TurnMessage } from '../api/chat/types'; import type { MessageCost, TurnMessage } from '../api/chat/types';
// ── Envelope ── // ── Envelope ──
@@ -54,9 +54,12 @@ export type SidecarEvent =
| { type: 'email:new'; userEmail: string } | { type: 'email:new'; userEmail: string }
// OpenCode — the sidecar reports where its `opencode serve` is listening (random port) on connect // OpenCode — the sidecar reports where its `opencode serve` is listening (random port) on connect
| { type: 'opencode:server'; port: number } | { type: 'opencode:server'; port: number }
// OpenCode turn streaming (analog of claude:*): spawned ack, per-event stream, session id report // OpenCode turn streaming (analog of claude:*): spawned ack, per-message stream, session id report
| { type: 'opencode:spawned'; id: string; sessionKey: string } | { type: 'opencode:spawned'; id: string; sessionKey: string }
| { type: 'opencode:event'; sessionKey: string; event: ChatEvent } // Same contract as `claude:message`: a finished turn message the sidecar has already committed to
// chat_session_events, plus the cursor id it landed on. Officer relays it and folds it into its
// in-memory transcript; it does not translate or persist.
| { type: 'opencode:message'; sessionKey: string; msg: TurnMessage; seq?: number }
| { type: 'opencode:session'; sessionKey: string; sessionId: string } | { type: 'opencode:session'; sessionKey: string; sessionId: string }
| { type: 'opencode:error'; id: string; error: string } | { type: 'opencode:error'; id: string; error: string }
// Music — the sidecar reports where its audio-streaming HTTP server is listening (random port) on connect // Music — the sidecar reports where its audio-streaming HTTP server is listening (random port) on connect
@@ -117,6 +120,7 @@ export type OpenCodeRunParams = {
cwd?: string; // passed to `opencode run --dir` — hard-re-anchors tools to this directory 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 model?: string; // `providerID/modelID` (e.g. opencode/claude-haiku-4-5); passed to --model verbatim
resumeSessionId?: string; // OpenCode `ses_…` id to continue (`--session`) resumeSessionId?: string; // OpenCode `ses_…` id to continue (`--session`)
durable?: boolean; // commit turn output to chat_session_events (default true) — see ClaudeSpawnStreamingParams
}; };
// ── VNC types ── // ── VNC types ──