Files
platform/src/servers/sidecar/claude/claude-manager.ts
T
pastilhasandClaude Opus 5 8770f98dcd capabilities: read a plugin's where they live, stop copying them in
Four publishers copied a plugin's affordances, notifications and skills into
capabilities/ at install. That directory is meant to be forked — core's work
plus the owner's — so a fork accumulated files it did not author with nothing on
disk saying which, and there was no good answer: commit them and carry another
plugin's work, leave them and git status stays dirty.

Copying was also wrong in two ways unrelated to git. A copy goes stale, because
plugins:update pulls the plugin's directory and the copy keeps describing the
old version. And a copy survived plugins:disable, so a disabled plugin's skill
stayed in the agent's index — off, and still being announced.

Now the loaders take a list of roots: the owner's store, plus each installed AND
enabled plugin's own directory. capability-sources.ts resolves that list. No
namespace is needed to keep them apart, because separate directories do it
structurally rather than by convention.

The owner's own win a skill name collision — their root is first and the merge
keeps the first of each id — so a plugin cannot take over a name already in use.
Affordances are deliberately NOT de-duplicated: several can be true at once.

Notifications needed more than a root list, and it would have failed silently.
Their codes were derived from the COPY'S PATH, <publisher>/<appName>/file.yaml.
Read in place the path is just notifications/catalogue.yaml and carries no
identity, so every plugin code would have vanished with nothing raised.
loadPluginEntries takes the identity from the manifest instead, which is better
provenance anyway — publisher and appName are validated at discovery, where a
path was only ever a directory somebody chose. A yaml key still cannot claim
another namespace, and that is pinned by test.

createSession is async now, only because resolving enabled plugins reads the
database. Once per session; its single caller already awaited.

Deletes all four publishers including api/tasks/publish.ts from earlier today,
superseded before it shipped. app-store/assets stays — images served over HTTP,
where the copy is what makes them reachable.

NOT done: tasks. That store is read-write, so reads must span sources while
writes stay in the owner's directory, and a plugin's task has to be read-only in
the UI. Next.

Two things the design predicted as orphans turned out not to be, and both were
checked before deleting rather than after. capabilities/extensions/tool-loader
is written by the platform's own bootstrap every boot, not published by a
plugin. And skills/email-db has no source to fall back on — the email plugin
ships no skills directory, so that file exists in exactly one place and was
hand-written. Neither was touched.

1422 pass, tsgo clean, officer restarts and serves.

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

1367 lines
65 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 { 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();
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();
/*
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<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;
}