/** * agent-manager.ts — Tracks agents, background execution, resume support. * * Background agents are subject to a configurable concurrency limit (default: 4). * Excess agents are queued and auto-started as running agents complete. * Foreground agents bypass the queue (they block the parent anyway), and so do * nested children — see `occupiesPoolSlot`. */ import { randomUUID } from "node:crypto"; import { statSync } from "node:fs"; import { isAbsolute } from "node:path"; import type { Model } from "@earendil-works/pi-ai"; import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js"; import { type MandatoryExtensionPolicy, normalizeMandatoryExtensionPaths, } from "./mandatory-extensions.js"; import { assignHandle, handleBase } from "./mention.js"; import { unregisterPermissionChildSession } from "./permission-system-bridge.js"; import type { AgentInvocation, AgentRecord, AgentTombstone, IsolationMode, MentionResolution, SubagentType, ThinkingLevel } from "./types.js"; import { addUsage, type LifetimeUsage } from "./usage.js"; import { cleanupWorktree, createWorktree, isWorktreeIsolationEnabled, pruneWorktrees, } from "./worktree.js"; export type OnAgentComplete = (record: AgentRecord) => void; export type OnAgentStart = (record: AgentRecord) => void; export type OnAgentCompact = (record: AgentRecord, info: CompactionInfo) => void; /** * Fired once per assistant `message_end`, for EVERY agent this manager owns — * top-level and nested alike, spawns and resumes. The one place where each * message is seen exactly once: `AgentRecord.lifetimeUsage` is deliberately * double-booked into ancestors (see `nested-tools.ts`) so a hidden child's spend * shows up on the record a human can see, which makes those records useless as * a basis for anything that must not count a message twice — parent-session * accounting above all. */ export type OnAgentUsage = (record: AgentRecord, usage: LifetimeUsage) => void; export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }; /** * Default max concurrent background agents. * * Raised from 4 when top-level spawns started defaulting to background * (`backgroundByDefault`): foreground agents bypass this pool entirely, so * while foreground was the default a fan-out of six ran six. With background * as the default every top-level agent takes a slot, and a limit of 4 would * have silently queued the tail of exactly the parallel fan-outs the `Agent` * tool description tells the model to send. */ const DEFAULT_MAX_CONCURRENT = 10; /** * How many evicted agents stay addressable by name. Only a bound on memory — * a session that spawns hundreds of agents shouldn't retain every one — and * far above the handful anyone keeps in their head. */ const MAX_TOMBSTONES = 100; /** * Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset" * (parent cwd). Anything else must be an absolute path to an existing * directory — curated errors instead of TypeErrors from path/fs internals * (RPC callers send arbitrary JSON: null, numbers, file paths). */ function assertValidSpawnCwd(cwd: unknown): asserts cwd is string | undefined | null { if (cwd == null) return; if (typeof cwd !== "string" || !isAbsolute(cwd)) { throw new Error(`SpawnOptions.cwd must be an absolute path: "${String(cwd)}"`); } let isDirectory = false; try { isDirectory = statSync(cwd).isDirectory(); } catch { throw new Error(`SpawnOptions.cwd does not exist: "${cwd}"`); } if (!isDirectory) { throw new Error(`SpawnOptions.cwd is not a directory: "${cwd}"`); } } /** * Whether a record occupies one of the `maxConcurrent` background slots. * Nested children don't: their parent already holds a slot, so counting (and * therefore queueing) them would deadlock a parent that waits on its own child. * * Note this bounds nothing horizontally — the depth cap limits how DEEP nesting * goes, not how WIDE. A parent's only limit on concurrent children is that each * spawn costs it a turn, which is unbounded when max turns is unlimited. */ function occupiesPoolSlot(record: Pick): boolean { return !!record.isBackground && record.parentAgentId === undefined; } interface SpawnArgs { pi: ExtensionAPI; ctx: ExtensionContext; type: SubagentType; prompt: string; options: SpawnOptions; } interface SpawnOptions { description: string; /** * Optional memorable name for this instance, becoming a second handle * (`@auth-audit`) alongside the type-derived one. Slugged, not validated — * anything unusable degrades via `handleBase` rather than failing the spawn. */ name?: string; /** * Reopen this pi session file instead of starting a fresh conversation, so a * mention of an evicted agent continues where it left off. The agent's * definition is still resolved from its type, so the continuation runs under * the type's CURRENT config. */ resumeSessionFile?: string; /** * Take an evicted agent's names back verbatim instead of allocating fresh * ones, so a resumed conversation keeps the handle the user just typed — * `handleBase(type)` cannot reproduce a numbered `explore-2`. Safe without an * `assignHandle` pass because tombstoned names are excluded from allocation * (`takenHandles`), so nothing live can be holding them. * * Internal capability, like `resumeSessionFile`: a forged handle would * duplicate a live agent's name and make `resolveMention` ambiguous, so * `spawnTopLevel` strips it from anything a caller sends. */ reclaim?: { handle: string; alias?: string }; model?: Model; maxTurns?: number; isolated?: boolean; inheritContext?: boolean; thinkingLevel?: ThinkingLevel; isBackground?: boolean; /** * Skip the maxConcurrent queue check for this spawn — start immediately even * if the configured concurrency limit would otherwise queue it. Used by the * scheduler so a fired job can't be deferred past its trigger window. */ bypassQueue?: boolean; /** Isolation mode — "worktree" creates a temp git worktree for the agent. */ isolation?: IsolationMode; /** * Working directory for the agent (absolute path). Default: parent session * cwd. The agent's tools operate here, but .pi config (extensions, skills, * settings, memory) still loads from the parent session's project — the * target directory's `.pi` extensions never execute. With isolation: * "worktree", the worktree is created FROM this directory and the result * branch lands in that repo. */ cwd?: string; /** Resolved invocation snapshot captured for UI display. */ invocation?: AgentInvocation; /** Parent abort signal — when aborted, the subagent is also stopped. */ signal?: AbortSignal; /** Called on tool start/end with activity info (for streaming progress to UI). */ onToolActivity?: (activity: ToolActivity) => void; /** Called on streaming text deltas from the assistant response. */ onTextDelta?: (delta: string, fullText: string) => void; /** Called when the agent session is created (for accessing session stats). */ onSessionCreated?: (session: AgentSession) => void; /** Called at the end of each agentic turn with the cumulative count. */ onTurnEnd?: (turnCount: number) => void; /** Called once per assistant message_end with that message's usage delta. */ onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void; /** Called when the session successfully compacts. */ onCompaction?: (info: CompactionInfo) => void; /** Nesting depth: top-level subagent = 1. */ depth?: number; /** Parent agent ID for ownership-scoped nested controls. */ parentAgentId?: string; /** Effective inherited nesting cap for this branch. */ maxSubagentDepth?: number; /** Config-discovery root inherited by nested launches when it differs from the working directory. */ configCwd?: string; /** Root session id, inherited by nested launches so transcripts stay grouped. */ rootSessionId?: string; } interface ResumeOptions { /** * Run the resumed turn detached in the background: return immediately with * the record still "running" (or "queued" at the concurrency limit) and * notify on completion via onComplete, exactly like a background spawn. * Default (false/undefined) runs the resume inline and returns the settled * record — the historical behavior. */ isBackground?: boolean; /** Called on tool start/end with activity info (for streaming progress to UI). */ onToolActivity?: (activity: ToolActivity) => void; /** Called once per assistant message_end with that message's usage delta. */ onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void; /** Called when the session successfully compacts. */ onCompaction?: (info: CompactionInfo) => void; /** * Background resume only: called synchronously when the run actually starts — * immediately, or later from drainQueue. Callers wire per-run side effects * (output-file streaming) here rather than at the call site, so a resume that * is stopped while still queued never leaves a subscription behind: `abort()` * drops a queued record without reaching `settle()`, which is what would have * torn that subscription down. */ onStarted?: () => void; } /** Best-effort ceiling on one child's shutdown handlers, so teardown can't strand a quit. */ const CHILD_SHUTDOWN_TIMEOUT_MS = 3_000; /** * Close the extension lifecycle `runAgent` opened with `bindExtensions`, then dispose. * * `AgentSession.dispose()` only calls `ExtensionRunner.invalidate()` — pi emits the event * itself in `AgentSessionRuntime.dispose()` beforehand, and this is the one place that binds * extensions onto a session without going through that path. Without the emit, everything an * extension armed in `session_start` leaks once per spawn, and its next tick throws * `assertActive()` from a bare timer callback — an uncaughtException that kills pi (#242). */ async function shutdownChildSession(session: AgentSession | undefined): Promise { try { const runner = session?.extensionRunner; // Optional all the way down: on a pi without the getter, or a stubbed session from a // partial `onSessionCreated`, skip the emit — the same degrade as before this fix. if (runner?.hasHandlers?.("session_shutdown")) { // Raced, not awaited outright. `emit` runs every handler serially with no timeout of // its own, and dispose() is reached from pi's own `session_shutdown` with the TUI // already torn down — one hung handler would leave a dead terminal. await Promise.race([ runner.emit({ type: "session_shutdown", reason: "quit" }), new Promise(resolve => setTimeout(resolve, CHILD_SHUTDOWN_TIMEOUT_MS).unref()), ]); } } catch { /* a partial session must degrade, not take the teardown down with it */ } // Always, even on timeout: disposal is what this function ultimately exists to do. // Publish child disposal only after the session can no longer issue permission asks. try { session?.dispose?.(); } catch { /* ignore */ } finally { unregisterPermissionChildSession(session); } } export interface AgentManagerOptions extends MandatoryExtensionPolicy {} export class AgentManager { private agents = new Map(); private cleanupInterval: ReturnType; private onComplete?: OnAgentComplete; private onStart?: OnAgentStart; private onCompact?: OnAgentCompact; private onUsage?: OnAgentUsage; private maxConcurrent: number; private readonly mandatoryExtensionPaths: readonly string[]; /** Base repos worktrees were created from — so dispose() can prune them all, * not just the parent repo (caller-supplied cwd can target other repos). */ private worktreeRepos = new Set(); /** * Evicted agents that can still be reached by name, keyed by handle. Outlives * the 10-minute record cleanup — that timer exists to bound memory, not to * expire a conversation the user might still want — and is cleared alongside * completed records on session start/switch. */ private tombstones = new Map(); /** Queue of background agents waiting to start. */ private queue: { id: string; start: () => void }[] = []; /** Number of currently running background agents. */ private runningBackground = 0; constructor( onComplete?: OnAgentComplete, maxConcurrent = DEFAULT_MAX_CONCURRENT, onStart?: OnAgentStart, onCompact?: OnAgentCompact, onUsage?: OnAgentUsage, options: AgentManagerOptions = {}, ) { this.onComplete = onComplete; this.onStart = onStart; this.onCompact = onCompact; this.onUsage = onUsage; this.maxConcurrent = maxConcurrent; this.mandatoryExtensionPaths = normalizeMandatoryExtensionPaths( options.mandatoryExtensionPaths, ); // Cleanup completed agents after 10 minutes (but keep sessions for resume) this.cleanupInterval = setInterval(() => this.cleanup(), 60_000); this.cleanupInterval.unref(); } /** Update the max concurrent background agents limit. */ setMaxConcurrent(n: number) { this.maxConcurrent = Math.max(1, n); // Start queued agents if the new limit allows this.drainQueue(); } getMaxConcurrent(): number { return this.maxConcurrent; } /** * Spawn an agent and return its ID immediately (for background use). * If the concurrency limit is reached, the agent is queued. */ spawn( pi: ExtensionAPI, ctx: ExtensionContext, type: SubagentType, prompt: string, options: SpawnOptions, ): string { // Validate before the queue branch — a queued spawn should fail at the // call, not minutes later at drain. Throw (not warn): programmatic callers // can fix and retry; the RPC layer converts throws into error envelopes. assertValidSpawnCwd(options.cwd); const id = randomUUID().slice(0, 17); const abortController = new AbortController(); const record: AgentRecord = { id, type, // Nested children are filtered out of every top-level surface, so no // handle: nothing can address them and they must not consume a name a // top-level sibling could otherwise take. handle: options.parentAgentId !== undefined ? undefined // A reclaimed handle is used as-is: it belongs to the conversation this // spawn is reopening, and re-deriving it would lose the numbering. : options.reclaim?.handle ?? assignHandle(handleBase(type), this.takenHandles()), description: options.description, // Reclaimed here, or filled in below from `name` — in which case it must // see the handle this record just took, since both come out of the same // namespace. alias: options.parentAgentId === undefined ? options.reclaim?.alias : undefined, status: options.isBackground ? "queued" : "running", toolUses: 0, startedAt: Date.now(), abortController, lifetimeUsage: { input: 0, output: 0, cacheWrite: 0, cost: 0 }, compactionCount: 0, // Raw tri-state (not coerced to a boolean): true = background, false = // foreground (has an inline tool-result surface), undefined = caller never // declared it (e.g. a cross-extension RPC spawn). The widget's background- // only filter excludes only explicit `false`, so undefined agents — which // have no inline surface — stay visible instead of vanishing. isBackground: options.isBackground, invocation: options.invocation, depth: options.depth ?? 1, parentAgentId: options.parentAgentId, maxSubagentDepth: options.maxSubagentDepth, rootSessionId: options.rootSessionId, }; this.agents.set(id, record); // After the insert, so `takenHandles()` already counts this record's own // handle — a spawn named after its own type gets `explore-2`, not a // duplicate `explore` that would make resolution ambiguous. if (record.handle !== undefined && record.alias === undefined && options.name !== undefined) { record.alias = assignHandle(handleBase(options.name), this.takenHandles()); } const args: SpawnArgs = { pi, ctx, type, prompt, options }; if (occupiesPoolSlot(record) && !options.bypassQueue && this.runningBackground >= this.maxConcurrent) { // Queue it — will be started when a running agent completes this.queue.push({ id, start: () => this.startAgent(id, record, args) }); return id; } // startAgent can throw (e.g. strict worktree-isolation failure) — clean // up the record so callers don't see an orphan in `listAgents()`. try { this.startAgent(id, record, args); } catch (err) { this.agents.delete(id); throw err; } return id; } /** Actually start an agent (called immediately or from queue drain). */ private startAgent(id: string, record: AgentRecord, { pi, ctx, type, prompt, options }: SpawnArgs) { // Re-validate a caller-supplied cwd: queued spawns can start minutes after // spawn()'s check, and the directory may be gone by then (TOCTOU). Same // curated errors; drainQueue parks a throw on the record as an error. assertValidSpawnCwd(options.cwd); // Single resolution point for the caller-supplied cwd — the worktree base // repo and both cleanup calls below MUST agree on this value forever. const customCwd = options.cwd ?? undefined; // null (RPC "unset") → undefined const baseCwd = customCwd ?? ctx.cwd; // Worktree isolation: try to create a temporary git worktree. Strict — // fail loud if not possible (no silent fallback to main tree). Done // BEFORE state mutation so a throw doesn't leave the record half-running. // The project switch is enforced here as well as at the tool boundary // because cross-extension RPC forwards its options unvalidated — a schema // that omits the field can't stop a caller that never saw the schema. let worktreeCwd: string | undefined; if (options.isolation === "worktree" && isWorktreeIsolationEnabled()) { const wt = createWorktree(baseCwd, id); if (!wt) { throw new Error( 'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' + 'Initialize git and commit at least once, or omit `isolation`.', ); } record.worktree = wt; // workPath preserves subdirectory scoping for caller-supplied cwds: a // cwd deep in a monorepo maps to the same subdir inside the copy, not // the copied repo's root. Plain worktree spawns keep the historical // behavior (agent at the copy's root) — moving them to workPath would // also move .pi config discovery when the parent session sits in a repo // subdirectory, silently dropping extensions/skills. worktreeCwd = customCwd !== undefined ? wt.workPath : wt.path; this.worktreeRepos.add(baseCwd); } record.status = "running"; record.startedAt = Date.now(); if (occupiesPoolSlot(record)) this.runningBackground++; this.onStart?.(record); // Wire parent abort signal to stop the subagent when the parent is interrupted let detachParentSignal: (() => void) | undefined; if (options.signal) { const onParentAbort = () => this.abort(id); options.signal.addEventListener("abort", onParentAbort, { once: true }); detachParentSignal = () => options.signal!.removeEventListener("abort", onParentAbort); } const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; }; const promise = runAgent(ctx, type, prompt, { pi, agentId: id, model: options.model, maxTurns: options.maxTurns, isolated: options.isolated, inheritContext: options.inheritContext, thinkingLevel: options.thinkingLevel, resumeSessionFile: options.resumeSessionFile, nested: options.parentAgentId !== undefined, // Every descendant forwards permission asks to the interactive root, not // to an intermediate headless child. Top-level tool calls already stamp // rootSessionId; programmatic callers fall back to their current context. authoritySessionId: record.rootSessionId ?? ctx.sessionManager?.getSessionId?.(), mandatoryExtensionPaths: this.mandatoryExtensionPaths, // Worktree wins for the working dir (the agent must run in the copy — // which, with a custom cwd, was created from that target). Config stays // with the parent project when a caller-supplied cwd is in play; it must // stay undefined otherwise so plain worktree runs keep resolving config // (incl. relative extension paths and memory) inside the worktree copy. cwd: worktreeCwd ?? customCwd, // Set iff a worktree was created (see above) — names the directory the // copy came from, so the prompt can tell the agent not to work there. worktreeBase: worktreeCwd ? baseCwd : undefined, configCwd: options.configCwd ?? (customCwd !== undefined ? ctx.cwd : undefined), signal: record.abortController!.signal, onToolActivity: (activity) => { if (activity.type === "end") record.toolUses++; options.onToolActivity?.(activity); }, onTurnEnd: options.onTurnEnd, onTextDelta: options.onTextDelta, onAssistantUsage: (usage) => { addUsage(record.lifetimeUsage, usage); this.onUsage?.(record, usage); options.onAssistantUsage?.(usage); }, onCompaction: (info) => { record.compactionCount++; this.onCompact?.(record, info); options.onCompaction?.(info); }, nestedRuntime: { manager: this, parentAgentId: id, depth: record.depth ?? 1, maxSubagentDepth: record.maxSubagentDepth, }, onSessionCreated: (session) => { record.session = session; // Capture now, while the session object exists: after eviction this // path is the only thing that can reopen the conversation, and an // in-memory session reports undefined, which correctly means // "nothing to come back to". // Optional chaining, not defensiveness for its own sake: this is the // only field read off the session at creation, so an older pi or a // stubbed session must degrade to "not resumable" rather than throw // and take the whole spawn down with it. record.sessionFile = session.sessionManager?.getSessionFile?.(); // Flush any steers that arrived before the session was ready if (record.pendingSteers?.length) { for (const msg of record.pendingSteers) { session.steer(msg).catch(() => {}); } record.pendingSteers = undefined; } options.onSessionCreated?.(session); }, }) .then(({ responseText, session, aborted, steered, failure }) => { // Don't overwrite status if externally stopped via abort() if (record.status !== "stopped") { // Precedence: a hard abort keeps "aborted"; then a failed final turn // (provider error that pi resolved instead of rejecting, #144) is an // honest "error" — not a completion with an empty or stale result. if (aborted) { record.status = "aborted"; } else if (failure) { record.status = "error"; record.error = failure; } else { record.status = steered ? "steered" : "completed"; } } record.result = responseText; record.session = session; record.completedAt ??= Date.now(); detach(); // Final flush of streaming output file if (record.outputCleanup) { try { record.outputCleanup(); } catch { /* ignore */ } record.outputCleanup = undefined; } // Clean up worktree if used if (record.worktree) { const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description); record.worktreeResult = wtResult; if (wtResult.hasChanges && wtResult.branch) { // With a caller-supplied cwd the branch lives in THAT repo, not the // parent session's — say so, or the orchestrator merges in the wrong repo. const repoNote = customCwd !== undefined ? ` in \`${baseCwd}\`` : ""; record.result = (record.result ?? "") + `\n\n---\nChanges saved to branch \`${wtResult.branch}\`${repoNote}. Merge with: \`git merge ${wtResult.branch}\`${customCwd !== undefined ? ` (run in \`${baseCwd}\`)` : ""}`; } } this.abortOwnedChildren(id); // Fire onComplete for foreground agents too — lifecycle symmetry. // Mark resultConsumed so the callback skips notifications (result returned inline). if (!options.isBackground) { record.resultConsumed = true; try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ } } else { if (occupiesPoolSlot(record)) this.runningBackground--; try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ } this.drainQueue(); } return responseText; }) .catch((err) => { // Don't overwrite status if externally stopped via abort() if (record.status !== "stopped") { record.status = "error"; } record.error = err instanceof Error ? err.message : String(err); record.completedAt ??= Date.now(); detach(); // Final flush of streaming output file on error if (record.outputCleanup) { try { record.outputCleanup(); } catch { /* ignore */ } record.outputCleanup = undefined; } // Best-effort worktree cleanup on error if (record.worktree) { try { const wtResult = cleanupWorktree(baseCwd, record.worktree, options.description); record.worktreeResult = wtResult; } catch { /* ignore cleanup errors */ } } this.abortOwnedChildren(id); // Fire onComplete for foreground agents too — lifecycle symmetry. // Mark resultConsumed so the callback skips notifications (result returned inline). if (!options.isBackground) { record.resultConsumed = true; this.onComplete?.(record); } else { if (occupiesPoolSlot(record)) this.runningBackground--; this.onComplete?.(record); this.drainQueue(); } return ""; }); record.promise = promise; // Notify caller that spawn is complete (record is in the map, promise is set). // Called synchronously — onSessionCreated fires asynchronously inside runAgent. // Used by spawnAndWait to let the caller set up output files before streaming starts. this.onSpawned?.(id); } /** * Stop the nested children a settled parent owns. Nested records are hidden * from the UI and only their owner can consume them, so a child outliving its * parent would burn tokens unseen with no way to reach it. Grandchildren are * covered transitively — each abort lands in that child's own settle path. */ private abortOwnedChildren(parentId: string): void { for (const [id, record] of this.agents) { if (record.parentAgentId === parentId) this.abort(id); } } /** Start queued agents up to the concurrency limit. */ private drainQueue() { while (this.queue.length > 0 && this.runningBackground < this.maxConcurrent) { const next = this.queue.shift()!; const record = this.agents.get(next.id); if (!record || record.status !== "queued") continue; try { next.start(); } catch (err) { // Late failure (e.g. strict worktree-isolation) — surface on the record // so the user/agent can see it via /agents, then keep draining. record.status = "error"; record.error = err instanceof Error ? err.message : String(err); record.completedAt = Date.now(); this.onComplete?.(record); } } } /** * Called synchronously right after spawn, before onSessionCreated fires. * Lets the caller set up the output file path on the record. * The record is guaranteed to be in this.agents at this point. */ private onSpawned?: (id: string) => void; /** * Spawn an agent and wait for completion (foreground use). * Foreground agents bypass the concurrency queue. * Returns { id, record } so callers can access the agent ID. * * @param onSpawned - Called synchronously after spawn(), before onSessionCreated fires. * Use this to set record.outputFile so streamToOutputFile can pick it up. */ async spawnAndWait( pi: ExtensionAPI, ctx: ExtensionContext, type: SubagentType, prompt: string, options: Omit, onSpawned?: (id: string) => void, ): Promise<{ id: string; record: AgentRecord }> { // Temporarily register the onSpawned hook so startAgent can call it. const prevOnSpawned = this.onSpawned; this.onSpawned = onSpawned; let id: string; try { // spawn() invokes onSpawned synchronously before returning. Restore the // shared hook immediately so unrelated concurrent spawns cannot inherit // this foreground caller's callback while its run is awaited. id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false }); } finally { this.onSpawned = prevOnSpawned; } const record = this.agents.get(id)!; await record.promise; return { id, record }; } /** * Resume an existing agent session with a new prompt. */ async resume( id: string, prompt: string, signal?: AbortSignal, options?: ResumeOptions, ): Promise { const record = this.agents.get(id); if (!record?.session) return undefined; // Background resume: settle asynchronously and notify on completion exactly // like a background spawn, returning immediately with the record still // "running" — or "queued" when at the concurrency limit. Previously // run_in_background was ignored on resume (the Agent tool's resume branch // returned before its background branch, and resume() only ever awaited // inline), so a resumed agent always blocked the caller until it finished. if (options?.isBackground) { // Never re-enter a run that is still in flight. Detaching means the caller // gets control back while the record stays "running", so nothing stops the // model from resuming the same agent again. Starting a second run would // overwrite record.abortController — orphaning the live run beyond the // reach of `/agents` stop and abortAll() — double-count the pool slot, and // then reject from session.prompt() with "Agent is already processing", // whose settle path would abort the LIVE run's children and report a // failure for a run that is still going. Refuse instead, leaving the // record untouched; the caller decides whether to wait or steer. if (record.status === "running" || record.status === "queued") return undefined; record.isBackground = true; record.resultConsumed = false; record.result = undefined; record.error = undefined; record.completedAt = undefined; record.status = "queued"; const start = () => this.startResume(id, record, prompt, signal, options); if (occupiesPoolSlot(record) && this.runningBackground >= this.maxConcurrent) { // At the concurrency limit — queue it, drains when a slot frees. this.queue.push({ id, start }); } else { start(); } return record; } // Foreground resume: run inline and return the settled record. record.status = "running"; record.startedAt = Date.now(); record.completedAt = undefined; record.result = undefined; record.error = undefined; try { const { text, failure } = await resumeAgent(record.session, prompt, { onToolActivity: (activity) => { if (activity.type === "end") record.toolUses++; options?.onToolActivity?.(activity); }, onAssistantUsage: (usage) => { addUsage(record.lifetimeUsage, usage); this.onUsage?.(record, usage); options?.onAssistantUsage?.(usage); }, onCompaction: (info) => { record.compactionCount++; this.onCompact?.(record, info); options?.onCompaction?.(info); }, signal, }); // Same contract as the spawn path (#144): a failed final turn is an // error, not a completion — but the resumed text stays available. record.status = failure ? "error" : "completed"; if (failure) record.error = failure; record.result = text; record.completedAt = Date.now(); } catch (err) { record.status = "error"; record.error = err instanceof Error ? err.message : String(err); record.completedAt = Date.now(); } // Same contract as the spawn settle paths: children spawned during the // resumed turn must not outlive it — nothing else can see or reach them. this.abortOwnedChildren(id); return record; } /** * Start a background resume run: detached, settling and notifying like * startAgent's background path. Invoked immediately, or from drainQueue when * a concurrency slot frees. The session already exists (resume reuses it), so * there is no onSessionCreated to hang per-run wiring off — callers use * `options.onStarted`, which fires on both the immediate and the drained path. */ private startResume( id: string, record: AgentRecord, prompt: string, parentSignal: AbortSignal | undefined, options: ResumeOptions, ) { if (!record.session) return; record.status = "running"; record.startedAt = Date.now(); if (occupiesPoolSlot(record)) this.runningBackground++; this.onStart?.(record); // Fresh abort controller so /agents stop and steering target THIS run rather // than the previous one's settled controller. const abortController = new AbortController(); record.abortController = abortController; // Optional, and NOT what the Agent tool passes for a detached resume: a // parent signal aborts on the parent's own interrupt (user Esc), which is // right for a foreground run whose result the caller is awaiting, and wrong // for a detached one — background spawns omit it for exactly this reason. let detachParentSignal: (() => void) | undefined; if (parentSignal) { const onParentAbort = () => this.abort(id); parentSignal.addEventListener("abort", onParentAbort, { once: true }); detachParentSignal = () => parentSignal.removeEventListener("abort", onParentAbort); } // Per-run side effects (output streaming) — see ResumeOptions.onStarted. // After the record is in its running shape, before the run is kicked off. try { options.onStarted?.(); } catch { /* ignore caller wiring errors */ } const settle = () => { detachParentSignal?.(); detachParentSignal = undefined; // Final flush of streaming output file if (record.outputCleanup) { try { record.outputCleanup(); } catch { /* ignore */ } record.outputCleanup = undefined; } // Children spawned during the resumed turn must not outlive it. this.abortOwnedChildren(id); if (occupiesPoolSlot(record)) this.runningBackground--; try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ } this.drainQueue(); }; const promise = resumeAgent(record.session, prompt, { onToolActivity: (activity) => { if (activity.type === "end") record.toolUses++; options.onToolActivity?.(activity); }, onAssistantUsage: (usage) => { addUsage(record.lifetimeUsage, usage); this.onUsage?.(record, usage); options.onAssistantUsage?.(usage); }, onCompaction: (info) => { record.compactionCount++; this.onCompact?.(record, info); options.onCompaction?.(info); }, signal: abortController.signal, }) .then(({ text, failure }) => { // Don't overwrite status if externally stopped via abort(). if (record.status !== "stopped") { // Same contract as the spawn path (#144): a failed final turn is an // error, not a completion — but the resumed text stays available. record.status = failure ? "error" : "completed"; if (failure) record.error = failure; } record.result = text; record.completedAt ??= Date.now(); settle(); return text; }) .catch((err) => { if (record.status !== "stopped") { record.status = "error"; record.error = err instanceof Error ? err.message : String(err); } record.completedAt ??= Date.now(); settle(); return ""; }); record.promise = promise; } /** * Send a steering message to an agent from the UI (mirrors the steer_subagent * tool). A live session delivers it now — it interrupts the agent after its * current tool execution and appears as a user message. If the session isn't * ready yet, the message is queued on `pendingSteers` and flushed when the * session is created. Returns false if the agent can't accept steering * (unknown id, or no longer running/queued). */ steer(id: string, message: string): boolean { const record = this.agents.get(id); if (!record) return false; if (record.status !== "running" && record.status !== "queued") return false; if (record.session) { record.session.steer(message).catch(() => {}); } else { if (!record.pendingSteers) record.pendingSteers = []; record.pendingSteers.push(message); } return true; } getRecord(id: string): AgentRecord | undefined { return this.agents.get(id); } /** Handles already in use, so a fresh spawn can pick an unclaimed one. */ private takenHandles(): Set { const taken = new Set(); for (const record of this.agents.values()) { if (record.handle) taken.add(record.handle); if (record.alias) taken.add(record.alias); } // Tombstones hold their names too: an evicted `@explore` is still // resurrectable, so a later Explore must become `explore-2` rather than // shadowing a conversation the user can still reach. for (const entry of this.tombstones.values()) { taken.add(entry.handle); if (entry.alias) taken.add(entry.alias); } return taken; } /** * Resolve an `@name` from the prompt. Matches a top-level agent's handle * case-insensitively, preferring one that can still be steered and otherwise * the most recently started (which is the one a resume should continue), then * falls back to an exact agent id so `@` works too. */ resolveMention(name: string): MentionResolution | undefined { const wanted = name.toLowerCase(); let fallback: AgentRecord | undefined; for (const record of this.agents.values()) { if (record.parentAgentId !== undefined) continue; // Handle and alias share one namespace, so at most one agent answers a // name and it makes no difference which of the two matched. if (record.handle?.toLowerCase() !== wanted && record.alias?.toLowerCase() !== wanted) continue; if (record.status === "running" || record.status === "queued") return { kind: "live", record }; if (!fallback || record.startedAt > fallback.startedAt) fallback = record; } if (fallback) return { kind: "live", record: fallback }; const byId = this.agents.get(name); if (byId?.parentAgentId === undefined && byId !== undefined) return { kind: "live", record: byId }; // Only once nothing live answers: a tombstone is a conversation to reopen, // and reopening one while its record still exists would fork the session. for (const entry of this.tombstones.values()) { if (entry.handle.toLowerCase() === wanted || entry.alias?.toLowerCase() === wanted || entry.id === name) { return { kind: "tombstone", entry }; } } return undefined; } /** * Forget an evicted agent, by handle. For the case where its session file has * gone: the entry can then only ever fail, while still holding the name * against the type that would otherwise start a fresh agent under it. * * A *successful* resume does not drop its tombstone — the live record it * creates already wins in `resolveMention`, and overwrites the entry in place * when it is itself evicted. */ dropTombstone(handle: string): void { this.tombstones.delete(handle); } /** Evicted agents whose conversation can still be reopened, newest first. */ listTombstones(): AgentTombstone[] { return [...this.tombstones.values()].sort((a, b) => b.completedAt - a.completedAt); } listAgents(): AgentRecord[] { return [...this.agents.values()].sort( (a, b) => b.startedAt - a.startedAt, ); } abort(id: string): boolean { const record = this.agents.get(id); if (!record) return false; // Remove from queue if queued if (record.status === "queued") { this.queue = this.queue.filter(q => q.id !== id); record.status = "stopped"; record.completedAt = Date.now(); return true; } if (record.status !== "running") return false; record.abortController?.abort(); record.status = "stopped"; record.completedAt = Date.now(); return true; } /** Dispose a record's session and remove it from the map. */ private removeRecord(id: string, record: AgentRecord): void { this.tombstone(record); const session = record.session; // Detached before the shutdown starts, so the record leaves the map at once and // nothing can observe a session that is half torn down. record.session = undefined; this.agents.delete(id); // Fire-and-forget is right here and only here: this runs from the 60s cleanup timer // and from `clearCompleted()` on session boundaries, with the process staying alive, // so handlers get their full window. The quit path awaits instead — see dispose(). void shutdownChildSession(session); } /** * Preserve enough of a departing record for `@handle` to reopen its * conversation later. Nothing to keep unless it has both a handle to be * addressed by and a session file to reopen — an in-memory session leaves no * transcript, so the mention would have nothing to continue from. */ private tombstone(record: AgentRecord): void { if (!record.handle || !record.sessionFile) return; this.tombstones.set(record.handle, { handle: record.handle, alias: record.alias, id: record.id, type: record.type, description: record.description, sessionFile: record.sessionFile, completedAt: record.completedAt ?? Date.now(), }); // Bound the memory a long session can accumulate. Oldest first, since the // agent someone still wants to reach is the one they used most recently. while (this.tombstones.size > MAX_TOMBSTONES) { const oldest = [...this.tombstones.values()].reduce((a, b) => (a.completedAt <= b.completedAt ? a : b)); this.tombstones.delete(oldest.handle); } } private cleanup() { const cutoff = Date.now() - 10 * 60_000; for (const [id, record] of this.agents) { if (record.status === "running" || record.status === "queued") continue; if ((record.completedAt ?? 0) >= cutoff) continue; this.removeRecord(id, record); } } /** * Remove all completed/stopped/errored records immediately. * Called on session start/switch so tasks from a prior session don't persist. * Pass skipUnconsumed=true to preserve records the LLM hasn't read yet * (resultConsumed=false) — they will be evicted by the 10-minute cleanup timer instead. */ clearCompleted(skipUnconsumed = false): void { for (const [id, record] of this.agents) { if (record.status === "running" || record.status === "queued") continue; if (skipUnconsumed && !record.resultConsumed) continue; this.removeRecord(id, record); } // Unconditional: both callers are session boundaries (`session_start` and // `session_before_switch`), and `skipUnconsumed` only spares records whose // results the LLM has yet to read — it does not make the sweep partial in // the sense that matters here. A new session means new handles, or // `@explore` would silently reach an agent the user never started. Claude // Code resets its registry on `/clear` for the same reason. this.tombstones.clear(); } /** Whether any agents are still running or queued. */ hasRunning(): boolean { return [...this.agents.values()].some( r => r.status === "running" || r.status === "queued", ); } /** Abort all running and queued agents immediately. */ abortAll(): number { let count = 0; // Clear queued agents first for (const queued of this.queue) { const record = this.agents.get(queued.id); if (record) { record.status = "stopped"; record.completedAt = Date.now(); count++; } } this.queue = []; // Abort running agents for (const record of this.agents.values()) { if (record.status === "running") { record.abortController?.abort(); record.status = "stopped"; record.completedAt = Date.now(); count++; } } return count; } /** Wait for all running and queued agents to complete (including queued ones). */ async waitForAll(): Promise { // Loop because drainQueue respects the concurrency limit — as running // agents finish they start queued ones, which need awaiting too. while (true) { this.drainQueue(); const pending = [...this.agents.values()] .filter(r => r.status === "running" || r.status === "queued") .map(r => r.promise) .filter(Boolean); if (pending.length === 0) break; await Promise.allSettled(pending); } } async dispose(): Promise { clearInterval(this.cleanupInterval); // Clear queue this.queue = []; const sessions = [...this.agents.values()].map(record => record.session); this.agents.clear(); // Awaited, unlike the eviction path: pi awaits this extension's `session_shutdown` // handler and the process exits right after it returns, so anything left unawaited // here never runs at all. Bounded — each call carries its own ceiling, concurrently. await Promise.all(sessions.map(session => shutdownChildSession(session))); // Prune any orphaned git worktrees (crash recovery) try { pruneWorktrees(process.cwd()); } catch { /* ignore */ } // Also prune repos that caller-supplied cwds created worktrees in — a clean // exit with in-flight agents would otherwise leave stale registrations there. for (const repo of this.worktreeRepos) { try { pruneWorktrees(repo); } catch { /* ignore */ } } } }