import type { MessageCost, PromptImage, TurnMessage } from '../api/chat/types'; // ── Envelope ── export type SidecarMessage = SidecarCommand | SidecarEvent; /** * A session the agent is holding in memory right now — the ground truth about what is alive. * * `pendingTasks` is background work started but not yet notified, and it is the field worth having: * together with `isGenerating` it is exactly what the agent's own idle GC consults before deciding a * session may be collected, so a caller can tell "busy" from "merely open" the same way it does. */ export type LiveClaudeSession = { sessionKey: string; /** * Claude's own session id — the transcript's filename — or null before the first turn reports one. * * NOT the same as `sessionKey`, which is officer's handle for the conversation. Only the agent holds * the mapping (`setClaudeSession`/`getClaudeSession`), so anything that wants to find the transcript * has to be told it here. Assuming the two were equal made every live row unnameable and unlinkable. */ claudeSessionId: string | null; isGenerating: boolean; pendingTasks: number; }; /** * An OpenCode turn in flight. Only ever the generating ones, which is why it carries neither * `isGenerating` (always true) nor `pendingTasks` (no such concept on this harness). * * It deliberately carries no id for OpenCode's own `ses_…` session, and does not need to: the sidecar * only ever knows its own `sessionKey`, while the `ses_…` is reported separately over `opencode:session` * and recorded by officer in `api/chat/opencode/state.ts`. `/chat/live` joins the two on officer's side, * which is where both halves already are — widening this type would mean sending the sidecar a fact it * had told officer in the first place. */ export type LiveOpenCodeSession = { sessionKey: string; }; // ── Commands (API server → sidecar) ── import type { ProvisionableUser } from 'officerdb'; export type SidecarCommand = | { type: 'ping'; id: string } // Sent unprompted to a plugin whose manifest declares `userProvisioning`, right after its registration // ack and again whenever an account is created or removed. A push rather than a request, because the // sidecar has no authenticated way to ask — and because the platform is the only side that knows when // the answer changes. | { type: 'users:list'; id: string; users: ProvisionableUser[] } | { 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 } // Every one of these carries `userId` for the same reason `ClaudeSpawnStreamingParams.member` does: the // sidecar's session map is global and `sessionKey` arrives in a client message, so a command without an // identity is a command that acts on whoever's session happens to match. Resolved by the platform from the // authenticated request, never taken from the client. | { type: 'claude:kill'; id: string; sessionKey: string; userId: number } | { type: 'claude:interrupt'; id: string; sessionKey: string; userId: number } | { type: 'claude:clear-session'; id: string; sessionKey: string; userId: number } // 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; userId: number } // Turn plan mode on or off on a session that is already live, without waiting for the next turn. The // toggle is meant to be usable while reading the agent's last answer, which is exactly when no turn is // in flight to carry it. | { type: 'claude:set-plan-mode'; id: string; sessionKey: string; userId: number; enabled: boolean } /** * The user's answer to a plan the agent asked to exit with. * * This is the only command that resolves a promise the AGENT is blocked on: `canUseTool` is holding * `ExitPlanMode` open inside the sidecar while this round-trips to a browser and back. `requestId` * identifies which one — a session can only have a single plan outstanding today, but keying on the id * means a stale answer from a reconnected client cannot resolve a newer plan. */ | { type: 'claude:plan-decision'; id: string; sessionKey: string; userId: number; requestId: string; approve: boolean; feedback?: string; } /** * The user's answer to an `AskUserQuestion` the agent is blocked on. * * Sibling of `claude:plan-decision` and the second command that resolves a promise the AGENT is waiting * on. `toolCallId` is the harness's own `tool_use_id`, which the client already has from `tool:start` — * so a stale answer replayed by a reconnecting client lands on the question it was meant for, or on * nothing at all, and can never resolve a different one. */ | { type: 'claude:question-answer'; id: string; sessionKey: string; userId: number; toolCallId: string; answer: 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; userId: number } // Everything the agent is holding right now. `claude:is-generating` answers for a session you can // already name; this is for the case where officer has forgotten every name it had — its session // records are in memory and die with `pm2 restart officer`, while the agent keeps running. Without it // a live session is invisible until a browser happens to reconnect to it by id. | { type: 'claude:list'; id: string; userId: number } // OpenCode — drive a turn through the serve (POST /api/session/{id}/prompt), anchored to the chat cwd // by a per-request location header. Was an `opencode run` subprocess until 2026-08-10. | { type: 'opencode:run-streaming'; id: string; params: OpenCodeRunParams } | { type: 'opencode:kill'; id: string; sessionKey: string } // Which OpenCode turns are running right now. The counterpart of `claude:list`, and thinner for a // reason: OpenCode has no warm session between turns, so there is nothing to report but the running // ones — see `listRunningServeTurns`. | { type: 'opencode:list'; id: string }; // ── Responses/Events (sidecar → API server) ── /** * A sidecar telling the platform where its HTTP server is listening. * * `:server`, where the name is the sidecar's own — `music:server`, `pty:server`, and whatever a * plugin nobody has written yet decides to call itself. Deliberately open: see the note in SidecarEvent. */ export type SidecarPortAnnouncement = { type: `${string}:server`; port: number; /** * A per-boot secret for callers INSIDE the platform that reach this sidecar directly rather than * through its proxy — announced here, in memory on both sides, never written to disk. * * Optional because only a sidecar with a producer path to guard has one. `notify` is the first: its * send route accepts a target user in the body, which loopback alone stopped being enough to protect * once commanders could run a shell on the same machine. */ secret?: string; }; 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 } // A lightweight, non-durable ping for notification delivery — deliberately NOT part of // chat_session_events/TurnMessage. Officer decides whether to actually notify (preference lookup + // fan-out to the browser); this only says "something notify-worthy just happened for this session." | { type: 'claude:notify'; sessionKey: string; userId: number; kind: 'turn-ended' | 'input-needed' } | { 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 } | { type: 'claude:sessions'; id: string; sessions: LiveClaudeSession[] } | { type: 'opencode:sessions'; id: string; sessions: LiveOpenCodeSession[] } // 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 } // Every HTTP sidecar's port announcement, as ONE member rather than sixteen. // // A sidecar binds an ephemeral loopback port and tells the platform over the registration socket; the // proxy for its prefix subscribes to `:server` and remembers the number. See create-proxy.ts. // // ── Why this is not a list of names ── // // It was: music, vault, slskd, headscale, transmission, invoiceshelf, jellyfin, photos, memos, gitea, // caldav, wallet, pty, email and notify, each with an identical one-line entry. Two of those were for // plugins that had already left the repository, which is the tell — the platform was declaring event // types on behalf of code it no longer contains. // // The real cost was that a plugin could not announce its own port. `connection.send` takes a // SidecarEvent, so `{ type: 'notes:server', port }` did not typecheck until somebody added a line // HERE. A third-party plugin needed a pull request against the platform before its sidecar would // compile — for the one message every HTTP sidecar has to send. // // Nothing was lost by generalising. The only consumer is `sidecar.on(\`${name}:server\`, …)` in // create-proxy.ts, which already casts to read `.port` because a handler typed // `(event: SidecarEvent) => void` cannot narrow from a computed template string. The sixteen entries // bought a typo check on the SENDING side and nothing at the point of use — and two call sites had // already cast past it (`as SidecarEvent` in notify, `as never` in create-proxy.test.ts). // // `[open]` That typo check is genuinely gone: a sidecar sending `muzik:server` while its proxy listens // for `music:server` now compiles, and the symptom is every request answering 503 forever because the // port is never learned. The fix is not to restore the list — it is for `createSidecarConnector` to // announce the port itself from the `name` it already holds, so the string is written once instead of // twice and the typo becomes unrepresentable. See create-proxy.ts. | SidecarPortAnnouncement // Generic | { type: 'error'; id?: string; error: string }; // ── Claude sidecar state ── export type ClaudeState = { proxySecret: string; claudeSessions: Record; // sessionKey → whose, and which transcript }; // ── 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) /** * Run this turn in PLAN mode — research and propose, execute nothing until the plan is approved. * * Carried on the turn for the same reason `model` is: both are `query()` options read once at session * creation, so both need a control request to change on a live session, and the turn is where the client's * current intent arrives. Absent means "no opinion" and leaves a live session's mode alone; an explicit * `false` turns planning off. */ planMode?: boolean; // 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; // Run this turn as a member's own Linux account, with their `claude` and their credential. // // Absent means the owner, which is every caller today: `api/chat/chat.ts` and the socket in `server.tsx` // both refuse non-owners, so nothing can currently populate this. That is deliberate — the field exists so // the sidecar side is testable and reviewable before the gates come off, not because anything sets it yet. // // Resolved by the platform from `resolveHomeDir`, NEVER taken from a client. Same rule as the pty sidecar, // where `server.tsx` deletes any client-supplied `osUser`/`home` from the query string before setting its // own — a member's identity is not a parameter they get to choose. // // REQUIRED, though it may be undefined. Optional (`member?:`) let a call site simply omit it, and one // did: the "session already live" branch in `handleChat` built its own literal without it, so every turn // after the sidecar's 30-minute idle timeout re-created the session as the OWNER. Spelling `undefined` // is a decision; leaving the key out was an accident the compiler had no opinion about. member: { osUser: string; home: string } | undefined; }; 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`) /** * Images for this turn, base64 as the browser sent them. * * `opencode run` takes attachments as PATHS (`-f`), not inline data, so the sidecar writes each one to * a temp file and deletes it when the turn ends. They travel as data because that is what crosses the * websocket from the browser, and because officer and the sidecar are not guaranteed to share a disk. */ images?: PromptImage[]; durable?: boolean; // commit turn output to chat_session_events (default true) — see ClaudeSpawnStreamingParams }; // The VNC command variants and their types left with the desktop plugin on 2026-08-21. A plugin's // sidecar speaks its own vocabulary through `sendSidecarCommand`, which knows only that a command has a // type and an id — see the note above it in sidecar-registry.ts. // ── 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.