Files
platform/src/servers/sidecar/protocol.ts
T
pastilhasandClaude Opus 5 6b4339052a reattach a refreshed browser to a running turn
Refreshing mid-turn appeared to kill the agent's output. It never did: the
session survives a dropped socket, the agent keeps generating into it and keeps
committing durable events, and `close` only detaches the socket and arms an
hour-long idle timer. What broke was purely delivery — and the reconnect path
that would have fixed it could not fire, because the browser came back having
forgotten officer's session key. It lived in page state. The only id left was
Claude's transcript uuid in the URL, and nothing accepted that.

So accept it. `attach` carries the uuid, and the agent's on-disk session map —
the single record relating the two — turns it back into the key everything else
is written in terms of. The uuid now also goes out at `system.init` rather than
only at `result`, which is what makes the first turn recoverable at all: until
now a chat had no address until it had finished, and a long first turn is
exactly the one worth reconnecting to.

`sync:live` deliberately carries no messages. The harness writes its transcript
as it goes, so the HTTP load on landing already supplies the past; sending the
server's record of the same messages on top of it would duplicate them, and
there is no shared id to reconcile the two by. Attach hands over the rest of the
turn, the half-written paragraph the transcript cannot hold, and the session's
cursor head — that last one so a *later* drop replays from the head instead of
re-delivering the whole conversation from zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:59:26 +00:00

180 lines
8.7 KiB
TypeScript

