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
1367 lines
65 KiB
TypeScript
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;
|
|
}
|