import type { ServerWebSocket } from 'bun'; import { randomUUID } from 'crypto'; import type { ClientMessage, ServerMessage, Message, PromptImage, RunningTask, TurnMessage, UserSession, } from './types'; import { sessionManager } from './session-manager'; import { rememberOpenCodePrompt } from './opencode/state'; import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code'; import { sendOpenCodeStreaming } from '@@/channels/send-opencode'; import * as sidecar from '@@/sidecar-registry'; import { join } from 'path'; import { getOwnerHomeDir, getEmailAccountsDir, findEmailAccountName, getCommandersDir, } from '../../../servers/data-path'; import { attachedFilePaths } from './attached-files'; import { getUserSettings, getChatEventsSince, appendChatEvent, getUserById, setChatSessionModel, setChatSessionPlanMode, createCommandHqSession, } from 'officerdb'; import { resolveHomeDir } from '@@/user-home'; import { claudeLoginState } from '@@/os-user-claude'; import { mkdirSync } from 'node:fs'; import { logger } from './logger'; import { isSuperAdmin } from '@@/super-admin'; import { snapshotClaudeUsage } from './usage-store'; // Default model when no user preference is set export const DEFAULT_MODEL = 'claude-code'; // Harness selection: the `claude-code` provider runs through the Claude sidecar; every other provider // (opencode/anthropic/openai/… — all `providerID/modelID` ids) runs through the OpenCode server. const isClaudeModel = (model: string): boolean => model.startsWith('claude-code'); /** * Whose identity a turn runs as. * * A three-way answer rather than a nullable one, because `undefined` downstream means **the owner** — their * binary, their `~/.claude` credential, their HOME, and their MCP config carrying `OFFICER_AUTH_TOKEN`. A * nullable return collapsed three inputs into that: the caller genuinely being the owner, `resolveHomeDir` * failing, and a member whose `osUser` is null. The last two mean "I could not determine whose this is", and * answering them with the owner's identity is the one wrong answer this whole feature exists to prevent. * * Case three is not hypothetical: `provisionOsAccount` is non-fatal at every stage and records the account * either way, so a member whose Linux provisioning failed exists as a row with no `osUser`. On the night this * was written, provisioning failed three separate ways on a real member while the account continued to exist. * * The property: the owner's identity is reachable only by positively establishing that the caller IS the * owner, never by failing to establish anything else. `resolveHomeDir` already reports `isOwner` as a positive * fact — the old funnel through `undefined` was the only thing throwing it away. */ type TurnIdentity = | { kind: 'owner' } | { kind: 'member'; run: { osUser: string; home: string } } | { kind: 'refuse'; reason: string }; export async function resolveTurnIdentity(userId: number): Promise { const resolved = await resolveHomeDir(userId); if (!resolved.ok) return { kind: 'refuse', reason: resolved.reason }; if (resolved.isOwner) return { kind: 'owner' }; const row = await getUserById(userId); if (!row?.osUser) { return { kind: 'refuse', reason: 'your Linux account is not provisioned yet, so an agent cannot run as you' }; } return { kind: 'member', run: { osUser: row.osUser, home: resolved.home } }; } export async function getUserDefaultModel(userId: number): Promise { try { const settings = await getUserSettings(userId); const chat = settings?.chat as Record | undefined; return (chat?.defaultModel as string) || null; } catch (err) { logger.error('Failed to read user settings for default model', { userId, error: String(err) }); } return null; } type WSData = { userId: number; email: string; username: string; provider: string; }; const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour /** * Where a turn runs, relative to the caller's own home. * * `root` used to be `getOwnerHomeDir(email)`, which ignores its argument — so every * `~` expanded to the OWNER'S home regardless of who asked, and the comment here said "the server owner is * the only account" as though that were a property rather than an assumption. * * An absolute path is still passed through unchanged. That is not a hole: a member's turn runs as their Linux * account, so the kernel decides what it can open, and containment is `resolveUserPath`'s job in the file * browser rather than a string check here. But it is worth knowing it is the kernel doing the work. */ const resolveCwd = (home: string, cwd?: string) => { if (!cwd || cwd === '~') return home; if (cwd.startsWith('~/')) return join(home, cwd.slice(2)); if (cwd.startsWith('/')) return cwd; return join(home, cwd); }; /** * @param home an absolute filesystem path — NOT an email. * * It took an email until `95951fb`, resolved its own root, and both parameters are `string`, so the change of * meaning was invisible to the compiler and to every caller outside that diff. Three of them kept passing an * email and silently began building relative paths out of an address. If a fourth caller ever appears, this * line is the warning it gets. */ export const resolveBaseCwd = (home: string, cwd?: string) => resolveCwd(home, cwd); // The email chat runs from the selected account's storage dir: // DATA_PATH//email_accounts/ // `accountEmail` will come from the account selector (msg.contextId) later; for now default to the // owner's first enabled account. Falls back to the email_accounts root if there are no accounts. async function resolveEmailCwd(ownerEmail: string, accountEmail?: string): Promise { // Found on disk, never queried. Email is a plugin and the platform may not import its queries; the // directory layout is already the platform's, so reading it answers the question with no coupling and // nothing to throw when the plugin is not installed. const account = accountEmail?.trim() || findEmailAccountName(ownerEmail); const dir = account ? join(getEmailAccountsDir(ownerEmail), account) : getEmailAccountsDir(ownerEmail); mkdirSync(dir, { recursive: true }); return dir; } // The working directory a chat turn runs in, by context: email → the account dir; /chat → a chosen // pwd or the caller's own home; everything else (browser/project/dashboard) → the given cwd. async function resolveChatCwd( msg: { context?: string; contextId?: string; cwd?: string }, email: string, userId: number, home: string, ): Promise { // Every commander a user has shares ONE directory — never a client-chosen one, and never per-commander // (see `getCommandersDir`). `msg.cwd` is ignored on this branch entirely, the same posture the identity // half of a turn already takes: resolved by the platform, never trusted from the caller. // // `mkdirSync` — synchronous, so this returns only once the directory genuinely exists, the same pattern // `resolveEmailCwd` below already uses. No race: unlike the rejected per-commander design, this directory // is shared and only the very first commander ever needs the mkdir to do anything; `recursive: true` // makes every call after that a no-op stat. if (msg.context === 'command-hq') { const dir = getCommandersDir(email); mkdirSync(dir, { recursive: true }); return dir; } if (msg.context === 'email') return resolveEmailCwd(email, msg.contextId); if (msg.context === 'chat') { if (msg.cwd?.trim()) return resolveCwd(home, msg.cwd); // The caller's own home, for everyone. // // This used to be `DATA_PATH//general_chat_sessions`, a dedicated directory so /chat sessions // formed their own Claude project group and did not clutter the home. That is a sibling of the home, // and `confineUserTree` makes every sibling the platform's at 0700 because the others are `attachments` // and `email_accounts` — so it was unreachable for a member. The first live member turn ran there and // every Bash call failed on its own working directory before doing anything. // // A per-member copy inside each home would have worked and would have left two rules to remember. The // owner chose one: a chat with no chosen directory runs in the account's own home, whoever they are. // The cost is that /chat sessions now share a project group with anything else run from that home, // which was the reason the dedicated directory existed and is a trade the owner made knowingly. return home; } return resolveCwd(home, msg.cwd); } const wsToSessionMap = new WeakMap(); // Per-connection heartbeat. Bun closes a WS idle for `idleTimeout` (60s), and its timer only resets // on frames *received* from the client — but during a chat turn the client only receives. So we ping // each connection every 25s; the client auto-pongs at the protocol level, which resets Bun's timer // (and keeps reverse proxies happy). Genuinely dead sockets still time out (no pong). const pingTimers = new WeakMap, ReturnType>(); const PING_INTERVAL_MS = 25_000; function sendToClient(ws: ServerWebSocket | null, msg: ServerMessage, seq?: number): void { if (ws?.readyState === 1) { ws.send(JSON.stringify(seq === undefined ? msg : { ...msg, seq })); } } // Officer writes to chat_session_events only for events it ORIGINATES — `cut-off` and the restart // button's task pair (`emitToSession`). Both harnesses commit their own turn // output in the sidecar that produced it (`sidecar/claude/session-log.ts`), which is the whole point: // the durable record does not travel over the socket between the two processes, so officer can restart // mid-turn without losing it. Officer reads the table on `resume` (getChatEventsSince) and relays what // the sidecars send. The old `emitToSession` used to live here. export async function open(ws: ServerWebSocket): Promise { const timer = setInterval(() => { try { ws.ping(); // client auto-pongs → resets Bun's idleTimeout } catch { /* socket already gone */ } }, PING_INTERVAL_MS); pingTimers.set(ws, timer); } export function message(ws: ServerWebSocket, raw: string | Buffer): void { const data = typeof raw === 'string' ? raw : raw.toString(); (async () => { try { const clientMsg = JSON.parse(data) as ClientMessage; if (clientMsg.type === 'chat') { await handleChat(ws, clientMsg); } else if (clientMsg.type === 'resume') { await handleResume(ws, clientMsg); } else if (clientMsg.type === 'stop') { await handleStop(ws); } else if (clientMsg.type === 'disconnect') { await handleDisconnect(ws); } else if (clientMsg.type === 'resume-cursor') { await handleResumeCursor(ws, clientMsg); } else if (clientMsg.type === 'attach') { await handleAttach(ws, clientMsg); } else if (clientMsg.type === 'plan:mode') { await handlePlanMode(ws, clientMsg); } else if (clientMsg.type === 'plan:decide') { handlePlanDecision(ws, clientMsg); } } catch (err) { logger.error('Error handling WebSocket message', { email: ws.data.email, error: String(err) }); sendToClient(ws, { type: 'error', message: 'Failed to process message' }); } })(); } export function close(ws: ServerWebSocket): void { const timer = pingTimers.get(ws); if (timer) { clearInterval(timer); pingTimers.delete(ws); } const sessionId = wsToSessionMap.get(ws); if (sessionId) { sessionManager.detachWs(sessionId, ws); // Only once nothing is watching. Another pane or another device still attached means the // conversation is live, and arming the idle GC here would collect it out from under them. if (!sessionManager.hasSockets(sessionId)) sessionManager.setIdleTimeout(sessionId, IDLE_TIMEOUT_MS); } } // ── 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': // A subagent's deltas are not the main agent typing; appending them here spliced its sentences into // whatever the agent you are talking to was mid-way through saying. if (!msg.parentToolUseId) session.streamBuffer += msg.text; break; case 'assistant:text': session.messages.push({ id: randomUUID(), timestamp: Date.now(), role: 'assistant', text: msg.text, model, parentToolUseId: msg.parentToolUseId, }); session.meta.messageCount += 1; // Only the main agent's own stream feeds the buffer a resume replays as `streamingText`. if (!msg.parentToolUseId) 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, parentToolUseId: msg.parentToolUseId, }); 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). // The turn's cost is the main agent's, so skip past any subagent tail. const last = [...session.messages].reverse().find((m) => !m.parentToolUseId); if (last?.role === 'assistant' && !last.cost) last.cost = msg.cost; break; } case 'error': case 'stopped': session.isGenerating = false; break; } } // Shared by both harnesses now: whichever sidecar ran the turn has already translated and committed it. function createMessageHandler( sessionId: string, model: string, resumeSessionId?: string, planMode?: boolean, commandHq?: boolean, ) { /* The model messages are ATTRIBUTED to, which is not always the model the turn asked for. `foldIntoSession` stamps every assistant message with this, so it has to track what the harness actually did rather than what the client requested. A tier switch is a control request on a live session and can be refused; when it is, the turn still runs and still answers — as the OLD model — and stamping it with the new one would put a lie in the transcript that nothing downstream could detect. Mutable for that reason, and updated only from the harness's own `model:switched`. */ let effective = model; /* Claude's transcript uuid, which is NOT `sessionId`. `sessionId` is officer's handle; the session LIST is keyed by the transcript's own uuid, and the two are different strings (see the `session:claude` docs in types.ts). So the durable model record has to be keyed by the transcript id or it will never join to a list row — and the transcript id is not known when the turn starts, only when the harness announces it. That is what this waits for. */ /* Seeded from the resume id, and that seed is what makes a switch durable. This handler is built PER TURN, so without the seed `transcriptId` starts null on every one — and `applyModelChange` runs before the turn emits anything, so the `model:switched` it produces arrived while there was still nothing to key the row on. A resumed session then recorded its model only on the closing `result`, which means a turn that died took the switch with it: the sidecar log showed `fable -> opus[1m]` while the stored row still said `opus`. Observed on this machine. A brand-new conversation still has to wait for `session:claude` — it has no id yet by definition — but a resumed one already knows, and resumed is the case where a switch actually happens. */ let transcriptId: string | null = resumeSessionId ?? null; /* Plan mode, tracked exactly like `effective` above and for the same reason. What the turn ASKED for is `planMode`; what the harness is actually on is whatever its `plan:mode` messages last said, including the one `planGate` emits when a plan is approved and the session drops back out of planning on its own. Storing the request would leave a session marked as planning long after it stopped — and since this is the value the composer restores from, that is a toggle that comes back on by itself. Undefined until something says otherwise, so a client that never mentions plan mode writes nothing rather than writing `false` over a session that is deliberately planning. */ let effectivePlanMode: boolean | undefined = planMode; /** * Write the model down, once both halves are known. * * Fire-and-forget with a swallowed failure: a chat turn must not die because a bookkeeping row would not * write. The cost of losing it is that the picker falls back to a family guess on the next resume, which * is the behaviour every session already had before this table existed. */ const record = (): void => { if (!transcriptId) return; /* Only ever record a model that NAMES A TIER. `DEFAULT_MODEL` is the bare harness, `claude-code`, used when the client sends no model and the user has no default. It routes correctly — `isClaudeModel` is a prefix test, and `split('/')[1]` yields undefined so the CLI picks its own model — but it is not an id the catalogue offers, so storing it puts a value in the picker that matches no option and renders as BLANK. That failure feeds itself: a blank picker sends no model, so the next turn falls back to the bare harness again and overwrites whatever real value was there. It destroyed a correctly backfilled `claude-code/opus` within one turn. A bare harness means "no explicit choice", and the honest response to that is to leave the previous answer alone rather than to overwrite it with a non-answer. */ if (!effective.includes('/')) return; void setChatSessionModel(transcriptId, effective, effectivePlanMode).catch((err: unknown) => { logger.warn('Could not record the session model', { sessionId, transcriptId, model: effective, error: err instanceof Error ? err.message : String(err), }); }); }; /* File this session as a commander, once the transcript id is known — the same wait `record` above is built around, and for the same reason: there is nothing to key the row on before the harness announces it. Gated on `commandHq`, which the caller sets only when `msg.context === 'command-hq'`, so an ordinary `/chat` turn never touches this table. Idempotent by construction (`createCommandHqSession` upserts on `(user_id, session_id)`), so calling it again on every later turn of the same commander — which happens, because the client keeps sending `context: 'command-hq'` for `resolveChatCwd` to keep resolving the right directory — costs one no-op upsert rather than a duplicate row. That repetition is also a cheap self-heal: if the row were ever lost, the next turn puts it back. Fire-and-forget, swallowed failure, same posture as `record`: a database hiccup here must not be a reason a chat turn fails. */ const fileAsCommander = (): void => { if (!commandHq || !transcriptId) return; const userId = sessionManager.getSession(sessionId)?.userId; if (!userId) return; void createCommandHqSession(userId, transcriptId).catch((err: unknown) => { logger.warn('Could not file this session as a commander', { sessionId, transcriptId, error: err instanceof Error ? err.message : String(err), }); }); }; return (msg: TurnMessage, seq?: number): void => { const session = sessionManager.getSession(sessionId); if (!session) return; if (msg.type === 'model:switched') { // `to` on success, `from` on refusal — either way, what is answering from here on. effective = msg.ok ? msg.to : (msg.from ?? effective); // The server's record follows the harness, never the request: `handleStop`, `endTurnIfAgentIsGone` // and the orphan adopter all route on `session.model`, and a stale one sends a stop to the wrong // sidecar's kill path. sessionManager.setModel(sessionId, effective); record(); } /* What the harness reports it is running — forwarded ONLY when the turn did not name a tier. `effective` without a `/` is the bare harness, `claude-code`: the client chose nothing, officer passed no `--model`, and the CLI picked. That is the only case where the harness knows something officer does not, and it is the only case where this may speak. When the turn DID name a tier, this is dropped on the floor, because the translation back from an API id is lossy in exactly the directions that matter: `claude-code/opus[1m]` and `claude-code/opusplan` both answer as `claude-opus-5`, so forwarding would quietly relabel the user's own choice as plain opus. `model:switched` already covers a real change. Not recorded, not folded, not `sessionManager.setModel`. `session.model` is what dispatch and the stop path route on, and it is correct as the bare harness; and `record()` refuses a model without a `/` for a reason worth reading before changing any of this. */ if (msg.type === 'model:resolved') { if (effective.includes('/')) return; for (const socket of session.sockets) sendToClient(socket as ServerWebSocket, msg, seq); return; } // Follow the harness, never the request — a refused switch (`ok: false`) leaves the session where it was, // and recording the attempt would restore a mode the agent is not in. if (msg.type === 'plan:mode' && msg.ok) { effectivePlanMode = msg.enabled; record(); } // The transcript id arrives at the START of a turn, and again on the closing `result` for a session // whose id was assigned mid-turn. Recording on both means a brand-new conversation gets its model // written on its very first turn rather than on its second. if (msg.type === 'session:claude' && msg.claudeSessionId) { transcriptId = msg.claudeSessionId; record(); fileAsCommander(); } if (msg.type === 'result' && msg.claudeSessionId && msg.claudeSessionId !== transcriptId) { transcriptId = msg.claudeSessionId; record(); fileAsCommander(); } /* A turn ended — take a usage snapshot while the person is reading the answer. Here rather than on a timer because this is the moment the numbers have just moved AND nobody is waiting: the turn is over, the composer is idle, and whatever this costs lands in the gap before the next prompt. `void`, so nothing about the turn depends on it. Best-effort in every direction. It is capped by the reader's own 60s cache, so a burst of short turns does not become a burst of transcript scans; it stores nothing when the CLI is signed out; and it swallows its own failures. A missed snapshot costs one stale row until the next turn. Owner-gated because the reading is structurally the owner's — `getOwnerHomeDir` ignores the email it is passed, so a member's turn would snapshot the OWNER'S usage under the MEMBER'S user id. That is not a permissions slip, it is a wrong row, and it would be invisible. */ if (msg.type === 'result' && session.userId) { const userId = session.userId; void isSuperAdmin({ id: userId }).then((owner) => (owner ? snapshotClaudeUsage(userId) : null)); } foldIntoSession(session, msg, effective); for (const socket of session.sockets) sendToClient(socket as ServerWebSocket, msg, seq); }; } /** * Push an event into a session from OUTSIDE a turn, durably. * * The restart button is the only caller: the platform respawns a process the harness is no longer watching, * so nothing else would emit the `task:started` / `task:notification` pair that the tray, the log view and * every reconnect are built on. * * ── Why this writes to the log, against the note above ── * * It used to only fold and send, and that made the tray a fact about one browser tab. A refresh rebuilds * the tray by folding the durable log — `task:started` minus `task:notification`, see * `collectRunningTasks` — so a restart announced only over sockets left the log reading "started, then * failed" while the process it described was still up and serving. The pill vanished on refresh and the * job became uncontrollable from the UI: every task route takes an id, and the id existed nowhere else. * * `cut-off` is the same exception for the same reason, and the rule behind both is narrower than "officer * does not write here": officer writes events it ORIGINATES, because no sidecar will. Turn output still * belongs to the harness that produced it. * * ── The append is unconditional ── * * It happens whether or not a session is live in memory, and that is the half that was silently missing: * a platform restart drops the in-memory session while the job keeps running, and the old early return * meant the announcement went nowhere and was never written down either. Persistence does not need a * listener. Delivery does, and only delivery is conditional. * * Returns whether it reached a live socket — false is ordinary, not an error. */ export async function emitToSession(sessionId: string, msg: TurnMessage): Promise { let seq: number | undefined; try { seq = await appendChatEvent(sessionId, msg); } catch (err) { // Still tell whoever is listening. A pill that cannot survive a refresh beats no pill at all, and the // process is running either way. logger.error('Failed to persist an out-of-turn event', { sessionId, type: msg.type, error: String(err) }); } const session = sessionManager.getSession(sessionId); if (!session) return false; foldIntoSession(session, msg, session.meta.model ?? ''); // The seq is what a client records as its replay cursor, so it must be the one the log assigned. for (const socket of session.sockets) sendToClient(socket as ServerWebSocket, msg, seq); return true; } async function handleChat( ws: ServerWebSocket, msg: { prompt: string; displayText?: string; sessionId?: string; model?: string; cwd?: string; cwdRoot?: string; groupSlug?: string; attachmentIds?: string[]; images?: PromptImage[]; thinking?: string; context?: string; contextId?: string; resumeSummary?: string; resumeSessionId?: string; armWatcher?: boolean; }, ): Promise { const { userId } = ws.data; const sessionId = msg.sessionId || randomUUID(); // Prepend resume summary to the prompt if present const withSummary = msg.resumeSummary ? `Here is a summary of a previous conversation to continue from:\n\n${msg.resumeSummary}\n\n---\n\nUser's new message: ${msg.prompt}` : msg.prompt; /** * Attached files, named by PATH rather than carried in the message. * * `attachmentIds` has been on this wire and in `types.ts` since before today and NOTHING read it — * images work because they travel separately as base64, so the ids were dead. A PDF cannot travel that * way: it would be megabytes of base64 in every turn of the conversation, re-sent forever. * * So the file stays on disk and the agent is told where it is. Reading a document is something its own * tools already do better than a prompt can — page ranges, extraction, re-reading a section later * without paying for it again — and this is a machine where the agent runs as the person who uploaded * the file. */ const attached = await attachedFilePaths(userId, sessionId, msg.attachmentIds); const prompt = attached.length ? `${withSummary}\n\nAttached ${attached.length === 1 ? 'file' : 'files'} (read from disk):\n${attached .map((path) => `- ${path}`) .join('\n')}` : withSummary; // Use provided model, or fall back to user default, or the system default. const requested = msg.model || (await getUserDefaultModel(userId)) || DEFAULT_MODEL; /* A conversation may change TIER mid-flight; it may not change HARNESS. Tier is a control request on the live session (`setModel`), so opus → sonnet costs nothing and keeps the transcript. Harness is a different sidecar with a different session id space, and crossing it is what actually broke: an OpenCode `ses_…` id handed to `claude --resume`, which reads as a dead agent rather than as a wrong dispatch. Nothing refused it before — the UI simply locked the whole selector after the first message, which also blocked the harmless half. The server's own record wins here, the same precedence `decideResume` uses: a session in memory already knows its harness, and a socket does not get to re-declare it. */ const live = sessionManager.getSession(sessionId); let model = requested; if (live && isClaudeModel(live.model) !== isClaudeModel(requested)) { model = live.model; logger.warn('Refused a harness change mid-session', { sessionId, requested, keeping: live.model }); sendToClient(ws, { type: 'model:switched', from: live.model, to: requested, ok: false, error: 'A conversation cannot move between Claude Code and OpenCode. Start a new chat for that.', }); } logger.info('Model selected for chat', { sessionId, model, clientModel: msg.model || null }); // Route by harness: claude-code → Claude sidecar; anything else → OpenCode server. if (isClaudeModel(model)) return handleClaudeCodeChat(ws, sessionId, model, msg, prompt); // ── OpenCode is owner-only until it carries an identity ── // // `handleOpenCodeChat` resolves its cwd against `getOwnerHomeDir(email)` — a function that discards the // email it is given and always answers the owner — and the sidecar runs one shared `opencode serve` as the // service user. So a turn here executes AS THE OWNER, IN THE OWNER'S HOME, whoever asked. It carried a // comment describing itself as owner-only; this is the check that comment assumed existed. // // Reached by any account with the `chat` grant, which every role holds by default, and `isClaudeModel` is a // `startsWith` — so a typo'd model string lands here too, not just a deliberate choice. `model` is // client-supplied and never validated against the catalogue, so hiding these in `/chat/models` is not a // substitute for refusing them here. const identity = await resolveTurnIdentity(userId); if (identity.kind !== 'owner') { logger.warn('Refused an OpenCode turn for a non-owner', { userId, model, sessionId }); sendToClient(ws, { type: 'error', message: 'OpenCode is only available to the server owner. Pick a Claude model instead.', }); return; } return handleOpenCodeChat(ws, sessionId, model, msg, prompt); } export type ClaudeTurnOutcome = { ok: true; transcriptId: string } | { ok: false; error: string }; /** * The core of starting or continuing a Claude Code turn — identity, cwd, session bookkeeping, spawn. * * Extracted from `handleClaudeCodeChat` so `POST /command-hq/commanders/new` (`command-hq/router.ts`) can * drive the SAME path a websocket turn drives, rather than a second copy that drifts the way `turnParams`' * own docstring below describes already happening once. `ws` is nullable for exactly that caller: an HTTP * request has no live socket to attach, so every `ws`-specific step below is skipped when it is null rather * than the whole function being duplicated. * * `onOutcome`, when given, fires exactly once — with the transcript id on the first `session:claude` the * harness reports, or with the error text at whichever refusal/failure point this function would otherwise * only have told a live socket about. That is what lets an HTTP caller await a real answer instead of a * generic timeout: the reasons below (member not signed in, Claude not installed, spawn failure) are the * same reasons a websocket caller already sees via `sendToClient`, just mirrored to a second listener. */ export async function startClaudeTurn(params: { email: string; username: string; userId: number; ws: ServerWebSocket | null; sessionId: string; model: string; msg: { prompt: string; displayText?: string; groupSlug?: string; context?: string; contextId?: string; cwd?: string; cwdRoot?: string; images?: PromptImage[]; resumeSessionId?: string; planMode?: boolean; armWatcher?: boolean; }; effectivePrompt: string; onOutcome?: (outcome: ClaudeTurnOutcome) => void; }): Promise { const { email, username, userId, ws, sessionId, model, msg, effectivePrompt, onOutcome } = params; const fail = (error: string) => { sendToClient(ws, { type: 'error', message: error }); onOutcome?.({ ok: false, error }); }; // Identity first: it decides both whose home `~` expands against and whose account the turn runs as, and // those must be the same answer. Resolving the cwd first would expand `~` before knowing whose it was. const identity = await resolveTurnIdentity(userId); if (identity.kind === 'refuse') { fail(identity.reason); return; } // A member has to sign `claude` in themselves, once, with their own Anthropic account — the platform cannot // do it for them without lending them the owner's credential, which is the thing this whole feature exists // to avoid. Without this check their turn spawns, `claude` exits on an auth error, and it surfaces as "the // agent is broken" — the exact confusion `/agent-status` was built to prevent, arriving through a different // door. The refusal carries the instruction so the answer is the same whether the UI asked or not. if (identity.kind === 'member') { const state = await claudeLoginState({ email, osUser: identity.run.osUser }); if (!state.installed) { fail('Claude is not installed in your home yet — ask the server owner to reprovision your account.'); return; } if (!state.loggedIn) { fail('Open a terminal and run `claude` once to sign in with your own Anthropic account. It stays signed in.'); return; } } const home = identity.kind === 'member' ? identity.run.home : getOwnerHomeDir(email); const cwd = await resolveChatCwd(msg, email, userId, home); const groupSlug = msg.groupSlug || null; const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId); session.userId = userId; // No live socket to attach for an HTTP-originated turn — the caller attaches later, by transcript id, // through the ordinary `/chat` websocket `attach` path, exactly as a reconnect already does. if (ws) { sessionManager.attachWs(sessionId, ws); wsToSessionMap.set(ws as any, sessionId); } sendToClient(ws, { type: 'session:init', sessionId, model, cwd, context: session.meta.context, contextId: session.meta.contextId, }); // Add user message to session const userMsg: Message = { id: randomUUID(), timestamp: Date.now(), role: 'user', text: msg.prompt, }; session.messages.push(userMsg); session.meta.messageCount += 1; session.meta.updatedAt = Date.now(); if (!session.meta.title) { session.meta.title = (msg.displayText ?? msg.prompt).slice(0, 100); } session.isGenerating = true; // The resume id is passed so a tier switch on a RESUMED conversation is recorded when it happens rather // than when the turn ends — see the seed in `createMessageHandler`. `commandHq` is `msg.context === // 'command-hq'`, the same signal `resolveChatCwd` above already reads to pick this session's directory. const baseOnMessage = createMessageHandler( sessionId, model, msg.resumeSessionId, msg.planMode, msg.context === 'command-hq', ); // Wrapped rather than passed straight to the spawn call, so `onOutcome` sees the transcript id at the // exact moment `fileAsCommander` (inside `createMessageHandler`) does — both react to the same // `session:claude` message, neither races the other for it. const onMessage = (turnMsg: TurnMessage, seq?: number) => { baseOnMessage(turnMsg, seq); if (onOutcome && turnMsg.type === 'session:claude' && turnMsg.claudeSessionId) { onOutcome({ ok: true, transcriptId: turnMsg.claudeSessionId }); } }; /** * One description of the turn, used by both paths below. * * They were two object literals, and they had drifted: the first carried `member`, the second did not. * The second is taken whenever officer already holds a session — which it does long after the SIDECAR has * let go, because the sidecar tears a session down after 30 minutes idle while `_claudeKill` stays set * here. So an idle gap sent a turn with no identity to a sidecar that no longer had a session to push it * onto, and it created a new one. With no member, that means the OWNER. * * What that cost, on a real account: a `Developer` with their own Linux user, their own `claude` and their * own login had every turn after a break run as the owner — in the owner's HOME, with the owner's Anthropic * credential and the owner's MCP config, which carries a JWT that signs as the owner. The whole point of * per-member accounts, undone by an omitted line in the branch nobody looks at. * * It surfaced as breakage rather than as a silent escalation only by luck: the owner's `claude` was handed * the member's cwd — a 700 home it cannot enter — and their transcript id, so it exited 1 instead of * quietly working. Reported as "the chat stopped after he went for a walk", which is the idle timeout. * * Built once now, so the two paths cannot disagree again. */ const turnParams = { userId, email, username, prompt: effectivePrompt, images: msg.images, sessionKey: sessionId, cwd, model, // Never from the client: whose account a turn runs as is resolved here and nowhere else. member: identity.kind === 'member' ? identity.run : undefined, resumeSessionId: msg.resumeSessionId, // Undefined means "no opinion" and leaves a live session's mode alone — see ClaudeSpawnStreamingParams. // Only the composer's toggle sets it, so an older client sends nothing and nothing changes. planMode: msg.planMode, }; try { if (!session._claudeKill) { // First turn of this session: open the persistent session + a SESSION-scoped event subscription // (survives turn-end so background task:notifications keep flowing). `kill` tears both down for an // explicit disconnect; `detach` drops only the listener, which is what the idle GC uses so an // absent browser stops taking a live agent with it. const handle = await sendClaudeCodeStreaming({ ...turnParams, onMessage }); session.piProcess = sessionId as any; session._claudeKill = handle.kill; session._sidecarUnsub = handle.detach; } else { // Session already live HERE — which does not mean it is live in the sidecar. If it is not, the sidecar // creates one from these params, so they must be complete. await sidecar.spawnClaudeStreaming(turnParams); } } catch (err) { logger.error('Failed to start Claude Code streaming', { sessionId, error: String(err) }); session.isGenerating = false; fail('Failed to start Claude Code'); } } async function handleClaudeCodeChat( ws: ServerWebSocket, sessionId: string, model: string, msg: { prompt: string; displayText?: string; groupSlug?: string; context?: string; contextId?: string; cwd?: string; cwdRoot?: string; images?: PromptImage[]; resumeSessionId?: string; planMode?: boolean; armWatcher?: boolean; }, effectivePrompt: string, ): Promise { const { email, username, userId } = ws.data; return startClaudeTurn({ email, username, userId, ws, sessionId, model, msg, effectivePrompt }); } async function handleOpenCodeChat( ws: ServerWebSocket, sessionId: string, model: string, msg: { prompt: string; displayText?: string; groupSlug?: string; context?: string; contextId?: string; cwd?: string; cwdRoot?: string; resumeSessionId?: string; // The whole of B4 lived in this omission. The browser sent images, the bubble rendered them, and // they stopped at this signature — so they were never passed on and never reached the model, with // nothing anywhere reporting a loss. images?: PromptImage[]; }, effectivePrompt: string, ): Promise { const { email, username, userId } = ws.data; // The owner's home: opencode receives no identity at all (`TODO.md` → Multi-user), so this path is // owner-only and resolving anything else here would imply an isolation it does not have. const cwd = await resolveChatCwd(msg, email, userId, getOwnerHomeDir(email)); // Names this session in the Live panel until OpenCode gets round to titling it. First turn only. rememberOpenCodePrompt(sessionId, msg.displayText || msg.prompt); const groupSlug = msg.groupSlug || null; const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId); session.userId = userId; sessionManager.attachWs(sessionId, ws); wsToSessionMap.set(ws as any, sessionId); sendToClient(ws, { type: 'session:init', sessionId, model, cwd, context: session.meta.context, contextId: session.meta.contextId, }); const userMsg: Message = { id: randomUUID(), timestamp: Date.now(), role: 'user', text: msg.prompt, }; session.messages.push(userMsg); session.meta.messageCount += 1; session.meta.updatedAt = Date.now(); if (!session.meta.title) { session.meta.title = (msg.displayText ?? msg.prompt).slice(0, 100); } session.isGenerating = true; const onMessage = createMessageHandler(sessionId, model); try { // Drop the PREVIOUS turn's listener before opening the next one. // // This deliberately does not mirror the Claude guard above. Claude keeps one persistent session and // skips re-subscribing; OpenCode runs a fresh `opencode run` subprocess per turn, so a new // subscription each time is correct. What was wrong is that the old handle was overwritten without // being detached, leaving the previous session-scoped listener attached — so every turn after the // first delivered doubled, tripled, and so on, for any termination that is not result/error/stopped. session._sidecarUnsub?.(); session._sidecarUnsub = undefined; const handle = await sendOpenCodeStreaming({ userId, email, username, prompt: effectivePrompt, sessionKey: sessionId, cwd, model, resumeSessionId: msg.resumeSessionId, images: msg.images, onMessage, }); // Store the abort handle so handleStop can end the turn (OpenCode is aborted via this handle). session.piProcess = sessionId as any; session._claudeKill = handle.kill; session._sidecarUnsub = handle.detach; } catch (err) { logger.error('Failed to start OpenCode streaming', { sessionId, error: String(err) }); sendToClient(ws, { type: 'error', message: 'Failed to start OpenCode' }); session.isGenerating = false; } } async function handleResume( ws: ServerWebSocket, msg: { sessionId: string; cwd?: string; cwdRoot?: string }, ): Promise { const { sessionId } = msg; try { const session = sessionManager.getSession(sessionId); if (!session) { // Sessions live in memory for the connection's lifetime; there's no disk store to reload from. sendToClient(ws, { type: 'error', message: 'Session not found', errorCode: 'SESSION_NOT_FOUND' }); return; } sessionManager.attachWs(sessionId, ws); wsToSessionMap.set(ws as any, sessionId); sendToClient(ws, { type: 'session:init', sessionId, model: session.model, cwd: session.cwd, context: session.meta.context, contextId: session.meta.contextId, }); // Claude resumes lazily: the next chat prompt re-attaches via `--resume `, so there's // no long-lived process to spawn here — just replay the stored transcript to the client. sendToClient(ws, { type: 'sync:messages', sessionId, messages: session.messages, isGenerating: session.isGenerating, streamingText: session.streamBuffer, }); logger.info('Session resumed successfully', { sessionId, messageCount: session.messages.length }); } catch (err) { logger.error('Unexpected error in handleResume', { sessionId, error: String(err) }); sendToClient(ws, { type: 'error', message: 'Failed to resume session' }); } } /** * Flip plan mode between turns. * * Two independent effects, and neither implies the other: * * - the LIVE session changes mode, if there is one. There often is not: the sidecar collects a session * after thirty minutes idle, and reading an answer for half an hour before deciding to plan is an * entirely ordinary thing to do. Nothing is wrong in that case — the next turn carries `planMode` and * `createSession` opens in the right mode. * - the PREFERENCE is stored, so a refresh restores the toggle. This is the half that makes the feature * survive the page, and it is why the sticky column exists. * * Claude-only. OpenCode has no plan mode and no permission-mode control request, so a toggle there would be * a switch that does nothing; the client hides it, and this refuses it rather than trusting that. */ async function handlePlanMode( ws: ServerWebSocket, msg: { enabled: boolean; claudeSessionId?: string }, ): Promise { const sessionId = wsToSessionMap.get(ws); const session = sessionId ? sessionManager.getSession(sessionId) : null; if (session && !isClaudeModel(session.model)) { sendToClient(ws, { type: 'plan:mode', enabled: msg.enabled, ok: false, error: 'Plan mode is a Claude Code feature. OpenCode has no equivalent.', }); return; } if (sessionId) sidecar.setClaudePlanMode(sessionId, ws.data.userId, msg.enabled); if (msg.claudeSessionId) { try { await setChatSessionPlanMode(msg.claudeSessionId, msg.enabled); } catch (err) { // Bookkeeping, like the model record: worth a log, never worth failing the toggle the user just made. logger.warn('Could not store the session plan mode', { claudeSessionId: msg.claudeSessionId, error: err instanceof Error ? err.message : String(err), }); } } /* Echoed immediately rather than waiting for the sidecar. A live session will emit its own `plan:mode` through the durable log a moment later and the two agree — but a session that is NOT live emits nothing at all, and without this the toggle would sit there having visibly done nothing on exactly the conversations where using it is most natural. */ sendToClient(ws, { type: 'plan:mode', enabled: msg.enabled, ok: true }); } /** * Relay the user's plan decision to the agent that is blocked on it. * * Deliberately silent about the outcome: the sidecar answers through the session log with `plan:decided`, * which reaches every socket watching this conversation rather than only the one that clicked. Two panes on * the same chat is an ordinary setup here, and a plan resolved in one of them must not stay pending in the * other. */ function handlePlanDecision( ws: ServerWebSocket, msg: { requestId: string; approve: boolean; feedback?: string }, ): void { const sessionId = wsToSessionMap.get(ws); if (!sessionId) return; sidecar.decideClaudePlan({ sessionKey: sessionId, userId: ws.data.userId, requestId: msg.requestId, approve: msg.approve, feedback: msg.feedback, }); } async function handleStop(ws: ServerWebSocket): Promise { const sessionId = wsToSessionMap.get(ws); if (sessionId) { const session = sessionManager.getSession(sessionId); if (session?.piProcess) { try { 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, ws.data.userId); logger.info('Interrupted Claude Code turn via sidecar (session stays warm)', { sessionId }); } else { session._claudeKill?.(); // OpenCode: abort the turn via the stored handle logger.info('Aborted OpenCode turn', { sessionId }); } session.isGenerating = false; } catch (err) { logger.error('Failed to stop process', { sessionId, error: String(err) }); } } } sendToClient(ws, { type: 'stopped' }); } // Tear down the whole session (not just the current turn): deleteSession fires _claudeKill (kills any // in-flight Claude/OpenCode turn) + _sidecarUnsub, clears the idle timer, and drops the session from the // manager's maps. The WS stays open so the client can immediately start a fresh session. async function handleDisconnect(ws: ServerWebSocket): Promise { const sessionId = wsToSessionMap.get(ws); if (sessionId) { try { sessionManager.deleteSession(sessionId); wsToSessionMap.delete(ws as any); logger.info('Disconnected chat session', { sessionId }); } catch (err) { logger.error('Failed to disconnect session', { sessionId, error: String(err) }); } } sendToClient(ws, { type: 'disconnected' }); } /** * Re-adopt a session whose in-memory record died with the process that held it. * * The sidecars are PM2 peers, so `pm2 restart officer` does not touch a running turn: the agent keeps * generating and keeps committing to chat_session_events. What the restart destroys is purely this * process's binding to it — the session record and, critically, the session-scoped subscription that * relays the sidecar's events to the browser. Re-creating the record is not enough on its own; without * the subscription the client reconnects, receives its replay, and then goes silent for the rest of the * turn, which is indistinguishable from the agent having died. * * Nothing is spawned here. The subscription is a local event-bus filter, so adopting a session that is * NOT in fact still live upstream costs a listener that never fires and a record the idle GC collects. */ function adoptOrphanedSession(ws: ServerWebSocket, sessionId: string, model: string, cwd: string): UserSession { const { email, userId } = ws.data; const session = sessionManager.getOrCreate(sessionId, email, cwd, model, null); session.userId = userId; session.piProcess = sessionId as any; const onMessage = createMessageHandler(sessionId, model); const unsubClaude = sidecar.onClaudeMessage((key, msg, seq) => { if (key === sessionId) onMessage(msg, seq); }); const unsubOpenCode = sidecar.onOpenCodeMessage((key, msg, seq) => { if (key === sessionId) onMessage(msg, seq); }); const unsub = () => { unsubClaude(); unsubOpenCode(); }; // Must be set, and not only for teardown: handleChat treats an absent `_claudeKill` as "first turn of // this session" and opens a *second* subscription, which would then deliver every message twice. session._claudeKill = () => { if (isClaudeModel(model)) sidecar.killClaude(sessionId, userId); else sidecar.killOpenCode(sessionId); unsub(); }; // An adopted session can idle out and be released like any other, and releasing detaches through this // field alone. Leaving it unset would drop the record while the listener stayed subscribed — a leak // that grows by one every time a browser adopts a session and then goes away. session._sidecarUnsub = unsub; logger.info('Adopted orphaned chat session after restart', { sessionId, model }); return session; } // Reconnect: re-bind this socket to the (possibly still-live) session and replay every durable event // queued since the client's cursor — so a brief disconnect never loses turn output or a background // task:notification. attachWs cancels the pending idle-GC. async function handleResumeCursor( ws: ServerWebSocket, msg: { sessionId: string; cursor: number; model?: string; cwd?: string; generating?: boolean }, ): Promise { const { sessionId, cursor } = msg; const known = sessionManager.getSession(sessionId); const decision = decideResume(known?.model, msg.model); if (decision.kind !== 'known') { // Adopt on an assumption too: an unbound socket misses the turn entirely, which is worse than a // harness guess that only ever costs us the cut-off check below. adoptOrphanedSession(ws, sessionId, decision.model, msg.cwd ?? ''); if (decision.kind === 'assume') { logger.warn('resume-cursor for an unknown session with no model; adopting on the default', { sessionId }); } } sessionManager.attachWs(sessionId, ws); wsToSessionMap.set(ws as any, sessionId); try { const events = await getChatEventsSince(sessionId, cursor ?? 0); for (const { id, event } of events) { sendToClient(ws, event as ServerMessage, id); } } catch (err) { logger.error('Failed to replay chat events on resume-cursor', { sessionId, error: String(err) }); } // Only ask when the harness is actually known — never on an assumption. This check writes a DURABLE // row, so a wrong answer is permanent: that was B7, where a defaulted `claude-code` made officer ask // the Claude sidecar about an OpenCode session, hear "not generating", and write "the agent went // away" into a turn that was running perfectly well. if (msg.generating && decision.kind !== 'assume') { await endTurnIfAgentIsGone([ws], sessionId, decision.model, ws.data.userId); } } export type ResumeDecision = | { kind: 'known'; model: string } | { kind: 'adopt'; model: string } | { kind: 'assume'; model: string }; /** * Which harness a resuming session belongs to, and what that permits. * * This used to be `msg.model || DEFAULT_MODEL`, and `DEFAULT_MODEL` is `claude-code`. So a resume-cursor * that omitted `model` declared every session — OpenCode ones included — to be Claude, with three * consequences that all read as something else: * * - **Adopted into the wrong harness.** `adoptOrphanedSession` subscribes to the sidecar bus for that * model and pins `session.model` for the rest of its life, so an OpenCode turn's output never * arrived, and stopping it called `killClaude` on a key that sidecar had never held — a dead stop * button, silently. * - **A durable false `cut-off`.** `endTurnIfAgentIsGone` asked the Claude sidecar whether it was * generating, was told `false` because it had never heard of the session, and wrote "the agent went * away" into a turn that was running normally. It survives reload, which is the whole point of * writing it durably, and is therefore unrecoverable from the UI. * - Masked, never fixed, by the client always happening to send `model` next to `sessionId`. * * Two rules. **The server's own record beats the client's claim** — a session in memory already knows its * harness, and letting a socket re-declare it is how the wrong sidecar gets a session in the first place. * **A guess is still a guess**: when neither knows, the session is adopted on the default so delivery * keeps working, but it is marked `assume` and nothing durable may be written from it. * * The middle position — refusing to adopt at all when the harness is unknown — was tried and was WRONG, * visibly so within the hour. `useChat.ts` sends `model` only `if (modelRef.current)`, so a reconnect * without one is ordinary, not exotic; declining to adopt left the socket unbound to a live turn, and * the running turn's output went nowhere. On screen: the transcript collapsed to "turn completed * without output" and only a refresh — which rebuilds from the durable log — brought it back. * * So adoption is about DELIVERY and must be generous. Only the durable write needs certainty. */ export function decideResume(knownModel: string | undefined, claimedModel: string | undefined): ResumeDecision { if (knownModel) return { kind: 'known', model: knownModel }; if (claimedModel) return { kind: 'adopt', model: claimedModel }; return { kind: 'assume', model: DEFAULT_MODEL }; } /** * Which background tasks are still outstanding, by replaying the durable log against itself. * * A task's whole life is two events — `task:started` and, eventually, `task:notification` with a terminal * status — so started-minus-notified is the answer, and a Map keyed by taskId keeps the last word on each. * There is no third event: a task the agent abandoned without notifying stays here until the log is pruned, * which is the honest reading of the record rather than a bug to paper over. * * Exported for `api/command-hq/router.ts`, which uses it the same way and for the same reason: a commander's * liveness is derived from this fold rather than a stored column, so it cannot go stale independently of the * log it is read from. Do not reimplement this elsewhere — reuse it. */ export function collectRunningTasks(events: ServerMessage[]): RunningTask[] { const running = new Map(); for (const event of events) { if (event.type === 'task:started') { running.set(event.taskId, { taskId: event.taskId, description: event.description, taskType: event.taskType, }); } else if (event.type === 'task:notification') { running.delete(event.taskId); } } return [...running.values()]; } /** * Re-bind a socket that knows only Claude's transcript uuid. * * This is the refresh case, and until now it was the hole in an otherwise complete reconnect path. Every * piece of the machinery already existed — the session survives a dropped socket, the agent keeps * generating into it, `close` only detaches and arms an hour-long idle timer — but the browser came back * having forgotten officer's session id, so `resume-cursor` could never fire and the output simply stopped * arriving. The uuid in the URL is the one identifier a refresh cannot destroy; the agent's on-disk map * turns it back into the key everything else here is written in terms of. * * Deliberately hands over only the live turn, never the transcript — see `sync:live`. */ async function handleAttach(ws: ServerWebSocket, msg: { claudeSessionId: string }): Promise { const { claudeSessionId } = msg; if (!claudeSessionId) return; const sessionId = await sidecar.findClaudeSessionKey(claudeSessionId, ws.data.userId); if (!sessionId) { // No agent, or a transcript it has never run. Nothing is wrong: an ordinary finished conversation // opened from history lands here every time. Stay silent and leave the socket as it was — the next // `chat` mints a session in the usual way. logger.info('Attach found no live session for transcript', { claudeSessionId }); return; } // An officer restart takes the in-memory session with it while the agent carries on, so the key can // resolve to a session this process has never heard of. Adopting re-subscribes it to the sidecar's bus, // which is what makes the rest of the turn arrive. // `DEFAULT_MODEL` is right here and wrong in resume-cursor, which is worth being explicit about since // it was just removed there: this path reached `sessionId` by asking the CLAUDE sidecar to resolve a // `claudeSessionId`, so the harness is not a guess — only Claude could have answered. Attach is // Claude-only by construction. If an OpenCode reattach verb ever lands, this stops being safe. const existing = sessionManager.getSession(sessionId); const session = existing ?? adoptOrphanedSession(ws, sessionId, DEFAULT_MODEL, ''); sessionManager.attachWs(sessionId, ws); wsToSessionMap.set(ws as any, sessionId); // The client learns officer's key here, so any *later* drop of this socket goes down the existing // cursor-replay path instead of coming back through attach. sendToClient(ws, { type: 'session:init', sessionId, model: session.model, cwd: session.cwd, context: session.meta.context, contextId: session.meta.contextId, }); // `isGenerating` is officer's own belief and is only as good as this process's memory of the turn. For // an adopted session it is a fresh record's default, so ask the agent — the same question, and for the // same reason, as `endTurnIfAgentIsGone`. const isGenerating = existing ? session.isGenerating : await sidecar.isClaudeGenerating(sessionId, ws.data.userId); session.isGenerating = isGenerating; // One read serves both answers: the head of the log is the cursor, and folding the whole log gives the // tasks still outstanding. Reading it all is affordable because attach happens once per socket and this // server has one user; a `getLastChatEventSeq` would only have saved a second round trip. let cursor = 0; let runningTasks: RunningTask[] = []; try { const events = await getChatEventsSince(sessionId, 0); cursor = events.at(-1)?.id ?? 0; runningTasks = collectRunningTasks(events.map((e) => e.event as ServerMessage)); } catch (err) { logger.error('Failed to read the durable log on attach', { sessionId, error: String(err) }); } sendToClient(ws, { type: 'sync:live', sessionId, isGenerating, cursor, runningTasks, // Whatever the agent had typed but not yet finished as a message. The transcript on disk cannot // supply it — the harness writes an assistant message only once it is complete — so this is the one // piece of the turn a refresh would otherwise genuinely lose. streamingText: session.streamBuffer, }); logger.info('Attached socket to live session by transcript id', { sessionId, claudeSessionId, isGenerating, recoveredTasks: runningTasks.length, }); } /** * The client came back still believing a turn is running. Check whether it is, and if it isn't, say so. * * A restart of the agent sidecar takes its persistent sessions with it, and nothing downstream notices: * the turn simply stops emitting. The browser's socket is fine, the conversation looks alive, and the * spinner runs forever — a refresh doesn't help either, because there is no ending in the transcript to * read. This is the one moment we can catch it, so the answer is written durably: a reload after this * shows the same explanation rather than a conversation that trails off mid-tool-call. * * Only the claude harness is asked. OpenCode runs a turn per invocation and has no equivalent question, * so its sessions are left alone rather than guessed at. */ async function endTurnIfAgentIsGone( targets: Iterable | null>, sessionId: string, model: string, userId: number, ): Promise { if (!isClaudeModel(model)) return; if (await sidecar.isClaudeGenerating(sessionId, userId)) return; const session = sessionManager.getSession(sessionId); if (session) session.isGenerating = false; const event: ServerMessage = { type: 'cut-off' }; try { const seq = await appendChatEvent(sessionId, event); for (const target of targets) sendToClient(target, event, seq); } catch (err) { // Still tell every client — an un-replayable explanation beats a spinner that never stops. logger.error('Failed to persist cut-off notice', { sessionId, error: String(err) }); for (const target of targets) sendToClient(target, event); } logger.info('Ended a turn whose agent had gone', { sessionId }); } // The other half of the same problem: the agent restarts while the browser sits there with a healthy // socket, so nothing ever reconnects and nothing ever asks. A fresh agent process means every turn we // still believe is running belongs to a process that no longer exists. On a fresh officer this loop is // empty — it has no sessions yet — which is exactly right, because that case is the reconnect's to catch. sidecar.onClaudeSidecarStarted(() => { for (const session of sessionManager.getAllSessions()) { if (!session.isGenerating) continue; // No owner recorded, no question asked. `isClaudeGenerating` is now scoped to a caller, and there is no // safe id to substitute — asking as the owner would let a member's orphaned session be answered with the // owner's authority, and asking as nobody is not a thing. Leaving it marked generating is the same // outcome as before this loop existed, and it self-corrects on the next reconnect. if (session.userId === undefined) continue; void endTurnIfAgentIsGone( session.sockets as Set>, session.sessionId, session.model, session.userId, ); } }); export const chatWebsocket = { open, message, close, drain() {}, };