import type { MessageCost, PromptImage, TurnMessage } from '../api/chat/types';
// ── Envelope ──
export type SidecarMessage = SidecarCommand | SidecarEvent;
// ── Commands (API server → sidecar) ──
export type SidecarCommand =
| { type: 'ping'; id: string }
| { type: 'state:sync'; id: string }
// Proxy
| { type: 'proxy:secret'; id: string }
// Claude Code
| { type: 'claude:spawn'; id: string; params: ClaudeSpawnParams }
| { type: 'claude:spawn-streaming'; id: string; params: ClaudeSpawnStreamingParams }
| { type: 'claude:kill'; id: string; sessionKey: string }
| { type: 'claude:interrupt'; id: string; sessionKey: string }
| { type: 'claude:clear-session'; id: string; sessionKey: string }
// Is a turn still running for this session? Only the process that owns the session can say, which is
// exactly why it is asked over the wire — see `isClaudeGenerating` in sidecar-registry.
| { type: 'claude:is-generating'; id: string; sessionKey: string }
// Which session key owns this transcript? The map lives on the agent's disk, so only it can answer —
// see `findClaudeSessionKey` in sidecar-registry, and `attach` in the chat socket for why it is asked.
| { type: 'claude:find-session'; id: string; claudeSessionId: string }
// OpenCode — drive a turn via `opencode run … --format json` (tools re-anchored to cwd via --dir)
| { type: 'opencode:run-streaming'; id: string; params: OpenCodeRunParams }
| { type: 'opencode:kill'; id: string; sessionKey: string }
// VNC
| { type: 'vnc:start'; id: string; params: VncStartParams }
// Provision the VNC password without starting a server — the UI needs it before it can connect
| { type: 'vnc:ensure-password'; id: string; email: string }
| { type: 'vnc:stop'; id: string; email: string };
// ── Responses/Events (sidecar → API server) ──
export type SidecarEvent =
| { type: 'pong'; id: string }
| { type: 'state:sync'; id: string; state: ClaudeState }
| { type: 'proxy:secret'; id: string; secret: string }
// Claude Code
| { type: 'claude:spawned'; id: string; sessionKey: string }
// A finished, browser-facing turn message. The agent has already committed it to chat_session_events
// and `seq` is its cursor id there; officer relays it verbatim. No `seq` means it is not durable —
// an `assistant:delta` (superseded by the text that follows) or a message whose write failed.
| { type: 'claude:message'; sessionKey: string; msg: TurnMessage; seq?: number }
| { type: 'claude:result'; id: string; result: ClaudeCodeResult }
| { type: 'claude:error'; id: string; error: string }
| { type: 'claude:killed'; id: string }
| { type: 'claude:interrupted'; id: string }
| { type: 'claude:session-cleared'; id: string }
| { type: 'claude:generating'; id: string; generating: boolean }
| { type: 'claude:session-key'; id: string; sessionKey: string | null }
// VNC
| { type: 'vnc:started'; id: string; port: number; display: number }
| { type: 'vnc:password'; id: string; password: string }
| { type: 'vnc:stopped'; id: string }
| { type: 'vnc:error'; id: string; error: string }
// Email — new mail no longer crosses this socket; the sidecar owns the SSE stream and pushes directly
// 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-message stream, session id report
| { type: 'opencode:spawned'; id: string; sessionKey: string }
// 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
| { type: 'music:server'; port: number }
// Vault — the sidecar reports where its Vaultwarden reverse-proxy HTTP/WS server is listening on connect
| { type: 'vault:server'; port: number }
// slskd — the sidecar reports where its slskd reverse-proxy HTTP server is listening (random port) on connect
| { type: 'slskd:server'; port: number }
// Headscale — the sidecar reports where its HTTP server is listening (random port) on connect
| { type: 'headscale:server'; port: number }
// Transmission — the sidecar reports where its HTTP server is listening (random port) on connect
| { type: 'transmission:server'; port: number }
// InvoiceShelf — the sidecar reports where its HTTP server is listening (random port) on connect
| { type: 'invoiceshelf:server'; port: number }
// Jellyfin — the sidecar reports where its HTTP server is listening (random port) on connect
| { type: 'jellyfin:server'; port: number }
// Photos (Immich) — the sidecar reports where its HTTP server is listening (random port) on connect
| { type: 'photos:server'; port: number }
// Memos — the sidecar reports where its HTTP server is listening (random port) on connect
| { type: 'memos:server'; port: number }
// Gitea — the sidecar reports where its HTTP server is listening (random port) on connect
| { type: 'gitea:server'; port: number }
// CalDAV/CardDAV — the sidecar reports where its HTTP server is listening (random port) on connect.
// One port serves both doors: /dav (forwarded to Radicale) and /_officer (JSON for Officer's UI).
| { type: 'caldav:server'; port: number }
// Wallet — the sidecar reports where its HTTP server is listening (random port) on connect
| { type: 'wallet:server'; port: number }
// PTY — the sidecar reports where its terminal HTTP/WS server is listening (random port) on connect
| { type: 'pty:server'; port: number }
// Email — the sidecar reports where its mail HTTP server is listening (random port) on connect
| { type: 'email:server'; port: number }
// Notify — the sidecar reports where its notification HTTP server is listening (random port) on connect
| { type: 'notify:server'; port: number }
// Generic
| { type: 'error'; id?: string; error: string };
// ── Claude sidecar state ──
export type ClaudeState = {
proxySecret: string;
claudeSessions: Record<string, string>; // sessionKey → Claude Code session_id
};
// ── Param types ──
export type ClaudeSpawnParams = {
userId: number;
email: string;
username: string;
prompt: string;
sessionKey: string;
model?: string;
cwd?: string;
};
export type ClaudeSpawnStreamingParams = {
userId: number;
email: string;
username: string;
prompt: string;
images?: PromptImage[]; // sent as native image content blocks alongside the prompt text
sessionKey: string;
cwd?: string;
model?: string;
resumeSessionId?: string; // resume this Claude session uuid (from the /chat session list)
// Whether turn output should be committed to chat_session_events (default true). A chat session wants
// it — that is what survives an officer restart. A pipeline step does not: its sessionKey is a throwaway
// uuid no browser will ever replay, and the job's own event log is its record.
durable?: boolean;
};
export type ClaudeCodeResult = {
text: string;
sessionId: string;
model: string;
cost: MessageCost;
};
// ── OpenCode turn params ──
export type OpenCodeRunParams = {
sessionKey: string;
prompt: string;
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 ──
export type VncStartParams = {
email: string;
username: string;
resolution?: string;
};
export type VncSessionInfo = {
email: string;
display: number;
port: number;
pid: number;
alive: boolean;
};
// ── PTY ──
//
// Nothing but a port crosses this socket now. The pty sidecar serves its own HTTP + WebSocket listener and
// the browser reaches it through a byte relay, so there is no command vocabulary left: pty:init, :input,
// :resize, :close and :list all lived here until the sidecar owned its own transport, and officer filtered
// one global output stream per session to feed them. The port arrives as the shared `pty:server` event
// that createSidecarProxy already listens for.