Merge branch 'sidecars-claude' into sidecars

This commit is contained in:
2026-07-30 05:43:05 +00:00
20 changed files with 1129 additions and 182 deletions
+21
View File
@@ -138,6 +138,27 @@ export type ServerMessage =
| { type: 'task:started'; taskId: string; description: string; taskType?: string }
| { type: 'task:notification'; taskId: string; status: 'completed' | 'failed' | 'stopped'; summary: string };
// The turn-output subset of ServerMessage — everything the agent sidecar produces on its own. The
// remaining members (session:init, sync:messages, disconnected, connection-level errors) are officer's:
// they describe the browser's connection, not the turn.
//
// The sidecar builds these, commits them to chat_session_events, and hands officer a finished message
// plus its cursor id; officer relays it verbatim. `prevSeq` is the writer's continuity claim — the
// cursor of the previous durable message in the same session — which lets a reconnecting client tell a
// contiguous replay from one with a hole in it. Absent when the writer cannot vouch for it.
export type TurnMessageType =
| 'assistant:delta'
| 'assistant:text'
| 'tool:start'
| 'tool:result'
| 'result'
| 'error'
| 'stopped'
| 'task:started'
| 'task:notification';
export type TurnMessage = Extract<ServerMessage, { type: TurnMessageType }> & { prevSeq?: number };
export type ChatEvent =
| { type: 'text'; text: string }
| { type: 'delta'; text: string }
+92 -6
View File
@@ -1,6 +1,6 @@
import type { ServerWebSocket } from 'bun';
import { randomUUID } from 'crypto';
import type { ClientMessage, ServerMessage, Message, ChatEvent } from './types';
import type { ClientMessage, ServerMessage, Message, ChatEvent, TurnMessage, UserSession } from './types';
import { sessionManager } from './session-manager';
import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
@@ -165,6 +165,82 @@ export function close(ws: ServerWebSocket<WSData>): void {
}
}
// ── Claude Code: relay, don't rebuild ──
// The agent sidecar owns Claude's turn output end to end — it translates the parser stream, commits each
// message to chat_session_events and hands us a finished message plus its cursor id. Officer relays it.
// That is what makes a restart survivable: the durable record no longer travels over the socket between
// the two processes, so if this one is down the output is already written and the client replays it.
//
// Officer keeps only the in-memory transcript, which exists to answer a `resume` with sync:messages —
// Claude's own transcript is the real record — so it is folded from the same messages, not rebuilt.
function foldIntoSession(session: UserSession, msg: TurnMessage, model: string): void {
switch (msg.type) {
case 'assistant:delta':
session.streamBuffer += msg.text;
break;
case 'assistant:text':
session.messages.push({ id: randomUUID(), timestamp: Date.now(), role: 'assistant', text: msg.text, model });
session.meta.messageCount += 1;
session.streamBuffer = '';
break;
case 'tool:start':
session.messages.push({
id: randomUUID(),
timestamp: Date.now(),
role: 'tool',
toolCallId: msg.toolCallId,
toolName: msg.toolName,
toolInput: msg.toolInput,
});
session.meta.messageCount += 1;
break;
case 'tool:result':
for (let i = session.messages.length - 1; i >= 0; i--) {
const m = session.messages[i]!;
if (m.role === 'tool' && m.toolCallId === msg.toolCallId) {
m.output = msg.output;
m.isError = msg.isError;
break;
}
}
break;
case 'result': {
session.isGenerating = false;
session.meta.cost.inputTokens += msg.cost.inputTokens;
session.meta.cost.outputTokens += msg.cost.outputTokens;
session.meta.cost.totalUSD += msg.cost.totalUSD;
session.meta.updatedAt = Date.now();
// The turn's cost belongs to the assistant message it paid for (as it did when officer built these).
const last = session.messages[session.messages.length - 1];
if (last?.role === 'assistant' && !last.cost) last.cost = msg.cost;
break;
}
case 'error':
case 'stopped':
session.isGenerating = false;
break;
}
}
function createClaudeMessageHandler(sessionId: string, model: string) {
return (msg: TurnMessage, seq?: number): void => {
const session = sessionManager.getSession(sessionId);
if (!session) return;
foldIntoSession(session, msg, model);
sendToClient(session.ws as ServerWebSocket<WSData> | null, msg, seq);
};
}
// ── 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);
@@ -298,13 +374,23 @@ function createEventHandler(sessionId: string, model: string, cwd: string) {
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 });
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 });
await emitToSession(sessionId, {
type: 'task:notification',
taskId: event.taskId,
status: event.status,
summary: event.summary,
});
break;
}
}
@@ -401,7 +487,7 @@ async function handleClaudeCodeChat(
session.isGenerating = true;
const onEvent = createEventHandler(sessionId, model, cwd);
const onMessage = createClaudeMessageHandler(sessionId, model);
try {
if (!session._claudeKill) {
@@ -416,7 +502,7 @@ async function handleClaudeCodeChat(
cwd,
model,
resumeSessionId: msg.resumeSessionId,
onEvent,
onMessage,
});
session.piProcess = sessionId as any;
session._claudeKill = handle.kill;
@@ -572,7 +658,7 @@ async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
if (isClaudeModel(session.model)) {
// Interrupt the current turn but KEEP the persistent session alive (background tasks + the
// warm worker survive). Full teardown is 'disconnect' → deleteSession → _claudeKill.
void sidecar.interruptClaude(sessionId, session.email);
void sidecar.interruptClaude(sessionId);
logger.info('Interrupted Claude Code turn via sidecar (session stays warm)', { sessionId });
} else {
session._claudeKill?.(); // OpenCode: abort the turn via the stored handle
+26 -18
View File
@@ -8,7 +8,7 @@ import { getTaskByDirName } from './task-files';
import { getHomeDir } from '../../data-path';
import { resolveBaseCwd } from '../chat/websocket';
import { sendClaudeCodeStreaming } from '../../channels/send-claude-code';
import type { ChatEvent, MessageCost } from '../chat/types';
import type { TurnMessage, MessageCost } from '../chat/types';
import * as jobManager from './pipeline-job-manager';
const DEFAULT_MODEL = 'claude-code';
@@ -148,23 +148,26 @@ async function runAgenticStep({
fn();
};
const onEvent = (event: ChatEvent) => {
// The agent sidecar now hands over finished turn messages rather than raw parser events, so this is a
// re-label onto the pipeline's own event stream. The `seq` is ignored: pipeline steps have their own
// durable record (the job's events), not the chat cursor.
const onMessage = (msg: TurnMessage) => {
if (abortSignal.aborted) return;
lastActivity = Date.now();
switch (event.type) {
case 'delta':
emit({ type: 'assistant:delta', text: event.text, stepIndex, iterationLabel });
switch (msg.type) {
case 'assistant:delta':
emit({ type: 'assistant:delta', text: msg.text, stepIndex, iterationLabel });
break;
case 'text':
emit({ type: 'assistant:text', text: event.text, stepIndex, iterationLabel });
case 'assistant:text':
emit({ type: 'assistant:text', text: msg.text, stepIndex, iterationLabel });
break;
case 'tool:start':
emit({
type: 'tool:start',
toolCallId: event.toolCallId,
toolName: event.toolName,
toolInput: event.toolInput,
toolCallId: msg.toolCallId,
toolName: msg.toolName,
toolInput: msg.toolInput,
stepIndex,
iterationLabel,
});
@@ -172,25 +175,29 @@ async function runAgenticStep({
case 'tool:result':
emit({
type: 'tool:result',
toolCallId: event.toolCallId,
output: event.output,
isError: event.isError,
toolCallId: msg.toolCallId,
output: msg.output,
isError: msg.isError,
stepIndex,
iterationLabel,
});
break;
case 'result':
case 'result': {
const cost = msg.cost;
settle(() => {
cleanup?.();
resolve(event.cost);
resolve(cost);
});
break;
case 'error':
}
case 'error': {
const message = msg.message;
settle(() => {
cleanup?.();
reject(new Error(event.message));
reject(new Error(message));
});
break;
}
case 'stopped':
settle(() => {
cleanup?.();
@@ -236,7 +243,8 @@ async function runAgenticStep({
sessionKey: sessionId,
cwd,
model,
onEvent,
durable: false,
onMessage,
});
cleanup = handle.kill;
} catch (err) {