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:
@@ -1,7 +1,8 @@
|
||||
import type { ChatEvent, TurnMessage } from '../../api/chat/types';
|
||||
|
||||
// Translation from the parser's ChatEvent stream to the browser-facing turn messages, moved here from
|
||||
// the main server (`chat/websocket.ts:createEventHandler`). It lives with the process that produces the
|
||||
// Translation from a harness's ChatEvent stream to the browser-facing turn messages, moved here from 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
|
||||
// `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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import type { Subprocess } from 'bun';
|
||||
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
|
||||
// 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
|
||||
};
|
||||
|
||||
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 };
|
||||
|
||||
@@ -108,7 +115,10 @@ export function runOpenCodeTurn(params: OpenCodeRunParams, config: RunnerConfig,
|
||||
} catch {
|
||||
/* 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);
|
||||
};
|
||||
bumpInactivity();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MessageCost, ChatEvent, TurnMessage } from '../api/chat/types';
|
||||
import type { MessageCost, TurnMessage } from '../api/chat/types';
|
||||
|
||||
// ── Envelope ──
|
||||
|
||||
@@ -54,9 +54,12 @@ export type SidecarEvent =
|
||||
| { type: 'email:new'; userEmail: string }
|
||||
// OpenCode — the sidecar reports where its `opencode serve` is listening (random port) on connect
|
||||
| { 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: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:error'; id: string; error: string }
|
||||
// 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
|
||||
model?: string; // `providerID/modelID` (e.g. opencode/claude-haiku-4-5); passed to --model verbatim
|
||||
resumeSessionId?: string; // OpenCode `ses_…` id to continue (`--session`)
|
||||
durable?: boolean; // commit turn output to chat_session_events (default true) — see ClaudeSpawnStreamingParams
|
||||
};
|
||||
|
||||
// ── VNC types ──
|
||||
|
||||
Reference in New Issue
Block a user