Files
platform/src/servers/sidecar/claude/claude-manager.ts
T
pastilhasandClaude Opus 5 538a623bb8 every agent session gets a key, scoped to notify
a commander has officer.env and can reach the platform. an ordinary chat
agent had nothing — the only token in the tree lives in the MCP server's
environment, which the agent's own Bash cannot see. so an agent asked to
tell its owner something either could not, or asked to be handed a personal
API key: the owner's full authority, pasted into a transcript. one refused
tonight and was right to.

minted per session, permissions ['notify'], expiring in a week, named
session:<key> so re-arming replaces rather than accumulates and a survivor
is findable rather than one of a hundred identical rows.

"an agent may tell me things" is a far smaller thing to hand out than "an
agent may act as me", and it is the whole of what was being asked for.
everything else an agent needs it already has: it runs as somebody, in
their home, with their files. widening this later is one entry in an array;
starting wide and narrowing later never happens.

in the environment rather than a file, because it dies with the process:
nothing to leak, nothing to clean up after a crash, no file for a later
session to find. the commander's credential is a file for the opposite
reason — a detached loop reads it hours later.

members get it too, through the allowlist rather than round it. those
OFFICER_* names sit next to NEVER_ENV deliberately, and they are not that
kind of thing: NEVER_ENV guards the OWNER'S credentials, which would make a
member's shell more privileged than the member. this grants a member one
capability they already have, over their own devices, and nobody else's.

best effort throughout — a turn still starts if minting fails, and the
agent is simply back where it was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 09:06:29 +00:00

1381 lines
66 KiB
TypeScript

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 { mintSessionCredential } from './session-credential';
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<string, string> {
const out: Record<string, string> = { ...process.env } as Record<string, string>;
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<ClaudeCodeResult> {
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<string, string>,
});
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<string>; // 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<void>;
/** 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<typeof setTimeout> }
>;
/**
* 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<string, { resolve: (answer: string) => void; timer: ReturnType<typeof setTimeout> }>;
/** 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<typeof setTimeout>;
/**
* 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<typeof setTimeout>;
};
/*
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<string, PersistentSession>();
/**
* 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=<silent>'}`,
);
// 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<SdkUserMessage> {
while (true) {
if (buf.length) {
yield buf.shift()!;
continue;
}
if (closed) return;
await new Promise<void>((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<PersistentSession> {
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<string>(),
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<void>,
markExited: () => {},
};
session.exited = new Promise<void>((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();
/*
This session's own credential, scoped to notifications.
In the environment rather than a file: it dies with the process, there is no path to leak and nothing
to clean up after a crash. The agent's Bash inherits it as `$OFFICER_AGENT_KEY`, which is the whole
point — until now the only token in the tree was the MCP server's, which the agent's shell cannot see,
so an agent asked to tell its owner something had no door at all.
Null when minting failed. The agent then has no key, which is exactly where everything was before.
*/
const agentEnv = await mintSessionCredential(params.userId, sessionKey);
if (agentEnv) Object.assign(cleanEnv, agentEnv);
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/<slugged path>/<id>.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=<silent>` 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();
/*
No commander block here, and none in `startClaudeTurn` either, as of `docs/commander-directory.md`.
Two things used to live here in turn, both dead ends. First, the background-job dialog's own preamble,
which an agent arming itself from the conversation never saw. Then a per-turn block riding
`effectivePrompt` (`commander-block.ts`, deleted 2026-08-24) — which could not resolve on a commander's
FIRST turn either, since this function runs once per session and that turn has no row and no transcript
id yet, so the block was null and no later turn ever rebuilt the preamble.
`POST /command-hq/commanders` creates the row, its directory and its `CLAUDE.md` BEFORE any turn spawns
now, so a commander is told its name, its reference path and the two paths it must record itself at by a
FILE Claude Code reads from its own working directory at session start — the same mechanism this
function's `affordances`/`skills` blocks still use for everything else, minus the per-turn cost.
*/
const preamble = [affordances, skills].filter(Boolean).join('\n\n') || null;
const q = query({
prompt: input.gen as AsyncIterable<SdkUserMessage>,
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<string, string>,
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<Record<string, unknown>>) {
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<void> {
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<void> {
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<string, unknown>,
): Promise<{ behavior: 'allow'; updatedInput: Record<string, unknown> } | { 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<string> {
return new Promise<string>((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<void> {
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<boolean> {
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<boolean> {
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<boolean> {
const session = ownedSession(sessionKey, userId);
if (!session) return false;
const { exited } = session;
killClaudeSession(sessionKey, userId);
let timer: ReturnType<typeof setTimeout> | undefined;
const timedOut = await Promise.race([
exited.then(() => false),
new Promise<boolean>((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;
}