import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from 'node:fs'; import { spawn } from 'node:child_process'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { query, type Query, type SpawnOptions, type SpawnedProcess } from '@anthropic-ai/claude-agent-sdk'; import type { ChatEvent, PromptImage } from '../../api/chat/types'; import { affordanceBlock } from '../../affordances/load'; import { skillsBlock } from '../../skills/load'; import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult, LiveClaudeSession } from '../protocol'; import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state'; import { countsAsOutput, diagnoseExit, stderrReason } from './exit-diagnosis'; import { createParseState, processMessage } from './stream-parser'; import { spawnClaudeAsMember } from './spawn-as-member'; import { claudeBinIn } from '@@/os-user-claude'; const SEND_TIMEOUT_MS = 30 * 60 * 1000; // Where the Claude Code CLI lives. This was hardcoded to /usr/local/bin/claude, which dated from the // bwrap-sandboxed architecture: the jail ro-bound /usr and saw nothing else, so the installer's real // target (~/.local/bin/claude) had to be symlinked into a path the sandbox could reach. That sandbox // is gone, and the hardcoded path made the sidecar unrunnable anywhere it does not exist — a stock // macOS host has no /usr/local/bin at all. // // Resolution order mirrors OPENCODE_BIN in the opencode sidecar: an explicit pin, then PATH, then the // locations Anthropic's installer actually writes to. function resolveClaudeBin(): string { const pinned = process.env.CLAUDE_BIN; if (pinned) return pinned; const onPath = Bun.which('claude'); if (onPath) return onPath; const candidates = [join(homedir(), '.local', 'bin', 'claude'), '/usr/local/bin/claude', '/opt/homebrew/bin/claude']; return candidates.find((candidate) => existsSync(candidate)) ?? 'claude'; } const CLAUDE_BIN = resolveClaudeBin(); console.log(`[claude] CLI resolved to ${CLAUDE_BIN}`); /** * Variables the `claude` CLI injects to describe ITS OWN session, which must never reach a `claude` we * spawn ourselves. * * They arrive here by an ordinary accident: PM2 inherits the environment of whoever ran `pm2 start`, so * restarting this sidecar from inside a Claude Code terminal — which is how it is restarted most of the * time — bakes that terminal's session into the daemon. On 2026-08-14 this process was carrying * `CLAUDE_CODE_MESSAGING_SOCKET` for an unrelated PID that had been alive for an hour and a half. * * Only three of these were stripped before (`CLAUDECODE`, `CLAUDE_CODE_ENTRYPOINT`, `CLAUDE_CODE_SSE_PORT`); * the rest are newer and arrived with 2.x. Stripping them is hygiene rather than a fix — a spawn was * verified to succeed with the whole set present — but the failure it prevents is a child attaching to a * stranger's IPC socket, which would be extremely hard to recognise from the symptom. */ const NESTED_SESSION_ENV = [ 'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_CODE_SSE_PORT', 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_MESSAGING_SOCKET', 'CLAUDE_CODE_MESSAGING_TOKEN', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_EXECPATH', 'CLAUDE_PID', ] as const; /** `process.env` minus the parent session's fingerprint. */ function envWithoutParentSession(): Record { const out: Record = { ...process.env } as Record; for (const name of NESTED_SESSION_ENV) delete out[name]; // `~/.local/bin` on PATH, because that is where the platform installs a plugin's release binaries. // // A plugin may declare a dependency no package manager carries — `tea`, Gitea's CLI, is the first — // and `installRelease` puts it there: no root, and nothing downloaded from the internet sitting beside // what the distribution installed. But PM2 hands this process a login-less PATH // (`/usr/local/sbin:…:/snap/bin`), so the agent it spawns inherits one too, and the binary the platform // just installed is invisible to the agent it was installed FOR. // // Which is exactly what happened: gitea's affordance told the agent to use `tea`, the agent tried, and // got `command not found` for something sitting in the owner's home. // // Appended rather than prepended: a system binary of the same name should still win, and `identify` in // os-deps is what settles which one is really wanted. const localBin = join(homedir(), '.local', 'bin'); if (out.PATH && !out.PATH.split(':').includes(localBin)) out.PATH = `${out.PATH}:${localBin}`; return out; } // Capture original HOME before user-instance overrides it const HOST_HOME = process.env.HOME!; import { DATA_PATH } from '../../data-path'; // Tear a persistent session down after this long with no new turn (see PersistentSession below). const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // MCP config paths, set by user-instance at startup let mcpHostPath: string | undefined; // path on the host filesystem export function setMcpConfigPath(hostPath: string): void { mcpHostPath = hostPath; } // ── Blocking send (one-shot; used by the non-streaming 'claude:spawn' command) ── type ClaudeCodeOutput = { result: string; session_id: string; cost_usd: number; input_tokens: number; output_tokens: number; is_error: boolean; }; export async function spawnClaude(params: ClaudeSpawnParams): Promise { const { prompt, sessionKey } = params; const existingSession = getClaudeSession(sessionKey, params.userId); const claudeArgs = [CLAUDE_BIN, '-p', prompt, '--dangerously-skip-permissions', '--output-format', 'json']; const mcpConfig = mcpHostPath; if (mcpConfig) claudeArgs.push('--mcp-config', mcpConfig); const subModel = params.model?.split('/')[1]; if (subModel) claudeArgs.push('--model', subModel); if (existingSession) { claudeArgs.push('--resume', existingSession); } const spawnCmd = claudeArgs; const spawnCwd = params.cwd ?? HOST_HOME; const proc = Bun.spawn(spawnCmd, { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', cwd: spawnCwd, env: process.env as Record, }); const timeout = setTimeout(() => { try { proc.kill(); } catch { /* already dead */ } }, SEND_TIMEOUT_MS); try { const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); const exitCode = await proc.exited; clearTimeout(timeout); if (exitCode !== 0 && !stdout.trim()) { throw new Error(`claude exited with code ${exitCode}: ${stderr.trim().slice(0, 200)}`); } let output: ClaudeCodeOutput; try { output = JSON.parse(stdout) as ClaudeCodeOutput; } catch { return { text: stdout.trim() || '(no response)', sessionId: sessionKey, model: 'claude-code', cost: { inputTokens: 0, outputTokens: 0, totalUSD: 0 }, }; } if (output.is_error) { throw new Error(output.result || 'Claude Code returned an error'); } if (output.session_id) { setClaudeSession(sessionKey, output.session_id, params.userId); } return { text: output.result || '(no response)', sessionId: sessionKey, model: 'claude-code', cost: { inputTokens: output.input_tokens ?? 0, outputTokens: output.output_tokens ?? 0, totalUSD: output.cost_usd ?? 0, }, }; } catch (err) { clearTimeout(timeout); throw err; } } // ── Persistent streaming sessions (Agent SDK) ── // // Each sessionKey gets ONE long-lived `query()` driven by a streaming-input queue. The process stays // alive BETWEEN turns, so background work (Bash run_in_background, Monitor) and its `task_notification` // return to a live harness instead of being orphaned when the turn ends. A turn = one user message // pushed onto the input queue; the single consumer loop maps every SDK message (assistant text/tools, // tool results, turn `result`, and — crucially — post-turn `task_started`/`task_notification`) to a // ChatEvent and forwards it. Idle policy: torn down after IDLE_TIMEOUT_MS with no new turn, or on an // explicit kill (user "disconnect"). "Stop" is interrupt() — it ends the turn but keeps the session. /** * `content` is an Anthropic `MessageParam` content: a bare string, or the block array we build when the * turn carries images. Blocks are what make an attached screenshot actually reach the model — the string * form can only carry the client's `[Attached image: …]` placeholder, which is a label, not the picture. */ type ContentBlock = | { type: 'text'; text: string } | { type: 'image'; source: { type: 'base64'; media_type: string; data: string } }; type SdkUserMessage = { type: 'user'; message: { role: 'user'; content: string | ContentBlock[] }; parent_tool_use_id: null; session_id: string; }; type PersistentSession = { sessionKey: string; /** Whose session this is. The map is global and `sessionKey` arrives in a client message. */ userId: number; query: Query; pushTurn: (prompt: string, images?: PromptImage[]) => void; closeInput: () => void; abort: AbortController; emit: (event: ChatEvent) => void; /** * A lightweight, non-durable ping for notification delivery — deliberately separate from `emit`. * `ChatEvent`/`TurnMessage` is the durable, replayed transcript; this is not part of it, so it fires * even for events (`plan:proposed` parking, an `AskUserQuestion` gate) that have no event of their own * to piggyback on. Officer decides whether to actually notify (preference lookup + fan-out) — this * only says "something notify-worthy just happened for this session." */ notify: (kind: 'turn-ended' | 'input-needed') => void; isGenerating: boolean; pendingTasks: Set; // background tasks started but not yet notified; suppress idle-GC while non-empty /** * The user pressed stop and we are waiting for the turn to fall over. The SDK reports an interrupt as * an ordinary failed `result` — `is_error` with no text — which is indistinguishable downstream from * the harness actually breaking, and reached the user as "Claude Code returned an error". Only the * side that called `interrupt()` knows better, so it says so here. */ interrupted: boolean; /** What we passed as `--resume`, so a failure can name it and decide whether it was to blame. */ resumeId?: string; /** * Resolves when this session's child process is actually gone. * * Killing is not the same as being dead, and the difference is visible on disk: `abort()` returns at once * while the `claude` CLI keeps writing for as long as it takes to unwind. Officer does not own the * transcript — the CLI does — so a delete that races this window removes the file and the CLI writes it * straight back, resurrecting a conversation the user deleted. Anything that deletes a session's FILES * has to await this first. */ exited: Promise; /** Called by the exit hook. Resolving twice is a no-op, which is what makes the double-fire safe. */ markExited: () => void; /** * The model this session is currently answering as, `harness/tier`. * * Recorded because `model` reaches the SDK exactly once, in `createSession`'s `query()` options, while * every later turn is pushed onto that same call — so without this there is nothing to compare a turn's * requested model against, and a change was silently dropped. Officer's UI, its cost folding and its * durable log all took the client's word for it, so a switch produced a session that reported one tier * and answered as another. */ model?: string; /** * Whether this session is currently in plan mode. * * Same shape and same reason as `model` above: `permissionMode` is a `query()` option read once, so this * is the only record of what the live session is actually on, and a change has to go through a control * request rather than the next turn's options. * * It is also what `canUseTool` reads to decide whether an `ExitPlanMode` is a real decision or a formality * — see `planGate`. */ planMode: boolean; /** * Plans waiting on a human, keyed by request id. * * Each entry is a `canUseTool` promise the AGENT IS BLOCKED ON, so this map is the one piece of session * state whose leak is a hang rather than a stale value: an entry that is never resolved is a turn that * never ends. Everything that can end a session resolves these first — see `settleAllPlans`. */ pendingPlans: Map< string, { resolve: (decision: { approve: boolean; feedback?: string }) => void; timer: ReturnType } >; /** * AskUserQuestion calls waiting on a human, keyed by the harness's `tool_use_id`. * * Same hazard as `pendingPlans` — each entry is a promise the agent is blocked on — and settled from the * same places, via `settleAllQuestions`. Keyed on the tool call id rather than an id we invent because * the UI already has that one: it arrives with `tool:start` and comes back on the answer, so nothing has * to be correlated by position or by "the newest pending question", which is what breaks when two * questions are asked in one turn. */ pendingQuestions: Map void; timer: ReturnType }>; /** The tail of the child's stderr, so its exit can carry a reason instead of just a number. */ stderrTail: string; /** Whether the child ever produced a single message. False plus a non-zero exit means it never started. */ sawOutput: boolean; /** * When the `PreCompact` hook fired, so the `compact_boundary` that closes it can carry how long the * silence lasted. The harness reports the boundary but not the duration, and the duration is the part * that explains the wait. */ compactStartedAt?: number; idleTimer?: ReturnType; /** * Fires when a turn that claims to be generating has emitted nothing for too long. * * The idle timer above answers the opposite question — how long a session with NO turn in flight may * sit before it is collected — so neither one covers a turn that is wedged. Nothing did: a turn could * stop producing events and stay `isGenerating` forever, and every client showed a spinner with no * timeout of its own. On 2026-08-08 that ran for seventeen minutes inside a compaction and was * indistinguishable, from the phone, from a dead chat. */ stallTimer?: ReturnType; }; /* How long a generating turn may say nothing before we call it stalled. Generous on purpose, because the legitimate silences here are long: compaction narrates nothing for as long as it takes (minutes on a large conversation), and a single deep tool call can be quiet for a while too. This is a backstop against turns that will never speak again, not a latency budget — too tight and it would kill work that was about to succeed, which is worse than the hang it prevents. */ const STALL_TIMEOUT_MS = 10 * 60 * 1000; /** Compaction gets longer still: it is the known-slowest silent phase, and the one that stalled. */ const COMPACT_STALL_TIMEOUT_MS = 20 * 60 * 1000; const sessions = new Map(); /** * The owner's spawn — what the SDK would do by default, written out so it can be wrapped by `watchChild`. * * A member's turn already supplies its own (`spawnClaudeAsMember`) because it has to go through `setpriv`. * The owner had no such function and therefore no place to observe the child, which is precisely why its * death was invisible. The existence check mirrors the SDK's default: without it a bad `CLAUDE_BIN` fails * as a write to a closed pipe several seconds later, naming nothing useful. */ function spawnClaudeAsOwner({ command, args, cwd, env, signal }: SpawnOptions): SpawnedProcess { if (!existsSync(command)) throw new Error(`claude CLI not found at ${command}`); const child = spawn(command, args, { cwd, env, signal, stdio: ['pipe', 'pipe', 'pipe'] }); // Non-null by construction: 'pipe' on all three. Mirrors the cast in `spawn-as-member.ts`. return child as unknown as SpawnedProcess; } /** * Drop a session whose `claude` process is gone, and tell whoever was waiting. * * This closes the hole that made a dead agent look like a slow one **forever**. The SDK runs two * independent tasks per session: the consumer loop (`for await (const msg of q)`) and an input pump that * writes the queue to the child's stdin. The consumer loop's `finally` is what removes a session from the * map — but when the CHILD dies, it is the input pump that fails, with `ProcessTransport is not ready for * writing`, and that rejection neither ends the consumer loop nor is caught anywhere. So the loop stayed * parked on a stream with no writer, `finally` never ran, the session stayed in the map, and * `spawnClaudeStreaming` handed every later turn to the same corpse. Each one pushed a message onto a * queue nobody drained: no error, no result, no timeout. The client spun forever, and the only trace was * an `unhandledRejection` line in the sidecar log. * * Observed on 2026-08-14; the session had to be cleared with `pm2 restart officer-claude-code`. * * Emitting only while a turn is in flight is deliberate. A child that exits between turns is invisible to * the user, and an error bubble arriving in a chat nobody is looking at would be noise — dropping the map * entry is the whole repair there, because the next turn then builds a fresh session and resumes the * transcript by id. */ function dropDeadSession(session: PersistentSession, detail: string): void { // Before the identity guard, and deliberately: this fires on the real process exit, and a waiter must be // released even when a newer session has already taken this key. Otherwise a replaced session's waiter // blocks until its timeout for a process that died on schedule. session.markExited(); // Identity, not key: a later turn may have already replaced this entry, and killing its session because // its predecessor's process exited would break the live conversation instead of a dead one. if (sessions.get(session.sessionKey) !== session) return; sessions.delete(session.sessionKey); if (session.idleTimer) clearTimeout(session.idleTimer); if (session.stallTimer) clearTimeout(session.stallTimer); session.idleTimer = undefined; session.stallTimer = undefined; const wasGenerating = session.isGenerating; session.isGenerating = false; // A deliberate teardown (kill / idle GC) aborts first, and its exit is not news. if (session.abort.signal.aborted) return; const diagnosis = diagnoseExit({ sawOutput: session.sawOutput, resumeId: session.resumeId, stderrTail: session.stderrTail, }); const reason = stderrReason(session.stderrTail); console.error( `[claude:exit:${session.sessionKey}] ${detail} resume=${session.resumeId ?? 'none'} ` + `sawOutput=${session.sawOutput}${reason ? ` stderr="${reason}"` : ' stderr='}`, ); // Dropping the mapping is what makes the thread usable again — until this, the failing id was retried on // every message forever. See `diagnoseExit` for why `sawOutput` is the thing that decides it. if (diagnosis.clearResume) { clearClaudeSession(session.sessionKey); console.error( `[claude:exit:${session.sessionKey}] cleared the stored resume id — the next message starts a new session`, ); } if (!wasGenerating) return; session.emit({ type: 'error', message: diagnosis.message }); session.notify('turn-ended'); } /** Wrap a spawn so the session self-heals when its child goes away. */ function watchChild( inner: (options: SpawnOptions) => SpawnedProcess, session: PersistentSession, ): (options: SpawnOptions) => SpawnedProcess { return (options: SpawnOptions): SpawnedProcess => { const child = inner(options); child.on('exit', (code, signal) => dropDeadSession(session, `claude exited (code=${code ?? 'null'} signal=${signal ?? 'null'})`), ); child.on('error', (err: Error) => dropDeadSession(session, `claude failed to start: ${err.message}`)); return child; }; } /** A hand-rolled async iterable we can push turns onto and close on teardown. */ function makeInputQueue() { const buf: SdkUserMessage[] = []; let wake: (() => void) | null = null; let closed = false; async function* gen(): AsyncGenerator { while (true) { if (buf.length) { yield buf.shift()!; continue; } if (closed) return; await new Promise((r) => { wake = r; }); } } return { gen: gen(), push(m: SdkUserMessage) { buf.push(m); wake?.(); wake = null; }, close() { closed = true; wake?.(); wake = null; }, }; } /** * (Re)arm the stall watchdog. Called on every emitted event, so any sign of life pushes it back. * * On expiry it ends the turn the same way a real failure would — `isGenerating` off, idle re-armed, * and an `error` the client can render — rather than tearing the session down. The agent process is * left alive deliberately: it may still be working, and the next turn resumes it. What this guarantees * is that the CLIENT is told, which is the part that was missing. */ function armStall(session: PersistentSession): void { if (session.stallTimer) clearTimeout(session.stallTimer); if (!session.isGenerating) { session.stallTimer = undefined; return; } // ── A turn WAITING ON A PERSON is not a stalled turn ── // // Parking on `AskUserQuestion` or a plan gate means emitting nothing until somebody answers — that is // what parking is. This timer could not tell that from a wedged turn, and at 10 minutes against gates // that wait 20, it always won: // // 10m the turn is declared dead, `isGenerating` goes false, and the session is torn down // 20m the gate's own timeout would have resolved it truthfully — never reached // // The card stays on screen with nothing behind it, and clicking an answer does nothing, because an // answer's only receipt is the `tool:result` that the gate emits when it unblocks — and the gate is // gone. Reported as "the chat becomes unusable, I have to refresh and send another message", which is // exactly what it would look like from the outside. // // So a session with an outstanding question or plan is left alone here. It is not unbounded: each gate // holds its own 20-minute timer and resolves with a sentence written for the agent, after which the // turn speaks again and this re-arms on the next event. const waitingOnAPerson = session.pendingQuestions.size > 0 || session.pendingPlans.size > 0; if (waitingOnAPerson) { session.stallTimer = undefined; return; } const compacting = session.compactStartedAt !== undefined; session.stallTimer = setTimeout( () => { if (!session.isGenerating) return; const waited = Math.round((compacting ? COMPACT_STALL_TIMEOUT_MS : STALL_TIMEOUT_MS) / 60000); console.error( `[claude:stall:${session.sessionKey}] no events for ${waited}m${compacting ? ' (compacting)' : ''} — ending the turn`, ); session.isGenerating = false; session.compactStartedAt = undefined; session.interrupted = false; session.emit({ type: 'error', message: compacting ? `Compaction has produced nothing for ${waited} minutes and has been given up on. Your conversation is safe — send again to continue.` : `The agent has produced nothing for ${waited} minutes and has been given up on. Your conversation is safe — send again to continue.`, }); // A background job can be silent for far longer than this and still land its `task_notification`, so // a stall with tasks outstanding keeps the old behaviour and leaves the session alone. if (session.pendingTasks.size > 0) return; // Otherwise tear it down rather than leaving it armed for the next turn. // // This used to keep the session — "it may still be working, and the next turn resumes it" — which is // the right instinct for a SLOW agent and exactly wrong for a wedged one: a session that has said // nothing for ten minutes because its transport is broken stays broken, so every later turn hangs // the same way and the message above ("send again to continue") is a lie. Killing costs a resume, // which is what the message already promises; the transcript id survives in `claudeSessions`, so the // next turn continues the same conversation. killClaudeSession(session.sessionKey, session.userId); }, compacting ? COMPACT_STALL_TIMEOUT_MS : STALL_TIMEOUT_MS, ); } function armIdle(session: PersistentSession): void { if (session.idleTimer) clearTimeout(session.idleTimer); session.idleTimer = setTimeout(() => { // Heartbeat: never GC a session that's mid-turn or still has background tasks running — a long // silent run_in_background job would otherwise be killed along with its pending task_notification. if (session.isGenerating || session.pendingTasks.size > 0) { armIdle(session); return; } // The idle GC is this process acting on its own session, so it passes the session's own owner. killClaudeSession(session.sessionKey, session.userId); }, IDLE_TIMEOUT_MS); } // Async only because the preamble is: resolving which plugins are installed and enabled, so their // capabilities can be read where they live, reads the database. Once per session — see // `capability-sources.ts`. Its single caller already awaits. async function createSession( params: ClaudeSpawnStreamingParams, onEvent: (event: ChatEvent) => void, onNotify: (kind: 'turn-ended' | 'input-needed') => void, ): Promise { const { sessionKey } = params; const input = makeInputQueue(); const abort = new AbortController(); const session: PersistentSession = { sessionKey, userId: params.userId, query: undefined as unknown as Query, pushTurn: () => {}, closeInput: () => input.close(), abort, emit: onEvent, notify: onNotify, isGenerating: false, pendingTasks: new Set(), planMode: params.planMode === true, pendingPlans: new Map(), pendingQuestions: new Map(), interrupted: false, stderrTail: '', sawOutput: false, // Assigned immediately below — the resolver has to escape the executor before the object is usable. exited: undefined as unknown as Promise, markExited: () => {}, }; session.exited = new Promise((resolve) => { session.markExited = resolve; }); // Strip the nested-session guard vars so the SDK can spawn `claude` (mirrors the old spawn env clean). const cleanEnv = envWithoutParentSession(); const resumeId = getClaudeSession(sessionKey, params.userId) ?? params.resumeSessionId; /* Where that transcript actually lives, because `--resume` is scoped to the WORKING DIRECTORY. Claude Code files transcripts per cwd (`~/.claude/projects//.jsonl`), so resuming from a different directory fails with "No conversation found with session ID" — exit 1, before any output. Conversations created before the New Chat directory picker carry no cwd, fall back to HOST_HOME, and could therefore NEVER resume: every message died the same way, and the exit line said `stderr=` while the CLI had printed the reason. The transcript knows its own directory — every line carries `cwd` — so it is read from the file rather than guessed. An explicit cwd from the caller still wins; this only fills the gap. */ const resumeCwd = resumeId && !params.cwd ? cwdOfTranscript(resumeId) : undefined; const subModel = params.model?.split('/')[1]; session.resumeId = resumeId; session.model = params.model; /** * What we are about to run, before we run it. * * A member's turn died on `exit code=1` with no stderr and nothing else logged, and the resume id — the * one input most likely to be wrong — appeared in no log anywhere. Every hypothesis about it therefore * had to be tested by hand on the server, which took an evening and produced two wrong answers before * the right question. One line here would have replaced all of it. * * The resume id in particular is not the id in the URL: it comes from the stored map, so the two can * disagree, and only this line can show that they have. */ console.log( `[claude:spawn:${sessionKey}] resume=${resumeId ?? 'none'} cwd=${params.cwd ?? resumeCwd ?? 'default'} ` + `as=${params.member?.osUser ?? 'owner'} model=${params.model ?? 'default'}`, ); // Read here rather than at module load: a plugin installed an hour ago should be live on the next // message, and this costs a few kilobytes once per session. const affordances = await affordanceBlock(); // The skills INDEX — names, descriptions and paths, never bodies. See skills/load.ts for why this is // the inverse of an affordance. Read here rather than at module load for the same reason: a skill // written an hour ago should be offered on the next session. const skills = await skillsBlock(); /* And, for a commander, the two paths only this session can be told. Not in the skill, which is read on demand and cannot know them; not only in the background-job dialog's preamble, which is where this used to live and which an agent arming itself never sees. See `api/command-hq/commander-block.ts`. */ /* No commander block here, deliberately. It used to be composed in, and could never arrive: this runs once per session, and a commander's first turn has no row and no transcript id, so the block resolved to null and no later turn rebuilt the preamble. It rides `effectivePrompt` instead — see `startClaudeTurn` in api/chat/websocket.ts. */ const preamble = [affordances, skills].filter(Boolean).join('\n\n') || null; const q = query({ prompt: input.gen as AsyncIterable, options: { // HOST_HOME is this process's home — the owner's. Defaulting a member's turn to it would start them in // a directory they cannot read, and the failure would look like a broken agent rather than a wrong cwd. cwd: params.cwd ?? resumeCwd ?? params.member?.home ?? HOST_HOME, // `bypassPermissions` unless the conversation opened in plan mode. CLAUDE.md is explicit that the // unsandboxed default is deliberate — this is the owner's own machine — so plan mode is the only // thing that may ever move it, and it moves back the moment a plan is approved. permissionMode: session.planMode ? 'plan' : 'bypassPermissions', allowDangerouslySkipPermissions: true, /** * Passed ALWAYS, and shadowed almost always. * * Under `bypassPermissions` the SDK never consults this and says so once at startup * (`CLAUDE_SDK_CAN_USE_TOOL_SHADOWED`) — verified 2026-08-17: a bypassing session made a file edit with * zero calls here. But `canUseTool` is a `query()` option read once at session creation, so a session * that did not pass it can never gate anything later. Passing it unconditionally is what makes plan * mode toggleable on a LIVE conversation instead of only choosable when one starts, and it costs a * log line. * * It allows everything that is not `ExitPlanMode`, and that is not laziness. Approving a plan drops * the harness into `default` — not back into `bypassPermissions` — so for the rest of that turn this * callback is the only thing between the agent and its tools. A default-deny here would jail an agent * the platform deliberately does not jail. */ canUseTool: async (toolName, toolInput) => { if (toolName !== 'ExitPlanMode' || !session.planMode) return { behavior: 'allow', updatedInput: toolInput }; return planGate(session, toolInput); }, includePartialMessages: true, // The only warning that compaction is about to happen. Everything else the harness does narrates // itself through the message stream; compaction goes silent for as long as it takes — 2.5 minutes // in the worst case on disk here — and the stream resumes with no explanation of the gap. The hook // returns immediately and never throws: it is a notification, and it must not be able to stall or // fail the compaction it is announcing. hooks: { /** * The answer channel for `AskUserQuestion`, and the reason it is a HOOK rather than `canUseTool`. * * Without one the harness answers the tool itself, instantly, with the literal string "The user did * not answer the questions." — before the card has painted, let alone been read. The agent then acts * on a refusal that never happened: on 2026-08-19 it asked the same question three times in a row * while the answers were sitting on screen. * * `canUseTool` cannot carry this. Under `bypassPermissions` — which is every session here — the SDK * shadows it and never calls it (measured against 0.2.59: a bypassing session's AskUserQuestion * reached the PreToolUse hook and never reached the callback). Hooks run regardless of permission * mode, which is exactly the property needed. * * The mechanism is `permissionDecision: 'deny'` with the answer as its reason, because the harness * feeds that reason back as the TOOL RESULT verbatim. "Deny" reads wrong and is right: the tool is * prevented from resolving itself, and what replaces its output is the human's answer. * * The matcher timeout is deliberately longer than `QUESTION_ANSWER_TIMEOUT_MS` so that OUR timer is * always the one that fires. A hook killed by the harness would fall back to the built-in lie; a * hook that times out here answers in words that are true. */ PreToolUse: [ { matcher: 'AskUserQuestion', timeout: Math.round((QUESTION_ANSWER_TIMEOUT_MS + 5 * 60 * 1000) / 1000), hooks: [ async (input) => { if (!('tool_use_id' in input) || typeof input.tool_use_id !== 'string') return { continue: true }; const answer = await questionGate(session, input.tool_use_id); return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: answer, }, }; }, ], }, ], PreCompact: [ { hooks: [ async (input) => { session.compactStartedAt = Date.now(); const trigger = 'trigger' in input && input.trigger === 'manual' ? 'manual' : 'auto'; session.emit({ type: 'compact:start', trigger }); // Re-arm on the compaction budget: this hook fires as the long silence BEGINS, so the // deadline the turn is holding was sized for ordinary work and is about to be wrong. armStall(session); return { continue: true }; }, ], }, ], }, abortController: abort, // A member's turn runs their own install as their own Linux account; the owner's runs as it always has. // // `settingSources` is why the binary and the spawn have to move together: it makes `~/.claude` // authoritative for settings, and `~` is decided by the HOME the process gets. Pointing the SDK at a // member's binary while spawning as the service user would read the OWNER'S settings and credential // while executing the member's code — the worst of both, and it would look like it worked. // // Both branches go through `watchChild`: the owner's spawn exists only so there is something to // wrap (see `spawnClaudeAsOwner`), because a child nobody watches is a session that can die silently. ...(params.member ? { pathToClaudeCodeExecutable: claudeBinIn(params.member.home), spawnClaudeCodeProcess: watchChild(spawnClaudeAsMember(params.member), session), } : { pathToClaudeCodeExecutable: CLAUDE_BIN, spawnClaudeCodeProcess: watchChild(spawnClaudeAsOwner, session), }), settingSources: ['user', 'project', 'local'], /** * What the platform tells the agent about the surface it is speaking through. * * `preset` keeps Claude Code's own system prompt and APPENDS to it — replacing it would throw away * everything the harness knows about its own tools. * * Passed here rather than written to a file because this is the one place every session goes * through, owner and member alike. A generated CLAUDE.md cannot do that: `user-instance.ts` * deliberately refuses to write one for the owner, since it would pollute the personal * `~/.claude/CLAUDE.md` they maintain themselves. * * Read per session, not cached: `capabilities/` is already read fresh each agent session, so a * plugin installed an hour ago is live on the next message without a restart. * * OPENCODE DOES NOT GET THIS, and that is a real gap rather than an oversight — an affordance that * holds on one harness is the drift this exists to remove. Deliberate as of 2026-08-19: opencode * development is paused pending its v2 migration, so the work would be thrown away. See TODO.md. */ ...(preamble ? { systemPrompt: { type: 'preset' as const, preset: 'claude_code' as const, append: preamble } } : {}), env: cleanEnv as Record, stderr: (d: string) => { if (!d.trim()) return; console.error(`[claude:stream:${sessionKey}] ${d.slice(0, 300)}`); // Kept as well as logged, so the exit can carry it. A reason that only exists in the server's log // is a reason the person who hit it cannot see, and they are the one who has to decide what to do. session.stderrTail = `${session.stderrTail}${d}`.slice(-2000); }, ...(subModel ? { model: subModel } : {}), ...(resumeId ? { resume: resumeId } : {}), // The owner's MCP config, and only ever the owner's. `mcpHostPath` is module-level, written once at // this process's bootstrap, and its `env` carries OFFICER_AUTH_TOKEN — a JWT that signs as the owner. // Handing it to a member's turn would either spawn their MCP server holding the owner's token, or (once // that file is 0600, which it now is) point their `claude` at a file it cannot read and fail obscurely. // // So a member gets no MCP config at all. What they SHOULD get — their own generated config with a token // scoped to them, or nothing until per-user tools exist — is an open design question; `undefined` is // the correct answer until it is settled, and is strictly better than the owner's. ...(mcpHostPath && !params.member ? { extraArgs: { 'mcp-config': mcpHostPath } } : {}), }, }); session.query = q; session.pushTurn = (prompt: string, images?: PromptImage[]) => { if (session.idleTimer) clearTimeout(session.idleTimer); session.isGenerating = true; // A turn that dies before its FIRST event is the case the emit-path arming cannot reach. armStall(session); // Images first, then the text: the model reads what it is looking at before what to do about it. const content: string | ContentBlock[] = images?.length ? [ ...images.map( (img): ContentBlock => ({ type: 'image', source: { type: 'base64', media_type: img.mediaType, data: img.data }, }), ), { type: 'text', text: prompt }, ] : prompt; input.push({ type: 'user', message: { role: 'user', content }, parent_tool_use_id: null, session_id: sessionKey, }); }; sessions.set(sessionKey, session); // Single consumer loop for the session's whole life. Turn-end (`result`) and errors flip isGenerating // and (re)arm the idle timer; the session process stays alive so later task_notifications still flow. void (async () => { const state = createParseState(); const emit = (raw: ChatEvent) => { // A turn we interrupted ends in a failed `result`. That is the stop landing, not a fault. const event: ChatEvent = raw.type === 'error' && session.interrupted ? { type: 'stopped' } : // The boundary knows what it dropped; only this side knows how long it took, because the start // came from a hook rather than from the stream. raw.type === 'compact:done' && session.compactStartedAt ? { ...raw, durationMs: Date.now() - session.compactStartedAt } : raw; if (event.type === 'compact:done') session.compactStartedAt = undefined; if (event.type === 'task:started') { // Work is running — hold off idle-GC until it finishes. session.pendingTasks.add(event.taskId); if (session.idleTimer) { clearTimeout(session.idleTimer); session.idleTimer = undefined; } } else if (event.type === 'task:notification') { session.pendingTasks.delete(event.taskId); if (!session.isGenerating && session.pendingTasks.size === 0) armIdle(session); } else if (event.type === 'result' || event.type === 'error' || event.type === 'stopped') { session.isGenerating = false; // Whatever ended the turn, the interrupt is spent — a later genuine error must not wear it. session.interrupted = false; if (session.pendingTasks.size === 0) armIdle(session); session.notify('turn-ended'); } // Any event at all is a sign of life, so push the stall deadline back. Placed after the branch // above so a terminal event disarms rather than re-arms it. armStall(session); // See `countsAsOutput`: an error is the one event that does not prove the harness was working. if (countsAsOutput(event.type)) session.sawOutput = true; session.emit(event); }; try { for await (const msg of q as AsyncGenerator>) { processMessage(msg, state, { onEvent: emit, onSessionId: (id: string) => setClaudeSession(sessionKey, id, params.userId), }); } } catch (err) { if (!abort.signal.aborted) { session.emit({ type: 'error', message: err instanceof Error ? err.message : String(err) }); } } finally { if (session.idleTimer) clearTimeout(session.idleTimer); if (session.stallTimer) clearTimeout(session.stallTimer); // Before the session leaves the map, so nothing is left holding a promise whose resolver has just // become unreachable. settleAllPlans(session, 'The session ended before the plan was answered.'); settleAllQuestions(session, 'The session ended before the question was answered.'); sessions.delete(sessionKey); } })(); return session; } /** Start a turn: create the persistent session if needed, then push the prompt as a user message. */ export async function spawnClaudeStreaming( params: ClaudeSpawnStreamingParams, onEvent: (event: ChatEvent) => void, onNotify: (kind: 'turn-ended' | 'input-needed') => void, ): Promise { let session = sessions.get(params.sessionKey); if (session && session.userId !== params.userId) { // A live session belongs to whoever started it. Without this, handing over someone else's `sessionKey` // pushes a turn into their conversation and streams their agent's output back — the chat equivalent of // resuming another account's shell, which `4d4a253f` refused for the pty sidecar. // // Throws rather than silently starting a fresh session under the same key: the caller asked to continue a // specific conversation, and quietly giving them a different one is its own kind of wrong. throw new Error('that chat session belongs to another account'); } if (session) { session.emit = onEvent; // adopt the latest emitter (equivalent across turns; keeps events flowing) session.notify = onNotify; await applyModelChange(session, params.model); await applyPlanModeChange(session, params.planMode); } else { session = await createSession(params, onEvent, onNotify); } session.pushTurn(params.prompt, params.images); } /** * Move a LIVE session onto a different model, the way the CLI's `/model` does. * * `model` is a `query()` option and options are read once, so before this a changed model was dropped on * the floor — the session kept answering as whatever it started as while Officer's UI, its cost folding and * its durable log all reported the new tier. The mismatch was invisible: no log line, no error, and the one * way to trigger it was to leave a chat idle past the 30-minute GC. * * `setModel` is a control request, so it needs streaming input mode — which is the only mode this file uses. * That is why nothing has to be torn down and re-resumed: the conversation, its cwd, its permission mode and * its transcript all continue, and only the next response comes from a different model. * * A refusal is reported and swallowed. The turn the user just typed is worth more than the tier they picked, * so a `setModel` the harness rejects must not lose the prompt — it answers as the old model and says so. */ async function applyModelChange(session: PersistentSession, model: string | undefined): Promise { if (!model || model === session.model) return; const previous = session.model; const subModel = model.split('/')[1]; if (!subModel) return; try { await session.query.setModel(subModel); session.model = model; console.log(`[claude:model:${session.sessionKey}] ${previous ?? 'default'} -> ${model}`); session.emit({ type: 'model:switched', from: previous ?? null, to: model, ok: true }); } catch (err) { // Leave `session.model` alone, so the next turn tries again rather than believing a switch that failed. const reason = err instanceof Error ? err.message : String(err); console.error(`[claude:model:${session.sessionKey}] refused ${previous ?? 'default'} -> ${model}: ${reason}`); session.emit({ type: 'model:switched', from: previous ?? null, to: model, ok: false, error: reason }); } } /* How long a plan may sit unanswered before it is refused on the user's behalf. This is a deadlock timer, not a UX one. `canUseTool` gates the turn: while a plan is pending the agent is blocked, `isGenerating` stays true, and no further events are emitted — which also means the stall watchdog above sees a silent turn and would eventually tear the whole session down. Refusing after twenty minutes gives a plan a realistic reading time while guaranteeing the turn ends by a route that keeps the conversation, since a denial is something the agent can answer. */ const PLAN_DECISION_TIMEOUT_MS = 20 * 60 * 1000; /** * How long a question waits for a person before the agent is told, truthfully, that it has no answer yet. * * Matched to the plan timeout because the situation is the same one: a human who has walked away, and a turn * that must end rather than hang. What differs is the wording — an unanswered question is not a refusal, and * saying so invites the agent to re-ask, which is the behaviour this whole channel exists to stop. */ const QUESTION_ANSWER_TIMEOUT_MS = 20 * 60 * 1000; /** * Hold `ExitPlanMode` open while a human decides. * * The one place in this file where the agent waits on Officer rather than the other way round. The promise * returned here is the harness's permission answer, so every path out of it must resolve — a throw or a * forgotten branch is a wedged turn, and the symptom would be a chat that shows a plan and then nothing. */ /** * The working directory a transcript belongs to, read from the transcript itself. * * `--resume` only finds a session when the process starts in the directory it was recorded in, and the * id alone does not say which that is. Every line of a transcript carries its own `cwd`, so the file * answers the question — no path-slug decoding, which is lossy the moment a directory name contains a * dash. * * Only the FIRST line is read. These files reach 100 MB (one on this machine already has), and the * answer is in the first few hundred bytes. * * The same id can exist under two project directories when a checkout has moved — `…/officerdev/platform` * and `…/officer-dev/platform` both hold one here. The newest wins, and a directory that no longer * exists is skipped, because resuming into a path that is gone fails exactly the way this is fixing. */ function cwdOfTranscript(sessionId: string): string | undefined { try { const root = join(homedir(), '.claude', 'projects'); const candidates = readdirSync(root) .map((dir) => join(root, dir, `${sessionId}.jsonl`)) .filter((file) => existsSync(file)) .map((file) => ({ file, at: statSync(file).mtimeMs })) .sort((a, b) => b.at - a.at); for (const { file } of candidates) { const fd = openSync(file, 'r'); try { const buf = Buffer.alloc(8192); const read = readSync(fd, buf, 0, buf.length, 0); const line = buf.subarray(0, read).toString('utf8').split('\n')[0]; const cwd: unknown = line ? (JSON.parse(line) as { cwd?: unknown }).cwd : undefined; if (typeof cwd === 'string' && cwd && existsSync(cwd)) return cwd; } finally { closeSync(fd); } } } catch { // A missing projects dir, an unreadable file, a first line that is not JSON — all mean "no answer", // and the caller falls back to the default cwd exactly as it did before this existed. } return undefined; } async function planGate( session: PersistentSession, toolInput: Record, ): Promise<{ behavior: 'allow'; updatedInput: Record } | { behavior: 'deny'; message: string }> { const plan = typeof toolInput.plan === 'string' ? toolInput.plan : ''; const requestId = `plan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const decision = await new Promise<{ approve: boolean; feedback?: string }>((resolve) => { const timer = setTimeout(() => { session.pendingPlans.delete(requestId); console.warn(`[claude:plan:${session.sessionKey}] no decision in 20m — refusing on the user's behalf`); resolve({ approve: false, feedback: 'No decision was made in time. The plan was not approved.' }); }, PLAN_DECISION_TIMEOUT_MS); session.pendingPlans.set(requestId, { resolve, timer }); session.emit({ type: 'plan:proposed', requestId, plan }); session.notify('input-needed'); }); session.emit({ type: 'plan:decided', requestId, approved: decision.approve, feedback: decision.feedback }); if (!decision.approve) { // Denial is the REVISION path, not an abort: the harness feeds this string back and asks the agent to // call ExitPlanMode again. So the message is the feedback, and a bare refusal still needs to say // something the agent can act on. return { behavior: 'deny', message: decision.feedback?.trim() || 'The plan was rejected. Revise it and ask again.', }; } /* Approval leaves plan mode — but into `default`, NOT back into `bypassPermissions` (verified 2026-08-17). The agent carries straight on inside this same turn, so the restore is fired here rather than at turn end. Deliberately NOT awaited, and the ordering is the reason: the harness is waiting on the promise this function returns, so awaiting a control request before answering it invites a deadlock. Any tool call that lands in the gap is allowed by the callback above, which makes the window a matter of overhead rather than correctness. */ session.planMode = false; void session.query.setPermissionMode('bypassPermissions').catch((err: unknown) => { console.error(`[claude:plan:${session.sessionKey}] could not restore bypassPermissions: ${String(err)}`); }); session.emit({ type: 'plan:mode', enabled: false, ok: true }); return { behavior: 'allow', updatedInput: toolInput }; } /** * Park an `AskUserQuestion` until a person answers it, and hand back what they said. * * Returns the string that BECOMES THE TOOL RESULT, so every branch has to read as an answer to the agent * rather than as a status report about the plumbing. * * No `question:asked` event is emitted, and that is the point of keying on `tool_use_id`: the tool call is * already in the session log as an ordinary `tool:start`, durable and ordered, carrying the questions. A * second copy would be a second source of truth for the same card, and they would disagree the moment one * of them was replayed. */ async function questionGate(session: PersistentSession, toolCallId: string): Promise { return new Promise((resolve) => { const timer = setTimeout(() => { session.pendingQuestions.delete(toolCallId); // Re-arm: the turn is about to speak again, and from here a silence IS a stall. `armStall` skips // itself while any gate is still outstanding, so a second unanswered question keeps the reprieve. armStall(session); console.warn(`[claude:question:${session.sessionKey}] no answer in 20m`); resolve( 'The user has not answered yet — they may be away from the screen. The question is still on theirs, ' + 'so do NOT ask it again. Continue with your best judgement, or stop and wait for their next message.', ); }, QUESTION_ANSWER_TIMEOUT_MS); session.pendingQuestions.set(toolCallId, { resolve, timer }); session.notify('input-needed'); }); } /** * Resolve every outstanding question, because the session or turn it belonged to is going away. * * Same contract as `settleAllPlans` and for the same reason: an unresolved entry is a turn that never ends. */ function settleAllQuestions(session: PersistentSession, reason: string): void { for (const [toolCallId, pending] of session.pendingQuestions) { clearTimeout(pending.timer); session.pendingQuestions.delete(toolCallId); pending.resolve(reason); } } /** * The user's answer to a pending question. False when nothing is waiting under that tool call id. * * A miss is ordinary, exactly as it is for plans: a reconnected client replaying its last action, or a * second pane answering a question the first already answered. */ export function answerClaudeQuestion(sessionKey: string, userId: number, toolCallId: string, answer: string): boolean { const session = ownedSession(sessionKey, userId); const pending = session?.pendingQuestions.get(toolCallId); // ── A dropped answer has to SAY it was dropped ── // // Every failure on this path used to be silent: the caller discards the boolean, `sendFire` does // nothing when no sidecar is connected, and this returns undefined for both "no such session" and // "not yours". So from the person's side a lost answer and a slow agent are the same thing — a card // that does not change — and the only move left is to send another message. Reported exactly that way. // // The receipt for a GOOD answer is deliberately the tool:result in the durable log, so that a second // pane sees it too. A failure gets the same treatment for the same reason: an error into the session // log reaches every socket watching, rather than confirming to the one browser that clicked. if (!session) { console.warn(`[claude:question:${sessionKey}] answer for a session this process does not hold`); return false; } if (!pending) { console.warn(`[claude:question:${sessionKey}] answer for ${toolCallId}, which is no longer waiting`); session.emit({ type: 'error', message: 'That answer arrived too late — the question had already been given up on, and the agent has ' + 'moved on. Send it as a message instead.', }); return false; } clearTimeout(pending.timer); session.pendingQuestions.delete(toolCallId); // The turn resumes now, so silence stops being legitimate and the watchdog goes back on. `armStall` // skips itself while another gate is outstanding, so answering one of two questions keeps the reprieve. armStall(session); pending.resolve(answer); return true; } /** * Resolve every outstanding plan as refused. * * Called from every path that ends a session or a turn. A pending plan is a promise the agent is blocked * on, so leaving one behind when the session goes away is the difference between a turn that ends and a * turn that hangs until the stall watchdog notices. */ function settleAllPlans(session: PersistentSession, reason: string): void { for (const [requestId, pending] of session.pendingPlans) { clearTimeout(pending.timer); session.pendingPlans.delete(requestId); pending.resolve({ approve: false, feedback: reason }); } } /** * The user's answer to a pending plan. Returns false when there is nothing waiting under that id. * * A miss is ordinary rather than exceptional: a client that reconnects and replays its last action can * answer a plan that has already timed out, and the honest response is to ignore it — the alternative is * resolving a promise that no longer exists, or worse, a newer plan that happens to be pending. */ export function decideClaudePlan( sessionKey: string, userId: number, requestId: string, approve: boolean, feedback?: string, ): boolean { const session = ownedSession(sessionKey, userId); const pending = session?.pendingPlans.get(requestId); if (!session || !pending) return false; clearTimeout(pending.timer); session.pendingPlans.delete(requestId); pending.resolve({ approve, feedback }); return true; } /** * Turn plan mode on or off on a live session — the same control-request shape as `applyModelChange`. * * Reachable two ways, because the toggle is used at two different moments: on the turn itself (a new * conversation whose first turn should plan) and on its own between turns (the common case — you decide to * plan while reading the last answer, when there is no turn to carry the flag). * * A refusal is reported and swallowed for the same reason a model refusal is: the conversation is worth more * than the mode, so the session stays on what it was and says so rather than losing the turn. */ async function applyPlanModeChange(session: PersistentSession, enabled: boolean | undefined): Promise { if (enabled === undefined || enabled === session.planMode) return; try { await session.query.setPermissionMode(enabled ? 'plan' : 'bypassPermissions'); session.planMode = enabled; console.log(`[claude:plan:${session.sessionKey}] plan mode ${enabled ? 'on' : 'off'}`); session.emit({ type: 'plan:mode', enabled, ok: true }); } catch (err) { const reason = err instanceof Error ? err.message : String(err); console.error(`[claude:plan:${session.sessionKey}] refused plan mode ${enabled ? 'on' : 'off'}: ${reason}`); // `session.planMode` is deliberately untouched: believing a switch that failed would put the gate above // out of step with the harness, and the visible symptom of that is an ExitPlanMode nobody is asked about. session.emit({ type: 'plan:mode', enabled, ok: false, error: reason }); } } /** Flip plan mode on a live session from outside a turn. Returns false when no such session is live. */ export async function setClaudePlanMode(sessionKey: string, userId: number, enabled: boolean): Promise { const session = ownedSession(sessionKey, userId); if (!session) return false; await applyPlanModeChange(session, enabled); return true; } /** Interrupt the current turn but KEEP the session alive (the "stop" button). */ export async function interruptClaudeSession(sessionKey: string, userId: number): Promise { const session = ownedSession(sessionKey, userId); if (!session) return false; // Set before the await: the failed `result` can arrive while interrupt() is still resolving, and the // consumer loop reads this flag to tell a stop from a fault. session.interrupted = true; // Stop cannot reach a turn that is blocked on a plan — the agent is not running, it is waiting on us — // so the promise has to be released for the interrupt to have anything to interrupt. settleAllPlans(session, 'The turn was stopped before the plan was answered.'); settleAllQuestions(session, 'The turn was stopped before the question was answered.'); try { await session.query.interrupt(); session.isGenerating = false; if (session.stallTimer) clearTimeout(session.stallTimer); session.stallTimer = undefined; return true; } catch { session.interrupted = false; return false; } } /** Fully tear the session down (the "disconnect" action / idle GC): abort the query + close input. */ /** * Kill a session and wait for its child process to actually be gone. * * The bounded wait is the point. `killClaudeSession` returns as soon as it has aborted, which is before the * `claude` CLI has finished writing — and the CLI, not officer, owns the transcript file. A caller that * deletes files has to await this or it deletes them out from under a process still writing them. * * The timeout resolves rather than throws: a child that will not die within the window is a worse problem * than a stale transcript, and blocking a user's delete on it forever helps nobody. The caller gets `false` * and can say so. */ export async function killClaudeSessionAndWait(sessionKey: string, userId: number, timeoutMs = 5000): Promise { const session = ownedSession(sessionKey, userId); if (!session) return false; const { exited } = session; killClaudeSession(sessionKey, userId); let timer: ReturnType | undefined; const timedOut = await Promise.race([ exited.then(() => false), new Promise((resolve) => { timer = setTimeout(() => resolve(true), timeoutMs); }), ]); if (timer) clearTimeout(timer); return !timedOut; } export function killClaudeSession(sessionKey: string, userId: number): boolean { const session = ownedSession(sessionKey, userId); if (!session) return false; if (session.idleTimer) clearTimeout(session.idleTimer); if (session.stallTimer) clearTimeout(session.stallTimer); settleAllPlans(session, 'The session was closed before the plan was answered.'); settleAllQuestions(session, 'The session was closed before the question was answered.'); try { session.abort.abort(); } catch { /* already aborted */ } try { session.closeInput(); } catch { /* noop */ } sessions.delete(sessionKey); return true; } export function clearSession(sessionKey: string, userId: number): void { // Only clears a mapping that is theirs. `getClaudeSession` already refuses a mismatch, so this asks it // first rather than reimplementing the check. if (!getClaudeSession(sessionKey, userId)) return; clearClaudeSession(sessionKey); } /** * The live session under this key, **only if it belongs to the caller**. * * Undefined for both "no such session" and "not yours", deliberately: every caller of this treats the two the * same, and a distinct answer for the second would tell a guesser that a session exists under a key they do * not own — which is the whole thing being defended against. */ function ownedSession(sessionKey: string, userId: number): PersistentSession | undefined { const session = sessions.get(sessionKey); if (!session || session.userId !== userId) return undefined; return session; } /** * Everything this process is holding, with the two facts that decide whether it is busy. * * Replaces a `getActiveSessionKeys` that returned bare keys and was never called by anything — the keys * alone could not distinguish a session mid-turn from one merely open, which is the whole question a * caller has. These are the same two fields `armIdle` consults before collecting a session. */ export function listSessions(userId: number): LiveClaudeSession[] { // Filtered, not just labelled. Enumerating every live session is a disclosure on its own, before anyone // acts on one: it names other accounts' conversations and says which are busy. return Array.from(sessions.values()) .filter((session) => session.userId === userId) .map((session) => ({ sessionKey: session.sessionKey, // The only place this mapping exists. Without it a caller cannot find the transcript, because the // key is officer's handle and the filename is Claude's id. claudeSessionId: getClaudeSession(session.sessionKey, session.userId) ?? null, isGenerating: session.isGenerating, pendingTasks: session.pendingTasks.size, })); } /** * Is a turn actually in flight for this session, right now, in this process? * * This is the ground truth a reconnecting browser has no way to work out for itself. Officer's own view * dies with `pm2 restart officer` while the turn keeps running here, so "officer doesn't remember" means * nothing — and if *this* process was the one that restarted, the session is simply absent and the turn * it was running is gone, however alive the client still believes it to be. */ export function isSessionGenerating(sessionKey: string, userId: number): boolean { // "Not yours" answers the same as "no such session": false. The caller uses this to decide whether to end // a turn it believes is running, and its own turn is the only one it can be right about. return ownedSession(sessionKey, userId)?.isGenerating ?? false; }