@nredd/pi-subagents 0.0.0-stage → 0.22.0-nredd.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1444 -2
  3. package/dist/chunks/chunk-MSGGFB5Q.ts +6755 -0
  4. package/dist/chunks/chunk-MSGGFB5Q.ts.map +7 -0
  5. package/dist/chunks/conversation-viewer-A7AMRDVY.ts +15 -0
  6. package/dist/chunks/conversation-viewer-A7AMRDVY.ts.map +7 -0
  7. package/dist/index.ts +9874 -0
  8. package/dist/index.ts.map +7 -0
  9. package/package.json +82 -4
  10. package/src/abortable.ts +43 -0
  11. package/src/agent-color.ts +161 -0
  12. package/src/agent-file-toggle.ts +298 -0
  13. package/src/agent-manager.ts +2431 -0
  14. package/src/agent-runner.ts +1703 -0
  15. package/src/agent-source-badge.ts +75 -0
  16. package/src/agent-types.ts +346 -0
  17. package/src/child-context.ts +33 -0
  18. package/src/clock.ts +25 -0
  19. package/src/completion-outbox.ts +144 -0
  20. package/src/context.ts +58 -0
  21. package/src/cross-extension-rpc.ts +200 -0
  22. package/src/custom-agents.ts +441 -0
  23. package/src/default-agents.ts +126 -0
  24. package/src/detached-results.ts +150 -0
  25. package/src/enabled-models.ts +181 -0
  26. package/src/enterprise-local.ts +67 -0
  27. package/src/env.ts +33 -0
  28. package/src/error-message.ts +11 -0
  29. package/src/git-path.ts +66 -0
  30. package/src/group-join.ts +142 -0
  31. package/src/home-paths.ts +27 -0
  32. package/src/index.ts +5047 -0
  33. package/src/invocation-config.ts +158 -0
  34. package/src/memory.ts +179 -0
  35. package/src/mention-clone.ts +200 -0
  36. package/src/mention.ts +141 -0
  37. package/src/model-call.ts +83 -0
  38. package/src/model-resolver.ts +333 -0
  39. package/src/model-scope.ts +71 -0
  40. package/src/model-selection.ts +170 -0
  41. package/src/model-tiers.ts +176 -0
  42. package/src/nested-tools.ts +512 -0
  43. package/src/notification-result.ts +118 -0
  44. package/src/output-file.ts +186 -0
  45. package/src/package-resources.ts +639 -0
  46. package/src/plan-mode-children.ts +176 -0
  47. package/src/process-budget.ts +346 -0
  48. package/src/prompts.ts +161 -0
  49. package/src/quota-admission.ts +121 -0
  50. package/src/resource-loader.ts +65 -0
  51. package/src/schedule-store.ts +153 -0
  52. package/src/schedule.ts +514 -0
  53. package/src/settings.ts +851 -0
  54. package/src/skill-loader.ts +288 -0
  55. package/src/stale-ctx.ts +16 -0
  56. package/src/startup-error.ts +29 -0
  57. package/src/status-note.ts +113 -0
  58. package/src/structured-output.ts +130 -0
  59. package/src/types.ts +487 -0
  60. package/src/ui/agent-mention.ts +219 -0
  61. package/src/ui/agent-row.ts +245 -0
  62. package/src/ui/agent-widget.ts +722 -0
  63. package/src/ui/conversation-viewer.ts +837 -0
  64. package/src/ui/fallback-flag.ts +114 -0
  65. package/src/ui/fleet-list.ts +1020 -0
  66. package/src/ui/fleet-state.ts +154 -0
  67. package/src/ui/notification-view.ts +139 -0
  68. package/src/ui/schedule-menu.ts +105 -0
  69. package/src/ui/select-item.ts +45 -0
  70. package/src/ui/viewer-keys.ts +39 -0
  71. package/src/ui/workflow-card.ts +459 -0
  72. package/src/ui/workflow-dialog.ts +1138 -0
  73. package/src/ui/workflow-menu.ts +193 -0
  74. package/src/usage.ts +167 -0
  75. package/src/workflow/collisions.ts +123 -0
  76. package/src/workflow/entry.ts +47 -0
  77. package/src/workflow/host.ts +398 -0
  78. package/src/workflow/journal.ts +176 -0
  79. package/src/workflow/json-schema.ts +129 -0
  80. package/src/workflow/meta.ts +329 -0
  81. package/src/workflow/progress.ts +554 -0
  82. package/src/workflow/runtime.ts +1249 -0
  83. package/src/workflow/saved.ts +259 -0
  84. package/src/workflow/task.ts +317 -0
  85. package/src/workflow/tool-description.ts +200 -0
  86. package/src/workflow/worker-source.ts +903 -0
  87. package/src/worktree.ts +520 -0
  88. package/src/xml.ts +13 -0
@@ -0,0 +1,2431 @@
1
+ /**
2
+ * agent-manager.ts — Tracks agents, background execution, resume support.
3
+ *
4
+ * There are two independent concurrency pools, never one:
5
+ *
6
+ * - Background (`maxConcurrent`, default 10) bounds detached agents.
7
+ * - Foreground (`maxConcurrentForeground`, default 0 = unlimited) bounds
8
+ * agents a caller is blocking on inline — `spawnAndWait`.
9
+ *
10
+ * Independent by design: a foreground agent blocks the parent anyway, so
11
+ * charging it to the background pool would let a saturated pool starve the main
12
+ * session of work it could have done itself. Excess agents in either pool are
13
+ * queued and auto-started as slots free up. Nested children take no slot in
14
+ * either — see `occupiesPoolSlot` / `occupiesForegroundSlot`.
15
+ *
16
+ * Both pools are per manager. Above them sits the process-wide budget
17
+ * (`process-budget.ts`, `maxActiveSubagents`), which EVERY run takes a lease
18
+ * from — nested, workflow, scheduled, RPC, and resumes included — across every
19
+ * manager in the process. A start needs room in its pool (if it has one) and a
20
+ * lease; whichever is missing, it waits in `queue`.
21
+ */
22
+
23
+ import { randomUUID } from "node:crypto";
24
+ import { accessSync, constants, opendirSync, statSync } from "node:fs";
25
+ import { isAbsolute } from "node:path";
26
+ import type { Model } from "@earendil-works/pi-ai";
27
+ import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
28
+ import { type AssistantMessageInfo, resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
29
+ import { getAgentConfig } from "./agent-types.js";
30
+ import { type Clock, realClock, type TimerHandle } from "./clock.js";
31
+ import { errorMessage } from "./error-message.js";
32
+ import { assignHandle, handleBase } from "./mention.js";
33
+ import type { ModelCallEvent } from "./model-call.js";
34
+ import { describeModel, describePhysicalModel, describeVirtualModel, isVirtualModel, type ModelFallback } from "./model-resolver.js";
35
+ import { admitModelSelection, describeSelection, type ModelSelection, modelSelectionNote, selectModel } from "./model-selection.js";
36
+ import { type TierDisclosure, tierDisclosure } from "./model-tiers.js";
37
+ import { appendModelFallbackEntry, appendModelTierEntry } from "./output-file.js";
38
+ import { type PlanToolPolicy, queryPlanPolicy } from "./plan-mode-children.js";
39
+ import {
40
+ assertWithinBudgetDepth,
41
+ BUDGET_QUEUE_FULL_ERROR,
42
+ type BudgetLease,
43
+ budgetHasPendingRoom,
44
+ enqueueBudget,
45
+ pumpBudget,
46
+ releaseBudget,
47
+ tryAcquireBudget,
48
+ } from "./process-budget.js";
49
+ import { describeBlock } from "./quota-admission.js";
50
+ import { isStaleCtxError } from "./stale-ctx.js";
51
+ import type { AgentInvocation, AgentRecord, AgentTombstone, IsolationMode, MentionResolution, Pool, SubagentType, ThinkingLevel } from "./types.js";
52
+ import { addUsage, type LifetimeUsage } from "./usage.js";
53
+ import type { CompiledSchema } from "./workflow/json-schema.js";
54
+ import { cleanupWorktree, createWorktree, isWorktreeIsolationEnabled, pruneWorktrees, type WorktreeCleanupResult, type WorktreeInfo } from "./worktree.js";
55
+
56
+ /**
57
+ * Relabel a record's model from the response that just finished. A virtual
58
+ * selection (`router/default`) is only a name until a request is routed; the
59
+ * assistant message is the one place that records the physical model, so the
60
+ * label follows it (and follows a router fallback to the next model).
61
+ */
62
+ function relabelFromResponse(record: AgentRecord, message: AssistantMessageInfo | undefined): void {
63
+ const registry = (record.session as { modelRegistry?: Parameters<typeof describePhysicalModel>[1] } | undefined)
64
+ ?.modelRegistry;
65
+ const physical = describePhysicalModel(message, registry);
66
+ if (!physical) return;
67
+ record.invocation ??= {};
68
+ Object.assign(record.invocation, physical);
69
+ if ((record.modelTier?.kind === "routed" || record.modelTier?.kind === "static") && record.modelTier.answered !== physical.modelId) {
70
+ record.modelTier = { ...record.modelTier, answered: physical.modelId };
71
+ if (record.outputFile) appendModelTierEntry(record.outputFile, record.id, record.modelTier);
72
+ }
73
+ }
74
+
75
+ /** Keep a cleanup failure visible on every result, including errors and stopped workflows. */
76
+ function recordWorktreeCleanup(record: AgentRecord, result: WorktreeCleanupResult): void {
77
+ record.worktreeResult = result;
78
+ if (!result.error) return;
79
+ const note = `Worktree cleanup failed: ${result.error}. Worktree retained at \`${result.path}\`${result.branch ? `; branch \`${result.branch}\` was created` : ""}. Inspect and recover it before removing it.`;
80
+ record.error = record.error ? `${record.error}\n${note}` : note;
81
+ record.result = record.result ? `${record.result}\n\n${note}` : note;
82
+ if (record.status !== "stopped") record.status = "error";
83
+ }
84
+
85
+ export type OnAgentComplete = (record: AgentRecord) => void;
86
+ export type OnAgentStart = (record: AgentRecord) => void;
87
+ /**
88
+ * Fired when `abort`, `detach` or `abortAll` stops a record that was still queued. Such a record
89
+ * never reaches `onComplete` (it never ran, so there is no result to announce), but a listener
90
+ * that heard it spawn needs to learn it ended.
91
+ */
92
+ export type OnAgentQueuedStop = (record: AgentRecord) => void;
93
+ /** Fired when the retention sweep evicts a top-level agent whose result was never read (#330). */
94
+ export type OnAgentReaped = (note: EvictedNote) => void;
95
+ /**
96
+ * Fired by `steer()` once a message is delivered or queued. Every steer path
97
+ * goes through it except the steer tools (`steer_subagent` in index.ts, and its
98
+ * nested twin in nested-tools.ts), which await the session directly.
99
+ */
100
+ export type OnAgentSteered = (record: AgentRecord, message: string) => void;
101
+ export type OnAgentCompact = (record: AgentRecord, info: CompactionInfo) => void;
102
+ /**
103
+ * Fired once per assistant `message_end`, for EVERY agent this manager owns —
104
+ * top-level and nested alike, spawns and resumes. The one place where each
105
+ * message is seen exactly once: `AgentRecord.lifetimeUsage` is deliberately
106
+ * double-booked into ancestors (see `nested-tools.ts`) so a hidden child's spend
107
+ * shows up on the record a human can see, which makes those records useless as
108
+ * a basis for anything that must not count a message twice — parent-session
109
+ * accounting above all.
110
+ */
111
+ export type OnAgentUsage = (record: AgentRecord, usage: LifetimeUsage) => void;
112
+ export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; tokensBefore: number };
113
+
114
+ /**
115
+ * Default max concurrent background agents.
116
+ *
117
+ * Raised from 4 when top-level spawns started defaulting to background
118
+ * (`backgroundByDefault`): foreground agents bypass this pool entirely, so
119
+ * while foreground was the default a fan-out of six ran six. With background
120
+ * as the default every top-level agent takes a slot, and a limit of 4 would
121
+ * have silently queued the tail of exactly the parallel fan-outs the `Agent`
122
+ * tool description tells the model to send.
123
+ */
124
+ const DEFAULT_MAX_CONCURRENT = 10;
125
+
126
+ /**
127
+ * Default max concurrent foreground (blocking) agents — `0` = unlimited, the
128
+ * extension's existing convention for "no ceiling" (`defaultMaxTurns`).
129
+ *
130
+ * Off by default because nothing here ever bounded foreground work, and pi
131
+ * dispatches a message's tool calls through `Promise.all`, so an unqualified
132
+ * fan-out of blocking `Agent` calls has always run all at once. Users who want
133
+ * it bounded — chiefly local models, where parallel agents thrash the prompt
134
+ * cache (#253) — opt in; everyone else keeps today's behaviour exactly.
135
+ */
136
+ const DEFAULT_MAX_CONCURRENT_FOREGROUND = 0;
137
+
138
+ /**
139
+ * How long a finished record stays in memory once its result was consumed
140
+ * (fetched, or delivered inline to a blocking caller). Nothing is waiting on it
141
+ * any more; the window only covers a follow-up `get_subagent_result` or steer.
142
+ */
143
+ const CONSUMED_RECORD_TTL_MS = 10 * 60_000;
144
+
145
+ /**
146
+ * How long a finished record nobody has read stays in memory, measured from
147
+ * completion. Far longer than the consumed window: an orchestrator that was busy
148
+ * when the notice arrived still gets to fetch the full result an hour later.
149
+ */
150
+ const UNCONSUMED_RECORD_TTL_MS = 60 * 60_000;
151
+
152
+ /**
153
+ * Read a retention override in minutes (fractions and 0 allowed) from `name`,
154
+ * else `fallbackMs`. An env var rather than a setting: the windows are not a
155
+ * user knob, but eviction is otherwise unobservable in an end-to-end test,
156
+ * which cannot wait an hour. Invalid values warn and keep the default.
157
+ */
158
+ function retentionMs(name: string, fallbackMs: number): number {
159
+ const raw = process.env[name]?.trim();
160
+ if (!raw) return fallbackMs;
161
+ const minutes = Number(raw);
162
+ if (!Number.isFinite(minutes) || minutes < 0) {
163
+ console.warn(`[pi-subagents] ignoring ${name}="${raw}": expected minutes >= 0`);
164
+ return fallbackMs;
165
+ }
166
+ return minutes * 60_000;
167
+ }
168
+
169
+ /** Sweep cadence: every minute, or as often as the shortest window needs, never under a second. */
170
+ const MAX_SWEEP_INTERVAL_MS = 60_000;
171
+ const MIN_SWEEP_INTERVAL_MS = 1_000;
172
+
173
+ /** How many evicted ids keep an answer to "what happened to that agent". */
174
+ const MAX_EVICTED_NOTES = 500;
175
+
176
+ /** Mark a record's result as read, which shortens its retention and suppresses its notice. */
177
+ export function markConsumed(record: Pick<AgentRecord, "resultConsumed" | "consumedAt">, now = Date.now()): void {
178
+ record.resultConsumed = true;
179
+ record.consumedAt = now;
180
+ }
181
+
182
+ /**
183
+ * What survives a record's eviction, so a later `get_subagent_result` or steer
184
+ * can say what became of the agent instead of "not found".
185
+ */
186
+ export interface EvictedNote {
187
+ id: string;
188
+ type: SubagentType;
189
+ description: string;
190
+ status: AgentRecord["status"];
191
+ /** Whole minutes between completion and eviction. */
192
+ evictedAfterMin: number;
193
+ /** The `.output` transcript when one was written, else the session file. */
194
+ transcript?: string;
195
+ /** The run's model fallback, so even an evicted agent's id still discloses it. */
196
+ modelFallback?: ModelFallback;
197
+ modelTier?: TierDisclosure;
198
+ /** Set when a saved session exists but its name now belongs to another agent, so it was not kept reopenable. */
199
+ nameTaken?: string;
200
+ }
201
+
202
+ /** The text a tool returns for an id whose record was evicted. */
203
+ export function describeEvicted(note: EvictedNote): string {
204
+ const where = note.transcript ? `; transcript: ${note.transcript}` : "";
205
+ const disclosure = modelSelectionNote(note);
206
+ const fallback = disclosure ? `\n${disclosure}` : "";
207
+ return `Agent "${note.id}" (${note.description}) ${note.status}, evicted after ${note.evictedAfterMin} min${where}${fallback}`;
208
+ }
209
+
210
+ /**
211
+ * How many evicted agents stay addressable by name. Only a bound on memory —
212
+ * a session that spawns hundreds of agents shouldn't retain every one — and
213
+ * far above the handful anyone keeps in their head.
214
+ */
215
+ const MAX_TOMBSTONES = 100;
216
+
217
+ /**
218
+ * Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset"
219
+ * (parent cwd). Anything else must be an absolute path to an existing
220
+ * directory — curated errors instead of TypeErrors from path/fs internals
221
+ * (RPC callers send arbitrary JSON: null, numbers, file paths).
222
+ */
223
+ function assertValidSpawnCwd(cwd: unknown): asserts cwd is string | undefined | null {
224
+ if (cwd == null) return;
225
+ if (typeof cwd !== "string" || !isAbsolute(cwd)) {
226
+ throw new Error(`SpawnOptions.cwd must be an absolute path: "${String(cwd)}"`);
227
+ }
228
+ let isDirectory = false;
229
+ try {
230
+ isDirectory = statSync(cwd).isDirectory();
231
+ } catch {
232
+ throw new Error(`SpawnOptions.cwd does not exist: "${cwd}"`);
233
+ }
234
+ if (!isDirectory) {
235
+ throw new Error(`SpawnOptions.cwd is not a directory: "${cwd}"`);
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Fail fast when a working directory exists but cannot be opened (#436).
241
+ * `stat` succeeds on such a directory (POSIX `chmod 000`, or a macOS privacy
242
+ * denial of an external volume), so without this the child starts and every
243
+ * read fails with a bare EPERM/EACCES. Only those two codes are rejected: a
244
+ * missing directory stays the caller's concern (`assertValidSpawnCwd`).
245
+ */
246
+ function assertReadableCwd(cwd: string, label: string): void {
247
+ try {
248
+ opendirSync(cwd).closeSync();
249
+ // opendir needs only the read bit; children also need search (x) to reach anything inside.
250
+ accessSync(cwd, constants.R_OK | constants.X_OK);
251
+ } catch (err) {
252
+ const code = (err as NodeJS.ErrnoException).code;
253
+ if (code !== "EACCES" && code !== "EPERM") return;
254
+ throw new Error(
255
+ `${label} is not readable (${code}): "${cwd}". Check its permissions; on macOS also check Privacy & Security -> Files & Folders / Full Disk Access for the terminal app (external volumes need it).`,
256
+ );
257
+ }
258
+ }
259
+
260
+ /** Validate the cwd a child will run in: caller-supplied `SpawnOptions.cwd`, else the session cwd. */
261
+ function assertSpawnCwd(cwd: unknown, sessionCwd: string): void {
262
+ assertValidSpawnCwd(cwd);
263
+ if (cwd == null) assertReadableCwd(sessionCwd, "The session cwd");
264
+ else assertReadableCwd(cwd, "SpawnOptions.cwd");
265
+ }
266
+
267
+ /** The session's own pending agents, split by whether their completion wakes the parent. */
268
+ export interface OwnedPending {
269
+ /** Background agents (Agent tool, `@handle`, scheduler, background resume) whose completion notice starts a parent turn. */
270
+ notifying: number;
271
+ /** Foreground agents and RPC spawns without `isBackground`, whose settle notifies nobody. */
272
+ silent: number;
273
+ }
274
+
275
+ /** A record whose run is over. Every settle and stop stamps `completedAt`; only a (re)start clears it. */
276
+ type SettledRecord = AgentRecord & { completedAt: number };
277
+
278
+ /** Whether `record` is past its run (neither running nor queued), and so carries `completedAt`. */
279
+ function isSettled(record: AgentRecord): record is SettledRecord {
280
+ return record.status !== "running" && record.status !== "queued";
281
+ }
282
+
283
+ /**
284
+ * Whether a record occupies one of the `maxConcurrent` background slots.
285
+ * Nested children don't: their parent already holds a slot, so counting (and
286
+ * therefore queueing) them would deadlock a parent that waits on its own child.
287
+ *
288
+ * Note this bounds nothing horizontally — the depth cap limits how DEEP nesting
289
+ * goes, not how WIDE. A parent's only limit on concurrent children is that each
290
+ * spawn costs it a turn, which is unbounded when max turns is unlimited.
291
+ */
292
+ function occupiesPoolSlot(
293
+ record: Pick<AgentRecord, "isBackground" | "parentAgentId" | "workflowId">,
294
+ ): boolean {
295
+ return !!record.isBackground && isTopLevelAgent(record);
296
+ }
297
+
298
+ /**
299
+ * Whether a record is one of the session's own agents, rather than something
300
+ * another agent or a workflow owns.
301
+ *
302
+ * The single definition behind every user-facing surface — the fleet list, the
303
+ * widget, the `/agents` menus, `@handle` resolution, and the completion events
304
+ * and session entries. An owned child reports through its owner, so surfacing
305
+ * it separately would double-count the same work in the places a person reads.
306
+ */
307
+ export function isTopLevelAgent(
308
+ record: Pick<AgentRecord, "parentAgentId" | "workflowId">,
309
+ ): boolean {
310
+ return record.parentAgentId === undefined && record.workflowId === undefined;
311
+ }
312
+
313
+ /**
314
+ * Whether a record occupies one of the `maxConcurrentForeground` slots.
315
+ *
316
+ * Keyed on `blocking` — a caller awaiting this record inline — rather than on
317
+ * `isBackground === false`, because `spawn()` is also the funnel for DETACHED
318
+ * starts (cross-extension RPC, `@handle` mentions, the registry) that may pass
319
+ * `isBackground: false` and are documented to run immediately regardless. Those
320
+ * block nobody, so bounding them buys nothing and would park a record with no
321
+ * one waiting to release it.
322
+ *
323
+ * Nested children are excluded for the same reason as `occupiesPoolSlot`, and
324
+ * more sharply: their parent is blocked *awaiting them*, so queueing a child
325
+ * behind its own parent is a guaranteed deadlock rather than a possible one.
326
+ * Enforced here rather than at the call site so no caller can reintroduce it.
327
+ *
328
+ * A workflow's children go out through `spawnAndWait` and so are `blocking`
329
+ * too, and are excluded on the same `isTopLevelAgent` test as the background
330
+ * pool: the run already caps how many of its agents run at once, and charging
331
+ * them here as well would let one fan-out queue behind a limit meant for the
332
+ * session's own work.
333
+ *
334
+ * Like the background pool this bounds width at the top level only — a parent's
335
+ * own fan-out is limited by nothing but its turn budget.
336
+ */
337
+ function occupiesForegroundSlot(
338
+ record: Pick<AgentRecord, "blocking" | "parentAgentId" | "workflowId">,
339
+ ): boolean {
340
+ return !!record.blocking && isTopLevelAgent(record);
341
+ }
342
+
343
+ /** A start waiting in `AgentManager.queue`; see there. */
344
+ interface QueueEntry {
345
+ id: string;
346
+ /** The queued record; it stays in `agents` while queued, since only settled records are evicted. */
347
+ record: AgentRecord;
348
+ /** The pool it needs room in, or undefined when it waits on the budget alone. */
349
+ pool?: Pool;
350
+ start: () => Promise<void>;
351
+ release: () => void;
352
+ /** Withdraw the budget waiter. A no-op once granted. */
353
+ cancel: () => void;
354
+ /** Resolves once the entry has left the queue, started (and settled its startup) or not. */
355
+ left: Promise<void>;
356
+ }
357
+
358
+ interface SpawnArgs {
359
+ pi: ExtensionAPI;
360
+ ctx: ExtensionContext;
361
+ type: SubagentType;
362
+ prompt: string;
363
+ options: SpawnOptions;
364
+ }
365
+
366
+ interface SpawnOptions {
367
+ /** Home inherited by nested callers; the manager's home is the default. */
368
+ homeDir?: string;
369
+ description: string;
370
+ /**
371
+ * Optional memorable name for this instance, becoming a second handle
372
+ * (`@auth-audit`) alongside the type-derived one. Slugged, not validated —
373
+ * anything unusable degrades via `handleBase` rather than failing the spawn.
374
+ */
375
+ name?: string;
376
+ /**
377
+ * Reopen this pi session file instead of starting a fresh conversation, so a
378
+ * mention of an evicted agent continues where it left off. The agent's
379
+ * definition is still resolved from its type, so the continuation runs under
380
+ * the type's CURRENT config.
381
+ */
382
+ resumeSessionFile?: string;
383
+ /**
384
+ * Take an evicted agent's names back verbatim instead of allocating fresh
385
+ * ones, so a resumed conversation keeps the handle the user just typed —
386
+ * `handleBase(type)` cannot reproduce a numbered `explore-2`. Safe without an
387
+ * `assignHandle` pass because tombstoned names are excluded from allocation
388
+ * (`takenHandles`), so nothing live can be holding them.
389
+ *
390
+ * Internal capability, like `resumeSessionFile`: a forged handle would
391
+ * duplicate a live agent's name and make `resolveMention` ambiguous, so
392
+ * `spawnTopLevel` strips it from anything a caller sends.
393
+ */
394
+ reclaim?: { handle: string; alias?: string };
395
+ model?: Model<any> | string;
396
+ /** Internal preflight outcome, stored on the record without resolving again. */
397
+ modelSelection?: ModelSelection;
398
+ /** Historical outcome only, never used to label the continuation. */
399
+ previousModelTier?: TierDisclosure;
400
+ /** Historical fallback only, never used to label the continuation. */
401
+ previousModelFallback?: ModelFallback;
402
+ /** Internal, like `reclaim`: the evicted id a reopen continues. */
403
+ previousId?: string;
404
+
405
+ maxTurns?: number;
406
+ isolated?: boolean;
407
+ inheritContext?: boolean;
408
+ thinkingLevel?: ThinkingLevel;
409
+ isBackground?: boolean;
410
+ /**
411
+ * Skip whichever pool's queue check applies to this spawn — start immediately
412
+ * even if the configured concurrency limit would otherwise queue it. The slot
413
+ * is still COUNTED once the run starts, so a bypassing spawn transiently
414
+ * exceeds the limit rather than being invisible to it.
415
+ *
416
+ * Used by the scheduler, so a fired job can't be deferred past its trigger
417
+ * window, and by the `/agents` agent-file generator, which has no way to
418
+ * cancel a wait (see its call site).
419
+ */
420
+ bypassQueue?: boolean;
421
+ /**
422
+ * A caller is awaiting this record inline (`spawnAndWait`) — what
423
+ * `maxConcurrentForeground` bounds. Set only by `spawnAndWait`; stripped from
424
+ * caller-supplied options by `spawnTopLevel`, since a forged `blocking` would
425
+ * defer a detached start behind a queue its caller cannot see or release.
426
+ */
427
+ blocking?: boolean;
428
+ /**
429
+ * The workflow run this child belongs to, when a workflow spawned it.
430
+ *
431
+ * Ownership, not decoration. A workflow's children are the workflow's — they
432
+ * report through its card, its notification and its dialog, so they are
433
+ * filtered out of every top-level surface exactly as nested children are, and
434
+ * they take no `maxConcurrent` slot: the run has its own concurrency cap, and
435
+ * counting them twice would let one workflow starve the whole session.
436
+ */
437
+ workflowId?: string;
438
+ /**
439
+ * Make the child report through a `StructuredOutput` tool built from this
440
+ * compiled schema. Set only by the workflow host, for `agent({ schema })`.
441
+ */
442
+ structuredOutput?: CompiledSchema;
443
+ /** Isolation mode — "worktree" creates a temp git worktree for the agent. */
444
+ isolation?: IsolationMode;
445
+ /**
446
+ * Working directory for the agent (absolute path). Default: parent session
447
+ * cwd. The agent's tools operate here, but .pi config (extensions, skills,
448
+ * settings, memory) still loads from the parent session's project — the
449
+ * target directory's `.pi` extensions never execute. With isolation:
450
+ * "worktree", the worktree is created FROM this directory and the result
451
+ * branch lands in that repo.
452
+ */
453
+ cwd?: string;
454
+ /**
455
+ * Last chance to look at an isolated agent's worktree, awaited immediately
456
+ * before it is committed to a branch and removed.
457
+ *
458
+ * Exists because that removal happens inside the settle path, before
459
+ * `spawnAndWait` resolves: by the time a caller has the finished record, the
460
+ * directory the child actually wrote in is gone. Anything that must inspect
461
+ * or verify that tree — a workflow `gate` is the motivating case — has to run
462
+ * here or it silently inspects the main tree instead.
463
+ *
464
+ * Fires only on the normal settle path, and only when a worktree was created.
465
+ * Not on the error path and not on the stop-during-copy guard: those are
466
+ * already failing, and delaying cleanup there would leak a copy for no gain.
467
+ * A rejection is swallowed — the hook can never keep the worktree alive.
468
+ */
469
+ onBeforeWorktreeCleanup?: (worktreePath: string) => Promise<void>;
470
+ /** Resolved invocation snapshot captured for UI display. */
471
+ invocation?: AgentInvocation;
472
+ /** Parent abort signal — when aborted, the subagent is also stopped. */
473
+ signal?: AbortSignal;
474
+ /** Prepare the record before it can be queued or started. A throw rejects the spawn. */
475
+ onPrepare?: (id: string) => void;
476
+ /**
477
+ * Called synchronously once the record is in the map and its promise is set,
478
+ * before `onSessionCreated` fires.
479
+ *
480
+ * Carried on the options rather than parked on the manager for the duration
481
+ * of a spawn: with a foreground queue, `startAgent` can run at drain time,
482
+ * long after any such field would have been restored, and the callback would
483
+ * silently never fire (or fire into an unrelated caller's closure).
484
+ */
485
+ onSpawned?: (id: string) => void;
486
+ /**
487
+ * Called synchronously when the spawn is queued instead of started, with how
488
+ * many entries in its own pool are ahead of it. The foreground UI uses it to
489
+ * say so while it waits; nothing else needs it.
490
+ */
491
+ onQueued?: (id: string, ahead: number) => void;
492
+ /** Called on tool start/end with activity info (for streaming progress to UI). */
493
+ onToolActivity?: (activity: ToolActivity) => void;
494
+ /** Called on streaming text deltas from the assistant response. */
495
+ onTextDelta?: (delta: string, fullText: string) => void;
496
+ /** Called for every streaming event of each model call (for the in-flight activity line). */
497
+ onModelCall?: (event: ModelCallEvent) => void;
498
+ /** Called when the agent session is created (for accessing session stats). */
499
+ onSessionCreated?: (session: AgentSession) => void;
500
+ /** Called at the end of each agentic turn with the cumulative count. */
501
+ onTurnEnd?: (turnCount: number) => void;
502
+ /** Called once per assistant message_end with that message's usage delta. */
503
+ onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
504
+ /** Called when the session successfully compacts. */
505
+ onCompaction?: (info: CompactionInfo) => void;
506
+ /** Nesting depth: top-level subagent = 1. */
507
+ depth?: number;
508
+ /** Parent agent ID for ownership-scoped nested controls. */
509
+ parentAgentId?: string;
510
+ /** Effective inherited nesting cap for this branch. */
511
+ maxSubagentDepth?: number;
512
+ /** Config-discovery root inherited by nested launches when it differs from the working directory. */
513
+ configCwd?: string;
514
+ /** Project-trust decision for `configCwd`; only meaningful paired with it, set by nested launches. */
515
+ configProjectTrusted?: boolean;
516
+ /** Root session id, inherited by nested launches so transcripts stay grouped. */
517
+ rootSessionId?: string;
518
+ }
519
+
520
+ interface ResumeOptions {
521
+ /**
522
+ * Run the resumed turn detached in the background: return immediately with
523
+ * the record still "running" (or "queued" at the concurrency limit) and
524
+ * notify on completion via onComplete, exactly like a background spawn.
525
+ * Default (false/undefined) runs the resume inline and returns the settled
526
+ * record — the historical behavior.
527
+ */
528
+ isBackground?: boolean;
529
+ /**
530
+ * Explicit turn limit for the resumed run: this, else the limit the agent
531
+ * was spawned with, else its type's own `max_turns`, else the default —
532
+ * counted afresh from the resume prompt (#368).
533
+ */
534
+ maxTurns?: number;
535
+ /** Called on streaming text and model-call changes during this resume. */
536
+ onTextDelta?: (delta: string, fullText: string) => void;
537
+ onModelCall?: (event: ModelCallEvent) => void;
538
+ /** Called at the end of each resumed turn with the count since the resume. */
539
+ onTurnEnd?: (turnCount: number) => void;
540
+ /** Called on tool start/end with activity info (for streaming progress to UI). */
541
+ onToolActivity?: (activity: ToolActivity) => void;
542
+ /** Called once per assistant message_end with that message's usage delta. */
543
+ onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
544
+ /** Called when the session successfully compacts. */
545
+ onCompaction?: (info: CompactionInfo) => void;
546
+ /**
547
+ * Background resume only: called synchronously when the run actually starts —
548
+ * immediately, or later from drainQueue. Callers wire per-run side effects
549
+ * (output-file streaming) here rather than at the call site, so a resume that
550
+ * is stopped while still queued never leaves a subscription behind: `abort()`
551
+ * drops a queued record without reaching `settle()`, which is what would have
552
+ * torn that subscription down.
553
+ */
554
+ onStarted?: () => void;
555
+ }
556
+
557
+ /** Best-effort ceiling on one child's shutdown handlers, so teardown can't strand a quit. */
558
+ const CHILD_SHUTDOWN_TIMEOUT_MS = 3_000;
559
+
560
+ /**
561
+ * Close the extension lifecycle `runAgent` opened with `bindExtensions`, then dispose.
562
+ *
563
+ * `AgentSession.dispose()` only calls `ExtensionRunner.invalidate()` — pi emits the event
564
+ * itself in `AgentSessionRuntime.dispose()` beforehand, and this is the one place that binds
565
+ * extensions onto a session without going through that path. Without the emit, everything an
566
+ * extension armed in `session_start` leaks once per spawn, and its next tick throws
567
+ * `assertActive()` from a bare timer callback — an uncaughtException that kills pi (#242).
568
+ */
569
+ async function shutdownChildSession(session: AgentSession | undefined): Promise<void> {
570
+ let deadline: ReturnType<typeof setTimeout> | undefined;
571
+ try {
572
+ const runner = session?.extensionRunner;
573
+ // Optional all the way down: on a pi without the getter, or a stubbed session from a
574
+ // partial `onSessionCreated`, skip the emit — the same degrade as before this fix.
575
+ if (runner?.hasHandlers?.("session_shutdown")) {
576
+ // Raced, not awaited outright. `emit` runs every handler serially with no timeout of
577
+ // its own, and dispose() is reached from pi's own `session_shutdown` with the TUI
578
+ // already torn down — one hung handler would leave a dead terminal.
579
+ await Promise.race([
580
+ runner.emit({ type: "session_shutdown", reason: "quit" }),
581
+ new Promise<void>(resolve => {
582
+ deadline = setTimeout(resolve, CHILD_SHUTDOWN_TIMEOUT_MS);
583
+ deadline.unref();
584
+ }),
585
+ ]);
586
+ }
587
+ } catch { /* a partial session must degrade, not take the teardown down with it */ }
588
+ finally {
589
+ // Promise.race does not cancel its loser, even when a handler rejects.
590
+ if (deadline !== undefined) clearTimeout(deadline);
591
+ }
592
+ // Always, even on timeout: disposal is what this function ultimately exists to do.
593
+ try { session?.dispose?.(); } catch { /* ignore */ }
594
+ }
595
+
596
+ /**
597
+ * Wrap an observer the child's own agent loop calls (usage, compaction) so a
598
+ * throw cannot reach that loop. pi records a throw from a session listener as
599
+ * the turn's failure, so an observer closed over a replaced session's `pi`
600
+ * ("This extension ctx is stale ...") used to end a detached child with an
601
+ * empty error turn and "No output" (#145). That failure is expected after a
602
+ * reload and dropped silently. Any other throw is a real observer defect: it
603
+ * is logged once per observer and also kept from killing the run.
604
+ */
605
+ function guardObserver<A extends unknown[]>(name: string, observer: ((...args: A) => void) | undefined): ((...args: A) => void) | undefined {
606
+ if (!observer) return undefined;
607
+ let logged = false;
608
+ return (...args: A) => {
609
+ try {
610
+ observer(...args);
611
+ } catch (error) {
612
+ if (isStaleCtxError(error) || logged) return;
613
+ logged = true;
614
+ console.error(`pi-subagents: ${name} observer failed; further failures are not logged`, error);
615
+ }
616
+ };
617
+ }
618
+
619
+ export class AgentManager {
620
+ private agents = new Map<string, AgentRecord>();
621
+ private cleanupInterval: TimerHandle;
622
+ /** `CONSUMED_RECORD_TTL_MS` unless `PI_SUBAGENTS_CONSUMED_TTL_MIN` overrides it. */
623
+ private readonly consumedTtlMs = retentionMs("PI_SUBAGENTS_CONSUMED_TTL_MIN", CONSUMED_RECORD_TTL_MS);
624
+ /** `UNCONSUMED_RECORD_TTL_MS` unless `PI_SUBAGENTS_UNREAD_TTL_MIN` overrides it. */
625
+ private readonly unreadTtlMs = retentionMs("PI_SUBAGENTS_UNREAD_TTL_MIN", UNCONSUMED_RECORD_TTL_MS);
626
+ private onComplete?: OnAgentComplete;
627
+ private onStart?: OnAgentStart;
628
+ private onQueuedStop?: OnAgentQueuedStop;
629
+ private onReaped?: OnAgentReaped;
630
+ private onCompact?: OnAgentCompact;
631
+ private onUsage?: OnAgentUsage;
632
+ private onSteered?: OnAgentSteered;
633
+ private maxConcurrent: number;
634
+ private maxConcurrentForeground = DEFAULT_MAX_CONCURRENT_FOREGROUND;
635
+ /** Base repos worktrees were created from — so dispose() can prune them all,
636
+ * not just the parent repo (caller-supplied cwd can target other repos). */
637
+ private worktreeRepos = new Set<string>();
638
+
639
+ /**
640
+ * Startup phases, keyed by agent id. `spawn()` still returns synchronously,
641
+ * but an agent using worktree isolation is not running yet when it does —
642
+ * copying the repo is an awaited git call. This is what `awaitStartup` hands
643
+ * callers that must fail their tool call on a startup failure, and what
644
+ * `waitForAll` waits on while a record is "running" with no `promise` yet.
645
+ * Entries are dropped once the run is underway, and kept (rejected) after a
646
+ * startup failure so a late `awaitStartup` still sees it.
647
+ */
648
+ private startups = new Map<string, Promise<void>>();
649
+
650
+ /**
651
+ * Evicted agents that can still be reached by name, keyed by handle. Outlives
652
+ * the 10-minute record cleanup — that timer exists to bound memory, not to
653
+ * expire a conversation the user might still want — and is cleared alongside
654
+ * completed records on session start/switch.
655
+ */
656
+ private tombstones = new Map<string, AgentTombstone>();
657
+
658
+ /** Evicted records by id, oldest first (insertion order). */
659
+ private evictedNotes = new Map<string, EvictedNote>();
660
+ /** Evicted id -> the id its reopened conversation runs under, so the old id keeps resolving (#330). */
661
+ private successors = new Map<string, string>();
662
+
663
+ /**
664
+ * Agents waiting to start, tagged with the pool they wait on (none for a
665
+ * start that only waits on the process budget). Each entry is also a waiter
666
+ * in the process-wide budget queue, which is what actually starts it: the
667
+ * budget picks the earliest entry, across every manager, whose depth fits
668
+ * and whose own pool has room, so neither pool can head-of-line-block the
669
+ * other, and every removal path (`abort`, `abortAll`, `dispose`) stays a
670
+ * single filter.
671
+ *
672
+ * `release` wakes a caller blocked in `spawnAndWait`, and is fired once the
673
+ * entry's `start` has SETTLED rather than at drain time: startup is async
674
+ * now, so releasing earlier would wake the caller before `record.promise`
675
+ * exists and it would read a still-starting agent as one that never ran.
676
+ * Removing an entry from this array MUST release it — a queued record has no
677
+ * promise to await, and pi has no tool-execution timeout to bail the caller
678
+ * out.
679
+ */
680
+ private queue: QueueEntry[] = [];
681
+ /** Number of currently running background agents. */
682
+ private runningBackground = 0;
683
+ /** Number of currently running foreground (blocking) agents. */
684
+ private runningForeground = 0;
685
+
686
+ constructor(
687
+ onComplete?: OnAgentComplete,
688
+ maxConcurrent = DEFAULT_MAX_CONCURRENT,
689
+ onStart?: OnAgentStart,
690
+ onCompact?: OnAgentCompact,
691
+ onUsage?: OnAgentUsage,
692
+ onSteered?: OnAgentSteered,
693
+ private readonly clock: Clock = realClock,
694
+ private readonly homeDir?: string,
695
+ onQueuedStop?: OnAgentQueuedStop,
696
+ onReaped?: OnAgentReaped,
697
+ ) {
698
+ this.onComplete = onComplete;
699
+ this.onStart = onStart;
700
+ this.onQueuedStop = guardObserver("queued stop", onQueuedStop);
701
+ this.onReaped = guardObserver("reaped", onReaped);
702
+ this.onCompact = guardObserver("compaction", onCompact);
703
+ this.onUsage = guardObserver("usage", onUsage);
704
+ this.onSteered = onSteered;
705
+ this.maxConcurrent = maxConcurrent;
706
+ // Sweep finished records past their retention window (see cleanup()).
707
+ const sweepMs = Math.min(
708
+ MAX_SWEEP_INTERVAL_MS,
709
+ Math.max(MIN_SWEEP_INTERVAL_MS, Math.min(this.consumedTtlMs, this.unreadTtlMs)),
710
+ );
711
+ this.cleanupInterval = this.clock.setInterval(() => this.cleanup(), sweepMs);
712
+ this.cleanupInterval.unref?.();
713
+ }
714
+
715
+ /**
716
+ * Put a run under Plan mode's policy when its owning agent's run is (a nested
717
+ * child) or the parent session is planning right now (everything else, a
718
+ * workflow stage included). The gate object is kept, since the child's hooks
719
+ * hold it.
720
+ */
721
+ private decidePlanGate(record: AgentRecord, query: () => PlanToolPolicy | undefined): void {
722
+ const owner = record.parentAgentId === undefined ? undefined : this.agents.get(record.parentAgentId);
723
+ const policy = owner?.planGate?.policy ?? query();
724
+ record.planGate ??= {};
725
+ record.planGate.policy = policy;
726
+ if (policy || record.invocation?.planMode) {
727
+ record.invocation ??= {};
728
+ record.invocation.planMode = policy ? true : undefined;
729
+ }
730
+ }
731
+
732
+ /** Spawns and settles, in order: `spawnSeq` / `settleSeq` on lifecycle events. */
733
+ private lifecycleSequence = 0;
734
+
735
+ /** The next lifecycle sequence number; the host stamps `settleSeq` with it at settle. */
736
+ nextSequence(): number {
737
+ return ++this.lifecycleSequence;
738
+ }
739
+
740
+ /** Update the max concurrent background agents limit. */
741
+ setMaxConcurrent(n: number) {
742
+ this.maxConcurrent = Math.max(1, n);
743
+ // Start queued agents if the new limit allows
744
+ this.drainQueue();
745
+ }
746
+
747
+ getMaxConcurrent(): number {
748
+ return this.maxConcurrent;
749
+ }
750
+
751
+ /** Update the max concurrent foreground (blocking) agents limit. 0 = unlimited. */
752
+ setMaxConcurrentForeground(n: number) {
753
+ // Floor 0, not 1: unlimited is a meaningful value here and the default.
754
+ this.maxConcurrentForeground = Math.max(0, n);
755
+ // Start queued agents if the new limit allows — including everything, when
756
+ // the limit is cleared back to unlimited mid-run.
757
+ this.drainQueue();
758
+ }
759
+
760
+ getMaxConcurrentForeground(): number {
761
+ return this.maxConcurrentForeground;
762
+ }
763
+
764
+ /**
765
+ * Which pool a spawn is charged to, or undefined for one that is charged to
766
+ * neither (nested children, detached non-background spawns).
767
+ *
768
+ * Nothing here queues when the limit is unset — `poolHasRoom` reports an
769
+ * unlimited pool as always having room, so that alone is what keeps the
770
+ * default path identical. The `> 0` guard is belt and braces on top: it also
771
+ * keeps the counter from churning and the settle path from calling a drain
772
+ * that would find nothing to do. Both are unobservable, which is why no test
773
+ * pins them; the observable half — that the default start stays synchronous —
774
+ * is pinned in `test/foreground-concurrency.test.ts`.
775
+ */
776
+ private poolFor(record: AgentRecord): Pool | undefined {
777
+ if (occupiesPoolSlot(record)) return "background";
778
+ if (this.maxConcurrentForeground > 0 && occupiesForegroundSlot(record)) return "foreground";
779
+ return undefined;
780
+ }
781
+
782
+ private poolHasRoom(pool: Pool): boolean {
783
+ return pool === "background"
784
+ ? this.runningBackground < this.maxConcurrent
785
+ : this.maxConcurrentForeground === 0 || this.runningForeground < this.maxConcurrentForeground;
786
+ }
787
+
788
+ /**
789
+ * Spawn an agent and return its ID immediately (for background use).
790
+ * If the concurrency limit is reached, the agent is queued.
791
+ *
792
+ * The id comes back synchronously, but with `isolation: "worktree"` the agent
793
+ * is not running yet when it does — the repo copy is an awaited git call.
794
+ * Callers that must fail a tool call on a startup failure await
795
+ * `awaitStartup(id)`; everyone else sees it on the record (status "error").
796
+ */
797
+ spawn(
798
+ pi: ExtensionAPI,
799
+ ctx: ExtensionContext,
800
+ type: SubagentType,
801
+ prompt: string,
802
+ options: SpawnOptions,
803
+ ): string {
804
+ // Validate before the queue branch — a queued spawn should fail at the
805
+ // call, not minutes later at drain. Throw (not warn): programmatic callers
806
+ // can fix and retry; the RPC layer converts throws into error envelopes.
807
+ assertSpawnCwd(options.cwd, ctx.cwd);
808
+ const selection = options.modelSelection ?? selectModel({
809
+ homeDir: options.homeDir ?? this.homeDir,
810
+ agentType: type,
811
+ definitionModel: getAgentConfig(type)?.model,
812
+ paramModel: options.model,
813
+ parentModel: ctx.model,
814
+ registry: ctx.modelRegistry,
815
+ cwd: options.configCwd ?? ctx.cwd,
816
+ agentLabel: getAgentConfig(type)?.displayName,
817
+ });
818
+ if (typeof selection === "string") throw new Error(selection);
819
+ if (selection.scopeWarning && !options.modelSelection) ctx.ui.notify(selection.scopeWarning, "warning");
820
+ options = { ...options, model: selection.model, modelSelection: selection };
821
+
822
+ const id = randomUUID().slice(0, 17);
823
+ const abortController = new AbortController();
824
+ const record: AgentRecord = {
825
+ id,
826
+ type,
827
+ // Owned children — nested, or a workflow's — are filtered out of every
828
+ // top-level surface, so no handle: nothing can address them and they must
829
+ // not consume a name a top-level sibling could otherwise take.
830
+ handle: !isTopLevelAgent(options)
831
+ ? undefined
832
+ // A reclaimed handle is used as-is: it belongs to the conversation this
833
+ // spawn is reopening, and re-deriving it would lose the numbering.
834
+ : options.reclaim?.handle ?? assignHandle(handleBase(type), this.takenHandles()),
835
+ description: options.description,
836
+ // Reclaimed here, or filled in below from `name` — in which case it must
837
+ // see the handle this record just took, since both come out of the same
838
+ // namespace.
839
+ alias: isTopLevelAgent(options) ? options.reclaim?.alias : undefined,
840
+ // Overwritten below when the spawn is actually queued; a foreground spawn
841
+ // that queues flips to "queued" there rather than being guessed at here,
842
+ // since the pool decision needs the finished record.
843
+ status: options.isBackground ? "queued" : "running",
844
+ toolUses: 0,
845
+ startedAt: this.clock.now(),
846
+ abortController,
847
+ lifetimeUsage: { input: 0, output: 0, cacheWrite: 0, cost: 0 },
848
+ spawnSeq: this.nextSequence(),
849
+ compactionCount: 0,
850
+ // Raw tri-state (not coerced to a boolean): true = background, false =
851
+ // foreground (has an inline tool-result surface), undefined = caller never
852
+ // declared it (e.g. a cross-extension RPC spawn). The widget's background-
853
+ // only filter excludes only explicit `false`, so undefined agents — which
854
+ // have no inline surface — stay visible instead of vanishing.
855
+ isBackground: options.isBackground,
856
+ // Whether anyone is awaiting this agent is a property of the agent, not
857
+ // of the call that made it — and both settle paths need it long after
858
+ // `options` has stopped being the interesting object.
859
+ blocking: options.blocking,
860
+ invocation: options.invocation,
861
+ modelFallback: selection.fallback,
862
+ modelSelection: selection,
863
+ modelTier: selection.tier ? tierDisclosure(selection.tier) : undefined,
864
+ previousModelTier: options.previousModelTier,
865
+ previousModelFallback: options.previousModelFallback,
866
+ previousId: options.previousId,
867
+ depth: options.depth ?? 1,
868
+ parentAgentId: options.parentAgentId,
869
+ workflowId: options.workflowId,
870
+ maxSubagentDepth: options.maxSubagentDepth,
871
+ rootSessionId: options.rootSessionId,
872
+ };
873
+ this.decidePlanGate(record, () => queryPlanPolicy(pi.events));
874
+ record.planQuery = () => queryPlanPolicy(pi.events, "restrict");
875
+ this.agents.set(id, record);
876
+ // After the insert, so `takenHandles()` already counts this record's own
877
+ // handle — a spawn named after its own type gets `explore-2`, not a
878
+ // duplicate `explore` that would make resolution ambiguous.
879
+ if (record.handle !== undefined && record.alias === undefined && options.name !== undefined) {
880
+ record.nameBase = handleBase(options.name);
881
+ record.alias = assignHandle(record.nameBase, this.takenHandles());
882
+ }
883
+
884
+ const args: SpawnArgs = { pi, ctx, type, prompt, options };
885
+
886
+ const pool = this.poolFor(record);
887
+ // `bypassQueue` skips the pool, never the process budget: that ceiling is
888
+ // the one guarantee every path shares.
889
+ const waitPool = options.bypassQueue ? undefined : pool;
890
+ let lease: BudgetLease | undefined;
891
+ try {
892
+ lease = this.admit(record, waitPool);
893
+ } catch (err) {
894
+ this.agents.delete(id);
895
+ throw err;
896
+ }
897
+ try {
898
+ options.onPrepare?.(id);
899
+ } catch (err) {
900
+ this.agents.delete(id);
901
+ releaseBudget(lease);
902
+ this.drainQueue();
903
+ throw err;
904
+ }
905
+ if (lease === undefined) {
906
+ // Queue it — started once its pool has room and the process budget a
907
+ // lease, whichever was missing.
908
+ // Idempotent for background (already "queued"); the flip that matters is
909
+ // a blocking foreground spawn, optimistically marked "running" above.
910
+ record.status = "queued";
911
+ // A queued record never reaches startAgent's signal wiring, so arm the
912
+ // parent abort here or Esc could not release the position.
913
+ if (!this.armQueuedAbort(record, options.signal)) return id;
914
+ let release!: () => void;
915
+ record.startGate = new Promise<void>(resolve => { release = resolve; });
916
+ // Failure bookkeeping keys on whether an inline caller gets the throw,
917
+ // which is `blocking`, not on which pool (if any) it waited for.
918
+ const failAs: Pool = record.blocking ? "foreground" : "background";
919
+ this.enqueueStart(id, record, waitPool, () => this.launch(id, record, args, failAs), () => release());
920
+ options.onQueued?.(id, this.queue.filter(e => e.pool === waitPool).length - 1);
921
+ return id;
922
+ }
923
+
924
+ record.budgetLease = lease;
925
+ this.launch(id, record, args, undefined);
926
+ return id;
927
+ }
928
+
929
+ /**
930
+ * Wire a parent abort signal for a record that is about to be QUEUED.
931
+ * `startAgent` does this for running agents, and a queued record never gets
932
+ * there, so without this Esc could not release a queue position.
933
+ *
934
+ * Returns false when the signal is ALREADY aborted, in which case the record
935
+ * is stopped here and must not be enqueued: `addEventListener` never fires on
936
+ * an aborted signal, so a `spawnAndWait` on it would wait forever — pi has no
937
+ * tool-execution timeout to bail it out.
938
+ *
939
+ * The listener is removable (`record.detachParentAbort`): `startAgent`
940
+ * replaces it with its own, and `sendToBackground` must be able to take it
941
+ * off, or the caller that armed it could stop a run it no longer owns.
942
+ */
943
+ private armQueuedAbort(record: AgentRecord, signal?: AbortSignal): boolean {
944
+ if (signal === undefined) return true;
945
+ if (signal.aborted) {
946
+ record.status = "stopped";
947
+ record.completedAt = this.clock.now();
948
+ return false;
949
+ }
950
+ this.armParentAbort(record, signal);
951
+ return true;
952
+ }
953
+
954
+ /**
955
+ * Stop `record` when its caller's `signal` aborts, replacing any listener an
956
+ * earlier stage armed — at most one is live per record, so one call to
957
+ * `detachParentAbort` cuts the run loose from its caller.
958
+ */
959
+ private armParentAbort(record: AgentRecord, signal: AbortSignal | undefined): void {
960
+ this.detachParentAbort(record);
961
+ if (signal === undefined) return;
962
+ const onParentAbort = () => this.abort(record.id);
963
+ signal.addEventListener("abort", onParentAbort, { once: true });
964
+ record.detachParentAbort = () => signal.removeEventListener("abort", onParentAbort);
965
+ }
966
+
967
+ private detachParentAbort(record: AgentRecord): void {
968
+ record.detachParentAbort?.();
969
+ record.detachParentAbort = undefined;
970
+ }
971
+
972
+ /**
973
+ * The one admission check every start makes: a lease now when `pool` (if
974
+ * any) has room and the process budget admits `record`'s depth, else
975
+ * undefined and the caller queues. Throws, having changed nothing, when the
976
+ * start could never be admitted (deeper than the budget reserves for) or the
977
+ * budget's wait queue is full.
978
+ */
979
+ private admit(record: AgentRecord, pool: Pool | undefined): BudgetLease | undefined {
980
+ assertWithinBudgetDepth(record.depth);
981
+ const lease = pool === undefined || this.poolHasRoom(pool) ? tryAcquireBudget(record.depth) : undefined;
982
+ if (lease === undefined && !budgetHasPendingRoom()) throw new Error(BUDGET_QUEUE_FULL_ERROR);
983
+ return lease;
984
+ }
985
+
986
+ /** Give back `record`'s budget lease, if it holds one. Callers drain afterwards. */
987
+ private releaseLease(record: AgentRecord): void {
988
+ releaseBudget(record.budgetLease);
989
+ record.budgetLease = undefined;
990
+ }
991
+
992
+ /**
993
+ * Park a start in `queue` and in the process budget. The budget calls
994
+ * `grant` once the record's depth fits and `pool` (if any) has room; `start`
995
+ * then runs and `release` fires when it settles. A record that stopped being
996
+ * "queued" without being dequeued hands the lease straight back.
997
+ */
998
+ private enqueueStart(
999
+ id: string,
1000
+ record: AgentRecord,
1001
+ pool: Pool | undefined,
1002
+ start: () => Promise<void>,
1003
+ release: () => void,
1004
+ ): void {
1005
+ let markLeft!: () => void;
1006
+ const left = new Promise<void>(resolve => { markLeft = resolve; });
1007
+ const entry: QueueEntry = {
1008
+ id,
1009
+ record,
1010
+ pool,
1011
+ start,
1012
+ release: () => {
1013
+ release();
1014
+ markLeft();
1015
+ },
1016
+ // enqueueBudget only registers; grant cannot run until the next pump,
1017
+ // after this entry has been initialized and published in the queue.
1018
+ cancel: enqueueBudget({
1019
+ depth: record.depth,
1020
+ ready: () => pool === undefined || this.poolHasRoom(pool),
1021
+ grant: (lease) => {
1022
+ this.queue = this.queue.filter(e => e !== entry);
1023
+ if (this.agents.get(id) !== record || record.status !== "queued") {
1024
+ releaseBudget(lease);
1025
+ entry.release();
1026
+ return;
1027
+ }
1028
+ record.budgetLease = lease;
1029
+ // Detached, and never rejects: a late failure (e.g. strict worktree
1030
+ // isolation) lands on the record inside `launch`. The release waits
1031
+ // for that startup to SETTLE: a release at grant time would wake a
1032
+ // blocked `spawnAndWait` while `record.promise` was still undefined,
1033
+ // and it would read a healthy agent as one that never ran.
1034
+ void entry.start().then(() => entry.release(), () => entry.release());
1035
+ },
1036
+ }),
1037
+ left,
1038
+ };
1039
+ this.queue.push(entry);
1040
+ }
1041
+
1042
+ /** Add `delta` to the running count of `pool`; no pool, no count. */
1043
+ private chargePool(pool: Pool | undefined, delta: 1 | -1): void {
1044
+ if (pool === "background") this.runningBackground += delta;
1045
+ else if (pool === "foreground") this.runningForeground += delta;
1046
+ }
1047
+
1048
+ /**
1049
+ * Kick off an agent's startup and register it under `startups`. The returned
1050
+ * promise never rejects — the failure is delivered through `awaitStartup`,
1051
+ * and to the record.
1052
+ *
1053
+ * @param failAs - For a start that QUEUED (on a pool or on the budget
1054
+ * alone), how a startup failure is reported: "foreground" when an inline
1055
+ * `spawnAndWait` caller gets it as a throw, else "background". Undefined
1056
+ * for an immediate start. A queued start can begin minutes after `spawn()`
1057
+ * returned, and nobody is awaiting `awaitStartup` by then, so its failure
1058
+ * lives on the record as status "error". An immediate start instead drops
1059
+ * the record, exactly as the throw out of `spawn()` did: no orphan in
1060
+ * `listAgents()`, and the handle goes back.
1061
+ */
1062
+ private launch(id: string, record: AgentRecord, args: SpawnArgs, failAs: Pool | undefined): Promise<void> {
1063
+ const startup = this.startAgent(id, record, args).then(
1064
+ () => { this.startups.delete(id); },
1065
+ (err) => {
1066
+ this.startups.delete(id);
1067
+ try {
1068
+ if (failAs !== undefined) {
1069
+ // Mirrors settleRun: an inline caller gets this failure as a throw
1070
+ // out of spawnAndWait, so an unconsumed record would ALSO nudge the
1071
+ // session about it — the same failure reported twice.
1072
+ if (failAs === "foreground") markConsumed(record, this.clock.now());
1073
+ record.status = "error";
1074
+ record.error = errorMessage(err);
1075
+ record.completedAt = this.clock.now();
1076
+ try { this.onComplete?.(record); } catch { /* the startup failure is the error worth reporting */ }
1077
+ } else {
1078
+ this.agents.delete(id);
1079
+ }
1080
+ } finally {
1081
+ // The agent never kept its slot (startAgent gives it back on failure)
1082
+ // or its lease, so anything queued behind it can go now.
1083
+ this.releaseLease(record);
1084
+ this.drainQueue();
1085
+ }
1086
+ throw err;
1087
+ },
1088
+ );
1089
+ this.startups.set(id, startup);
1090
+ // Nothing is obliged to await `startups` — swallow the rejection once here
1091
+ // so an unawaited startup can't take the process down, and hand callers
1092
+ // (drainQueue) that swallowed promise.
1093
+ return startup.catch(() => {});
1094
+ }
1095
+
1096
+ /**
1097
+ * Resolves once the agent is actually running, and rejects with the startup
1098
+ * failure (strict worktree isolation) that `spawn()` used to throw before the
1099
+ * repo copy became async. Resolves immediately for an agent that is already
1100
+ * running, still queued, or unknown — so callers can await it unconditionally.
1101
+ *
1102
+ * Call it in the same tick as the `spawn()` it belongs to: a failed startup
1103
+ * takes its record (and this entry) with it, exactly as the throw did.
1104
+ */
1105
+ awaitStartup(id: string): Promise<void> {
1106
+ return this.startups.get(id) ?? Promise.resolve();
1107
+ }
1108
+
1109
+ /** Actually start an agent (called immediately or from queue drain). */
1110
+ private async startAgent(
1111
+ id: string,
1112
+ record: AgentRecord,
1113
+ { pi, ctx, type, prompt, options }: SpawnArgs,
1114
+ ) {
1115
+ // Re-validate the cwd (caller-supplied, else the session's): queued spawns can start minutes after
1116
+ // spawn()'s check, and the directory may be gone by then (TOCTOU). Same
1117
+ // curated errors; drainQueue parks a throw on the record as an error.
1118
+ assertSpawnCwd(options.cwd, ctx.cwd);
1119
+ // Single resolution point for the caller-supplied cwd — the worktree base
1120
+ // repo and both cleanup calls below MUST agree on this value forever.
1121
+ const customCwd = options.cwd ?? undefined; // null (RPC "unset") → undefined
1122
+ const baseCwd = customCwd ?? ctx.cwd;
1123
+
1124
+ // Take the running state — and with it the concurrency slot — BEFORE the
1125
+ // first await. Creating a worktree is an awaited git call, and drainQueue
1126
+ // reads the pool counters synchronously in a loop: incrementing after the
1127
+ // await would let it start every queued agent at once while the first is
1128
+ // still copying its repo. Claiming "running" here also keeps abort() and
1129
+ // abortAll() able to reach an agent whose worktree is still being created.
1130
+ //
1131
+ // The pool is resolved ONCE, here, and recorded for `settleRun`:
1132
+ // `poolFor` reads `maxConcurrentForeground`, which the user can change from
1133
+ // `/agents → Settings` mid-run, so recomputing it at settle time would
1134
+ // decrement a pool this run never charged (counter underflow, limit
1135
+ // silently lifted) or skip the decrement for one it did (leaked slot —
1136
+ // every later blocking spawn queues forever). Only `sendToBackground`
1137
+ // changes it afterwards, moving the counters with it. The two startup exits
1138
+ // below never reach `settleRun`, so they hand the slot back themselves.
1139
+ record.chargedPool = this.poolFor(record);
1140
+ record.status = "running";
1141
+ record.startedAt = this.clock.now();
1142
+ record.startGate = undefined;
1143
+ this.chargePool(record.chargedPool, 1);
1144
+
1145
+ // Hold the pool slot BEFORE awaiting admission, just as for worktree creation.
1146
+ try {
1147
+ if (options.modelSelection?.tierBinding && !options.modelSelection.tierAdmitted) {
1148
+ const block = await admitModelSelection(options.modelSelection, ctx.modelRegistry, pi.events);
1149
+ // Admitting an unadmitted binding always resolves its `tier`.
1150
+ record.modelTier = tierDisclosure(options.modelSelection.tier!);
1151
+ record.modelFallback = options.modelSelection.fallback;
1152
+ if (block) throw new Error(`${modelSelectionNote(record)} ${describeBlock(block)} The agent was not started.`);
1153
+ if (record.abortController?.signal.aborted || options.signal?.aborted) throw new Error("Agent aborted during model admission.");
1154
+ }
1155
+ if (record.modelTier) {
1156
+ record.invocation ??= {};
1157
+ Object.assign(record.invocation, describeSelection(options.modelSelection, ctx.modelRegistry));
1158
+ }
1159
+ } catch (error) {
1160
+ this.chargePool(record.chargedPool, -1);
1161
+ throw error;
1162
+ }
1163
+
1164
+ // Worktree isolation: try to create a temporary git worktree. Strict —
1165
+ // fail loud if not possible (no silent fallback to main tree). Done BEFORE
1166
+ // the run is kicked off so a failure doesn't leave a half-running agent.
1167
+ // The project switch is enforced here as well as at the tool boundary
1168
+ // because cross-extension RPC forwards its options unvalidated — a schema
1169
+ // that omits the field can't stop a caller that never saw the schema.
1170
+ // The agent file's `isolation:` is resolved here too, not by each caller:
1171
+ // mentions pass none, and RPC forwards only its caller's value. Workflow
1172
+ // children are the exception, as in Claude Code, whose workflow agent()
1173
+ // reads only the script's option and ignores the definition's.
1174
+ const isolation = options.workflowId !== undefined
1175
+ ? options.isolation
1176
+ : getAgentConfig(type)?.isolation ?? options.isolation;
1177
+ // A worktree exists to make edits, which a child started while planning may not.
1178
+ if (isolation === "worktree" && record.planGate?.policy) {
1179
+ this.chargePool(record.chargedPool, -1);
1180
+ throw new Error('Cannot run with isolation: "worktree" while Plan mode is active: a worktree exists to make edits. Omit `isolation`, or leave Plan mode first.');
1181
+ }
1182
+ let worktreeCwd: string | undefined;
1183
+ if (isolation === "worktree" && isWorktreeIsolationEnabled()) {
1184
+ let wt: WorktreeInfo;
1185
+ try {
1186
+ wt = await createWorktree(pi, baseCwd, id);
1187
+ } catch (err) {
1188
+ this.chargePool(record.chargedPool, -1);
1189
+ const cause = errorMessage(err);
1190
+ // Only a parent-cwd spawn can be fixed by pointing `cwd` elsewhere.
1191
+ const hint = customCwd === undefined
1192
+ ? " To isolate another repo, pass its path as the `Agent` tool's `cwd`; or omit `isolation`."
1193
+ : " Pass a `cwd` inside a git repo with at least one commit, or omit `isolation`.";
1194
+ throw new Error(`Cannot run with isolation: "worktree": ${cause}.${hint}`);
1195
+ }
1196
+ record.worktree = wt;
1197
+ // workPath preserves subdirectory scoping for caller-supplied cwds: a
1198
+ // cwd deep in a monorepo maps to the same subdir inside the copy, not
1199
+ // the copied repo's root. Plain worktree spawns keep the historical
1200
+ // behavior (agent at the copy's root) — moving them to workPath would
1201
+ // also move .pi config discovery when the parent session sits in a repo
1202
+ // subdirectory, silently dropping extensions/skills.
1203
+ worktreeCwd = customCwd !== undefined ? wt.workPath : wt.path;
1204
+ this.worktreeRepos.add(baseCwd);
1205
+ // The widget's "worktree" tag reads this; only callers that resolve the
1206
+ // agent file themselves seed it.
1207
+ record.invocation ??= {};
1208
+ record.invocation.isolation = "worktree";
1209
+
1210
+ // No longer "running" means a stop landed while the copy was being made
1211
+ // (abort(), abortAll()) — a window that did not exist when creation was
1212
+ // synchronous. The record is already terminal, so launching the run would
1213
+ // burn tokens on work nobody is waiting for: discard the fresh (and by
1214
+ // definition unchanged) worktree instead.
1215
+ if (record.status !== "running") {
1216
+ this.chargePool(record.chargedPool, -1);
1217
+ recordWorktreeCleanup(record, await cleanupWorktree(pi, baseCwd, wt, options.description));
1218
+ if (record.worktreeResult?.error) {
1219
+ if (!record.isBackground) markConsumed(record, this.clock.now());
1220
+ try { this.onComplete?.(record); } catch { /* do not strand the lease */ }
1221
+ }
1222
+ // After the cleanup, not before: the copy is still being removed until here.
1223
+ this.releaseLease(record);
1224
+ this.drainQueue();
1225
+ return;
1226
+ }
1227
+ }
1228
+
1229
+ // A throwing `onStart` fails the startup like a worktree failure: hand the
1230
+ // slot back and remove the copy, since nothing will ever settle this run.
1231
+ // The lease goes back in `launch`, after the cleanup.
1232
+ try {
1233
+ this.onStart?.(record);
1234
+ } catch (err) {
1235
+ this.chargePool(record.chargedPool, -1);
1236
+ if (record.worktree) {
1237
+ try {
1238
+ recordWorktreeCleanup(record, await cleanupWorktree(pi, baseCwd, record.worktree, options.description));
1239
+ } catch { /* ignore unexpected cleanup errors */ }
1240
+ }
1241
+ if (record.worktreeResult?.error) {
1242
+ const message = err instanceof Error ? err.message : String(err);
1243
+ throw new Error(`${message}\n${record.error}`);
1244
+ }
1245
+ throw err;
1246
+ }
1247
+
1248
+ // Wire parent abort signal to stop the subagent when the parent is interrupted.
1249
+ // A queued spawn can start minutes after the caller handed us its signal,
1250
+ // by which time it may already be aborted — and `addEventListener` would
1251
+ // never fire, leaving a child the parent can no longer reach.
1252
+ if (options.signal?.aborted) this.abort(id);
1253
+ else this.armParentAbort(record, options.signal);
1254
+ const detach = () => this.detachParentAbort(record);
1255
+
1256
+ record.ranThisRun = record.status === "running";
1257
+ const promise = runAgent(ctx, type, prompt, {
1258
+ pi,
1259
+ clock: this.clock,
1260
+ homeDir: options.homeDir ?? this.homeDir,
1261
+ agentId: id,
1262
+ model: options.modelSelection?.model,
1263
+ modelSelection: record.modelSelection!,
1264
+ maxTurns: options.maxTurns,
1265
+ isolated: options.isolated,
1266
+ inheritContext: options.inheritContext,
1267
+ thinkingLevel: options.thinkingLevel,
1268
+ structuredOutput: options.structuredOutput,
1269
+ planGate: record.planGate,
1270
+ resumeSessionFile: options.resumeSessionFile,
1271
+ nested: options.parentAgentId !== undefined,
1272
+ workflow: options.workflowId !== undefined,
1273
+ // Worktree wins for the working dir (the agent must run in the copy —
1274
+ // which, with a custom cwd, was created from that target). Config stays
1275
+ // with the parent project when a caller-supplied cwd is in play; it must
1276
+ // stay undefined otherwise so plain worktree runs keep resolving config
1277
+ // (incl. relative extension paths and memory) inside the worktree copy.
1278
+ cwd: worktreeCwd ?? customCwd,
1279
+ // Set iff a worktree was created (see above) — names the directory the
1280
+ // copy came from, so the prompt can tell the agent not to work there.
1281
+ worktreeBase: worktreeCwd ? baseCwd : undefined,
1282
+ configCwd: options.configCwd ?? (customCwd !== undefined ? ctx.cwd : undefined),
1283
+ configProjectTrusted: options.configCwd !== undefined ? options.configProjectTrusted : undefined,
1284
+ signal: record.abortController!.signal,
1285
+ onToolActivity: (activity) => {
1286
+ if (activity.type === "end") record.toolUses++;
1287
+ options.onToolActivity?.(activity);
1288
+ },
1289
+ onTurnEnd: options.onTurnEnd,
1290
+ onTextDelta: options.onTextDelta,
1291
+ onModelCall: options.onModelCall,
1292
+ onAssistantUsage: (usage, message) => {
1293
+ relabelFromResponse(record, message);
1294
+ addUsage(record.lifetimeUsage, usage);
1295
+ this.onUsage?.(record, usage);
1296
+ options.onAssistantUsage?.(usage);
1297
+ },
1298
+ onCompaction: (info) => {
1299
+ record.compactionCount++;
1300
+ this.onCompact?.(record, info);
1301
+ options.onCompaction?.(info);
1302
+ },
1303
+ nestedRuntime: {
1304
+ manager: this,
1305
+ parentAgentId: id,
1306
+ depth: record.depth,
1307
+ maxSubagentDepth: record.maxSubagentDepth,
1308
+ },
1309
+ onSessionCreated: (session) => {
1310
+ record.session = session;
1311
+ // Capture now, while the session object exists: after eviction this
1312
+ // path is the only thing that can reopen the conversation, and an
1313
+ // in-memory session reports undefined, which correctly means
1314
+ // "nothing to come back to".
1315
+ // Optional chaining, not defensiveness for its own sake: this is the
1316
+ // only field read off the session at creation, so an older pi or a
1317
+ // stubbed session must degrade to "not resumable" rather than throw
1318
+ // and take the whole spawn down with it.
1319
+ record.sessionFile = session.sessionManager?.getSessionFile?.();
1320
+ // Same reason, different field: the model and thinking level are only
1321
+ // knowable once pi has resolved its defaults and clamped the level to
1322
+ // what the model supports. Writing them back here makes the record
1323
+ // authoritative, so every surface reads one place instead of each
1324
+ // re-deriving "session, else the request" for itself.
1325
+ if (session.model) {
1326
+ record.invocation ??= {};
1327
+ // Read the kept request first: a caller's level survives being clamped
1328
+ // AND, one line later, being replaced by the effective one.
1329
+ const requested = record.invocation.requestedThinking ?? record.invocation.thinking;
1330
+ Object.assign(
1331
+ record.invocation,
1332
+ record.modelTier?.kind === "routed" && record.modelTier.admitted
1333
+ ? describeSelection(options.modelSelection, ctx.modelRegistry)
1334
+ : isVirtualModel(session.model) ? describeVirtualModel(session.model) : describeModel(session.model),
1335
+ );
1336
+ // Guarded for the reason above: a session that reports no level keeps
1337
+ // the request rather than losing it. Overwriting unconditionally would
1338
+ // turn an older or stubbed session into a blank `thinking:` tag, which
1339
+ // is worse than the stale-but-true value it replaced.
1340
+ if (session.thinkingLevel) {
1341
+ record.invocation.thinking = session.thinkingLevel;
1342
+ if (requested && requested !== session.thinkingLevel) {
1343
+ record.invocation.requestedThinking = requested;
1344
+ }
1345
+ }
1346
+ }
1347
+ // The transcript says it too: it is what someone opens to find out what ran.
1348
+ if (record.modelFallback && record.outputFile) appendModelFallbackEntry(record.outputFile, record.id, record.modelFallback);
1349
+ if (record.modelTier && record.outputFile) appendModelTierEntry(record.outputFile, record.id, record.modelTier);
1350
+ // Flush any steers that arrived before the session was ready
1351
+ if (record.pendingSteers?.length) {
1352
+ for (const msg of record.pendingSteers) {
1353
+ session.steer(msg).catch(() => {});
1354
+ }
1355
+ record.pendingSteers = undefined;
1356
+ }
1357
+ options.onSessionCreated?.(session);
1358
+ },
1359
+ })
1360
+ .then(async ({ responseText, session, aborted, steered, failure, structuredJson, structuredRetried }) => {
1361
+ this.settleOutcome(record, { text: responseText, failure, aborted, steered });
1362
+ // Kept beside `result`, never inside it: `result` is prose meant for a
1363
+ // reader — it is previewed, transcribed, and appended to below — while
1364
+ // this is a machine-readable payload one caller asked for by schema.
1365
+ record.structuredJson = structuredJson;
1366
+ record.structuredRetried = structuredRetried;
1367
+ record.session = session;
1368
+
1369
+ detach();
1370
+
1371
+ // Final flush of streaming output file
1372
+ if (record.outputCleanup) {
1373
+ try { record.outputCleanup(); } catch { /* ignore */ }
1374
+ record.outputCleanup = undefined;
1375
+ }
1376
+
1377
+ // Clean up worktree if used
1378
+ if (record.worktree) {
1379
+ // The one moment the child's tree still exists and the child is done
1380
+ // writing to it. try/catch, not decoration: a hook that throws must
1381
+ // not leave the worktree behind.
1382
+ if (options.onBeforeWorktreeCleanup) {
1383
+ try {
1384
+ await options.onBeforeWorktreeCleanup(record.worktree.path);
1385
+ } catch { /* ignore — never block cleanup */ }
1386
+ }
1387
+ const wtResult = await cleanupWorktree(pi, baseCwd, record.worktree, options.description);
1388
+ recordWorktreeCleanup(record, wtResult);
1389
+ if (!wtResult.error && wtResult.hasChanges && wtResult.branch) {
1390
+ // With a caller-supplied cwd the branch lives in THAT repo, not the
1391
+ // parent session's — say so, or the orchestrator merges in the wrong repo.
1392
+ // Named by its toplevel: the cwd may be a subdirectory of it.
1393
+ const repo = record.worktree.repoRoot;
1394
+ const repoNote = customCwd !== undefined ? ` in \`${repo}\`` : "";
1395
+ // Appended to the prose only. A structured child's caller parses
1396
+ // `structuredJson`, which stays untouched — but `result` is also
1397
+ // what a human reads, so the note still belongs on it.
1398
+ record.result = responseText +
1399
+ `\n\n---\nChanges saved to branch \`${wtResult.branch}\`${repoNote}. Merge with: \`git merge ${wtResult.branch}\`${customCwd !== undefined ? ` (run in \`${repo}\`)` : ""}`;
1400
+ }
1401
+ }
1402
+
1403
+ this.abortOwnedChildren(id);
1404
+
1405
+ this.settleRun(record, true);
1406
+ return responseText;
1407
+ })
1408
+ .catch(async (err) => {
1409
+ // Don't overwrite status if externally stopped via abort()
1410
+ if (record.status !== "stopped") {
1411
+ record.status = "error";
1412
+ }
1413
+ record.error = errorMessage(err);
1414
+ record.completedAt ??= this.clock.now();
1415
+
1416
+ detach();
1417
+
1418
+ // Final flush of streaming output file on error
1419
+ if (record.outputCleanup) {
1420
+ try { record.outputCleanup(); } catch { /* ignore */ }
1421
+ record.outputCleanup = undefined;
1422
+ }
1423
+
1424
+ // Best-effort worktree cleanup on error
1425
+ if (record.worktree) {
1426
+ try {
1427
+ const wtResult = await cleanupWorktree(pi, baseCwd, record.worktree, options.description);
1428
+ recordWorktreeCleanup(record, wtResult);
1429
+ } catch { /* ignore cleanup errors */ }
1430
+ }
1431
+
1432
+ this.abortOwnedChildren(id);
1433
+
1434
+ this.settleRun(record, false);
1435
+ return "";
1436
+ });
1437
+
1438
+ record.promise = promise;
1439
+
1440
+ // Notify caller that spawn is complete (record is in the map, promise is set).
1441
+ // Called synchronously — onSessionCreated fires asynchronously inside runAgent.
1442
+ // Used by spawnAndWait to let the caller set up output files before streaming
1443
+ // starts. Read off the options, so a spawn that started from a queue drain
1444
+ // still reaches the caller that queued it.
1445
+ options.onSpawned?.(id);
1446
+ }
1447
+
1448
+ /**
1449
+ * The shared tail of both settle paths: release whatever pool slot the run
1450
+ * held, notify, and let the queue drain into the freed slot.
1451
+ *
1452
+ * The decrement lives HERE and nowhere else. `abort()` on a running record
1453
+ * only fires its controller and leaves the run to settle normally, so
1454
+ * decrementing there too would double-free — permanently lifting the limit.
1455
+ *
1456
+ * Foreground agents fire `onComplete` for lifecycle symmetry, with
1457
+ * `resultConsumed` set so the callback skips notifications the inline result
1458
+ * already delivered.
1459
+ *
1460
+ * @param guardCallback swallow a throwing `onComplete` (the success path does;
1461
+ * the error path historically did not, and keeps not doing so).
1462
+ *
1463
+ * Releases `record.chargedPool` — the pool the run was charged to at start
1464
+ * (or moved to by `sendToBackground`), never a recomputed one, so a mid-run
1465
+ * change to `maxConcurrentForeground` can't make the release disagree with
1466
+ * the acquire.
1467
+ */
1468
+ private settleRun(record: AgentRecord, guardCallback: boolean): void {
1469
+ if (!record.isBackground) markConsumed(record, this.clock.now());
1470
+ // Nobody waits on a settled run; a later foreground resume must not look
1471
+ // like a spawn `sendToBackground` could hand off.
1472
+ record.blocking = false;
1473
+ this.chargePool(record.chargedPool, -1);
1474
+ // Every run that reaches here holds a budget lease (each start takes one),
1475
+ // so every settle frees a slot some queued start, in any manager, may need.
1476
+ this.releaseLease(record);
1477
+
1478
+ try {
1479
+ if (guardCallback) {
1480
+ try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
1481
+ } else {
1482
+ this.onComplete?.(record);
1483
+ }
1484
+ } finally {
1485
+ // Drained even when an unguarded `onComplete` throws: the slot and lease
1486
+ // are already free, and a waiter that misses this drain may be the child
1487
+ // a running parent is blocked on, with nothing else left to pump.
1488
+ this.drainQueue();
1489
+ }
1490
+ }
1491
+
1492
+ /**
1493
+ * Stop the nested children a settled parent owns. Nested records are hidden
1494
+ * from the UI and only their owner can consume them, so a child outliving its
1495
+ * parent would burn tokens unseen with no way to reach it. Grandchildren are
1496
+ * covered transitively — each abort lands in that child's own settle path.
1497
+ */
1498
+ private abortOwnedChildren(parentId: string): void {
1499
+ for (const [id, record] of this.agents) {
1500
+ if (record.parentAgentId === parentId) this.abort(id);
1501
+ }
1502
+ }
1503
+
1504
+ /**
1505
+ * Start queued agents up to each pool's concurrency limit and the process
1506
+ * budget. The budget owns the order (`pumpBudget`): the earliest waiter, in
1507
+ * any manager, whose depth fits and whose own pool has room. Eligibility per
1508
+ * entry rather than `shift`: with one queue serving several limits, a
1509
+ * saturated pool at the head would otherwise stall everything behind it.
1510
+ * Taking the earliest eligible entry keeps FIFO within each pool.
1511
+ */
1512
+ private drainQueue() {
1513
+ pumpBudget();
1514
+ }
1515
+
1516
+ /**
1517
+ * Remove queued entries and wake anyone blocked on them. The single point
1518
+ * that enforces "leaving the queue releases the waiter" — a missed release is
1519
+ * an unbounded hang, not a failed call.
1520
+ */
1521
+ private dequeue(pred: (entry: QueueEntry) => boolean): void {
1522
+ const kept: typeof this.queue = [];
1523
+ const removed: QueueEntry[] = [];
1524
+ for (const entry of this.queue) (pred(entry) ? removed : kept).push(entry);
1525
+ this.queue = kept;
1526
+ for (const entry of removed) {
1527
+ entry.cancel();
1528
+ entry.release();
1529
+ }
1530
+ }
1531
+
1532
+ /**
1533
+ * Spawn an agent and wait for completion (foreground use).
1534
+ * Charged to the foreground pool (`maxConcurrentForeground`), which is
1535
+ * unlimited by default; never to the background one.
1536
+ * Returns { id, record } so callers can access the agent ID.
1537
+ *
1538
+ * @param onSpawned - Called synchronously once the run is kicked off, before
1539
+ * onSessionCreated fires.
1540
+ */
1541
+ async spawnAndWait(
1542
+ pi: ExtensionAPI,
1543
+ ctx: ExtensionContext,
1544
+ type: SubagentType,
1545
+ prompt: string,
1546
+ options: Omit<SpawnOptions, "isBackground">,
1547
+ onSpawned?: (id: string) => void,
1548
+ ): Promise<{ id: string; record: AgentRecord }> {
1549
+ // `blocking` is what maxConcurrentForeground bounds, and this is its only
1550
+ // source. onSpawned rides on the options rather than on a field of this
1551
+ // manager: a queued spawn starts at drain time, long after any install/
1552
+ // restore pair around this call would have put the field back — and it now
1553
+ // fires after an await (worktree creation) even on the immediate path.
1554
+ const id = this.spawn(pi, ctx, type, prompt, {
1555
+ ...options,
1556
+ isBackground: false,
1557
+ blocking: true,
1558
+ onSpawned,
1559
+ });
1560
+ const record = this.agents.get(id)!;
1561
+
1562
+ // Queued: nothing to await yet — the promise appears when the drain starts
1563
+ // it. The gate resolves (never rejects) on every path out of the queue,
1564
+ // start and abort alike, so a rejection can never escape into the caller's
1565
+ // tool `execute` and take down pi's whole Promise.all tool batch.
1566
+ if (record.status === "queued") await record.startGate;
1567
+
1568
+ // The run promise only exists once startup is past its awaited repo copy —
1569
+ // without this the call would return before the agent had started at all.
1570
+ // A startup failure (strict worktree isolation) rejects here, which is what
1571
+ // the immediate path owes its caller: the Agent tool reports failure by
1572
+ // letting this throw out of `execute`. A queued spawn's failure landed on
1573
+ // the record instead (nobody was awaiting `startups` at drain time) and is
1574
+ // rethrown below, so the contract is the same either way.
1575
+ await this.awaitStartup(id);
1576
+
1577
+ // undefined when it was aborted while queued, or stopped mid-copy, and so
1578
+ // never ran — the record is already terminal with a completedAt, which is
1579
+ // what the caller renders.
1580
+ if (record.promise) await record.promise;
1581
+
1582
+ // A record that ended "error" without ever getting a promise never ran: the
1583
+ // same startup failure spawn() rethrows on the immediate path (#179). Keep
1584
+ // one contract rather than letting queue pressure decide whether a strict
1585
+ // worktree failure throws or returns as a result.
1586
+ if (record.promise === undefined && record.status === "error") {
1587
+ throw new Error(record.error);
1588
+ }
1589
+ return { id, record };
1590
+ }
1591
+
1592
+ /**
1593
+ * Hand a live run to the background without restarting it. The session,
1594
+ * promise, in-flight turn, transcript and worktree all continue under the
1595
+ * same id; what changes is who answers for it. The caller's parent-abort
1596
+ * listener comes off (its Esc no longer reaches the run — `abort()` and
1597
+ * `/agents` still do), the slot moves to the background pool, and the run
1598
+ * then settles through the ordinary background path, since `settleRun`
1599
+ * reads the live `isBackground` and `chargedPool`.
1600
+ *
1601
+ * Releasing the caller is the caller's job — the `Agent` tool races its own
1602
+ * wake-up against `spawnAndWait`. This only refuses what it can see: a run
1603
+ * nobody is blocking on (a background or detached spawn, one already handed
1604
+ * off, or a foreground RESUME — `blocking` is cleared when the spawned run
1605
+ * settles, and a resume never charged the slot this would move), and one
1606
+ * that reports through something other than the session (a nested child, a
1607
+ * workflow's agent).
1608
+ *
1609
+ * The background slot is taken even when the pool is full: suspending a
1610
+ * live run is not an option, so it runs over the limit and queued
1611
+ * background work waits for the count to drop, as with `bypassQueue`.
1612
+ */
1613
+ sendToBackground(id: string): boolean {
1614
+ const record = this.agents.get(id);
1615
+ if (!record?.blocking || !isTopLevelAgent(record) || record.status !== "running" || !record.promise) return false;
1616
+
1617
+ this.detachParentAbort(record);
1618
+ this.chargePool(record.chargedPool, -1);
1619
+ record.chargedPool = "background";
1620
+ this.chargePool(record.chargedPool, 1);
1621
+ record.blocking = false;
1622
+ record.isBackground = true;
1623
+ record.resultConsumed = false;
1624
+ // A freed foreground slot may have unblocked a queued blocking spawn.
1625
+ this.drainQueue();
1626
+ return true;
1627
+ }
1628
+
1629
+ /**
1630
+ * Resume an existing agent session with a new prompt.
1631
+ */
1632
+ async resume(
1633
+ id: string,
1634
+ prompt: string,
1635
+ signal?: AbortSignal,
1636
+ options?: ResumeOptions,
1637
+ ): Promise<AgentRecord | undefined> {
1638
+ const record = this.agents.get(id);
1639
+ if (!record?.session) return undefined;
1640
+
1641
+ // Never re-enter a run that is still in flight, on either path. Starting a
1642
+ // second run would overwrite record.abortController — orphaning the live
1643
+ // run beyond the reach of `/agents` stop and abortAll() — take a second
1644
+ // pool slot and budget lease, and then reject from session.prompt() with
1645
+ // "Agent is already processing", whose settle path would abort the LIVE
1646
+ // run's children and report a failure for a run that is still going. A
1647
+ // detached resume makes this easy to hit (the caller gets control back
1648
+ // while the record stays "running"), and parallel tool calls make it
1649
+ // possible inline. Checked before anything below mutates the record, and
1650
+ // both paths flip the status synchronously, so a second call made in the
1651
+ // same tick is refused too. The caller decides whether to wait or steer.
1652
+ if (record.status === "running" || record.status === "queued") return undefined;
1653
+ // Decided per run: a resume after the parent left Plan gets its full surface back.
1654
+ // `spawn` sets `planQuery` on every record this manager holds.
1655
+ this.decidePlanGate(record, record.planQuery!);
1656
+
1657
+ // Background resume: settle asynchronously and notify on completion exactly
1658
+ // like a background spawn, returning immediately with the record still
1659
+ // "running" — or "queued" when at the concurrency limit. Previously
1660
+ // run_in_background was ignored on resume (the Agent tool's resume branch
1661
+ // returned before its background branch, and resume() only ever awaited
1662
+ // inline), so a resumed agent always blocked the caller until it finished.
1663
+ if (options?.isBackground) {
1664
+ // The pool it will be charged to once `isBackground` is set below; asked
1665
+ // before anything mutates the record, so a refusal leaves it untouched.
1666
+ const pool: Pool | undefined = occupiesPoolSlot({ ...record, isBackground: true }) ? "background" : undefined;
1667
+ const lease = this.admit(record, pool);
1668
+
1669
+ record.isBackground = true;
1670
+ record.resultConsumed = false;
1671
+ record.noticeDelivered = undefined;
1672
+ record.ranThisRun = false;
1673
+ record.consumedAt = undefined;
1674
+ record.result = undefined;
1675
+ record.error = undefined;
1676
+ record.completedAt = undefined;
1677
+ record.status = "queued";
1678
+
1679
+ // `startResume` throws only from the `onStart` callback, after it has
1680
+ // charged the pool and with the lease held: hand both back and leave the
1681
+ // record an error rather than "running" forever.
1682
+ const start = () => {
1683
+ try {
1684
+ this.startResume(id, record, prompt, signal, options);
1685
+ } catch (err) {
1686
+ if (occupiesPoolSlot(record)) this.runningBackground--;
1687
+ this.releaseLease(record);
1688
+ record.status = "error";
1689
+ record.error = errorMessage(err);
1690
+ record.completedAt = this.clock.now();
1691
+ this.drainQueue();
1692
+ throw err;
1693
+ }
1694
+ };
1695
+ if (lease === undefined) {
1696
+ // At a limit — queue it, started when a slot and a lease free up. A
1697
+ // detached resume has no inline caller, hence nothing to release. The
1698
+ // queue is shared with spawns, whose startup is async, so entries are
1699
+ // promise-shaped even though a resume starts synchronously; a failure
1700
+ // is reported here, since the budget pump swallows throws.
1701
+ this.enqueueStart(id, record, pool, async () => {
1702
+ try {
1703
+ start();
1704
+ } catch {
1705
+ this.onComplete?.(record);
1706
+ }
1707
+ }, () => {});
1708
+ } else {
1709
+ record.budgetLease = lease;
1710
+ start();
1711
+ }
1712
+ return record;
1713
+ }
1714
+
1715
+ // Foreground resume: run inline and return the settled record. Takes a
1716
+ // budget lease like everything else, waiting for one if the process is at
1717
+ // its limit; the caller's signal (or `/agents` stop) releases the wait.
1718
+ // No pool: the foreground pool does not cover resumes (see the README).
1719
+ const lease = this.admit(record, undefined);
1720
+ record.ranThisRun = false;
1721
+ record.noticeDelivered = undefined;
1722
+ record.status = lease === undefined ? "queued" : "running";
1723
+ record.budgetLease = lease;
1724
+ record.startedAt = this.clock.now();
1725
+ record.completedAt = undefined;
1726
+ record.result = undefined;
1727
+ record.error = undefined;
1728
+ // Fresh controller, as on the detached path: `abort()` fires whatever
1729
+ // `record.abortController` holds, and the spawn's has long settled — so a
1730
+ // stop from `/agents` or the caller's Esc would mark the record stopped
1731
+ // while the resumed prompt ran on to completion.
1732
+ const abortController = new AbortController();
1733
+ record.abortController = abortController;
1734
+ this.armParentAbort(record, signal);
1735
+ // `addEventListener` never fires on a signal that is already aborted, and
1736
+ // the runner skips the prompt only when ITS signal is — stop now, so that
1737
+ // holds for a resume issued on a call that was cancelled first. On a
1738
+ // record still waiting for a lease this stops it outright.
1739
+ if (signal?.aborted) this.abort(id);
1740
+
1741
+ if (lease === undefined) {
1742
+ // The queue entry's `start` only marks the record running: the run itself
1743
+ // stays inline below, so the caller awaits the same `resumeAgent` call
1744
+ // as an unqueued resume, and the lease the grant handed over is released
1745
+ // by the same tail. `granted` tells a grant from a removal (abort,
1746
+ // dispose), which resolves the same promise.
1747
+ let granted = false;
1748
+ if (record.status === "queued") {
1749
+ await new Promise<void>(resolve => {
1750
+ this.enqueueStart(id, record, undefined, async () => {
1751
+ granted = true;
1752
+ record.status = "running";
1753
+ record.startedAt = this.clock.now();
1754
+ }, resolve);
1755
+ });
1756
+ }
1757
+ // Removed while it waited: it never ran, so there is nothing to settle.
1758
+ // `abort` already marked it stopped; `dispose` and `detach` did not.
1759
+ if (!granted) {
1760
+ if (record.status === "queued") {
1761
+ record.status = "stopped";
1762
+ record.completedAt = this.clock.now();
1763
+ }
1764
+ markConsumed(record, this.clock.now());
1765
+ this.detachParentAbort(record);
1766
+ return record;
1767
+ }
1768
+ }
1769
+
1770
+ try {
1771
+ // Positive evidence for the reap notice: only a live signal means the prompt runs.
1772
+ record.ranThisRun = record.status === "running";
1773
+ this.settleOutcome(record, await resumeAgent(record.session, prompt, {
1774
+ clock: this.clock,
1775
+ type: record.type,
1776
+ maxTurns: options?.maxTurns ?? record.invocation?.maxTurns,
1777
+ onTurnEnd: options?.onTurnEnd,
1778
+ onTextDelta: options?.onTextDelta,
1779
+ onModelCall: options?.onModelCall,
1780
+ onToolActivity: (activity) => {
1781
+ if (activity.type === "end") record.toolUses++;
1782
+ options?.onToolActivity?.(activity);
1783
+ },
1784
+ onAssistantUsage: (usage, message) => {
1785
+ relabelFromResponse(record, message);
1786
+ addUsage(record.lifetimeUsage, usage);
1787
+ this.onUsage?.(record, usage);
1788
+ options?.onAssistantUsage?.(usage);
1789
+ },
1790
+ onCompaction: (info) => {
1791
+ record.compactionCount++;
1792
+ this.onCompact?.(record, info);
1793
+ options?.onCompaction?.(info);
1794
+ },
1795
+ signal: abortController.signal,
1796
+ }));
1797
+ } catch (err) {
1798
+ this.failResume(record, err);
1799
+ }
1800
+ this.detachParentAbort(record);
1801
+ // The caller gets the settled record inline, whatever the outcome.
1802
+ markConsumed(record, this.clock.now());
1803
+
1804
+ // Same contract as the spawn settle paths: children spawned during the
1805
+ // resumed turn must not outlive it — nothing else can see or reach them.
1806
+ this.abortOwnedChildren(id);
1807
+
1808
+ this.releaseLease(record);
1809
+ this.drainQueue();
1810
+ return record;
1811
+ }
1812
+
1813
+ /**
1814
+ * Write a run's outcome onto its record — a spawn's or a resume's, with one
1815
+ * precedence: an external stop keeps "stopped"; a hard abort (turn limit +
1816
+ * grace) is "aborted"; a failed final turn (a provider error pi resolved
1817
+ * instead of rejecting, #144) is an "error", not a completion with an empty
1818
+ * or stale result; otherwise "steered" or "completed". The run's text stays
1819
+ * available in every case.
1820
+ */
1821
+ private settleOutcome(
1822
+ record: AgentRecord,
1823
+ { text, failure, aborted, steered }: Awaited<ReturnType<typeof resumeAgent>>,
1824
+ ): void {
1825
+ if (record.status !== "stopped") {
1826
+ if (aborted) {
1827
+ record.status = "aborted";
1828
+ } else if (failure) {
1829
+ record.status = "error";
1830
+ record.error = failure;
1831
+ } else {
1832
+ record.status = steered ? "steered" : "completed";
1833
+ }
1834
+ }
1835
+ record.result = text;
1836
+ record.completedAt ??= this.clock.now();
1837
+ }
1838
+
1839
+ /** A resume whose runner rejected: an error, unless an external stop got there first. */
1840
+ private failResume(record: AgentRecord, err: unknown): void {
1841
+ if (record.status !== "stopped") {
1842
+ record.status = "error";
1843
+ record.error = errorMessage(err);
1844
+ }
1845
+ record.completedAt ??= this.clock.now();
1846
+ }
1847
+
1848
+ /**
1849
+ * Start a background resume run: detached, settling and notifying like
1850
+ * startAgent's background path. Invoked immediately, or from drainQueue when
1851
+ * a concurrency slot frees. The session already exists (resume reuses it), so
1852
+ * there is no onSessionCreated to hang per-run wiring off — callers use
1853
+ * `options.onStarted`, which fires on both the immediate and the drained path.
1854
+ */
1855
+ private startResume(
1856
+ id: string,
1857
+ record: AgentRecord,
1858
+ prompt: string,
1859
+ parentSignal: AbortSignal | undefined,
1860
+ options: ResumeOptions,
1861
+ ) {
1862
+ if (!record.session) {
1863
+ this.releaseLease(record);
1864
+ this.drainQueue();
1865
+ return;
1866
+ }
1867
+
1868
+ record.status = "running";
1869
+ record.startedAt = this.clock.now();
1870
+ if (occupiesPoolSlot(record)) this.runningBackground++;
1871
+ this.onStart?.(record);
1872
+
1873
+ // Fresh abort controller so /agents stop and steering target THIS run rather
1874
+ // than the previous one's settled controller.
1875
+ const abortController = new AbortController();
1876
+ record.abortController = abortController;
1877
+ // Optional, and NOT what the Agent tool passes for a detached resume: a
1878
+ // parent signal aborts on the parent's own interrupt (user Esc), which is
1879
+ // right for a foreground run whose result the caller is awaiting, and wrong
1880
+ // for a detached one — background spawns omit it for exactly this reason.
1881
+ let detachParentSignal: (() => void) | undefined;
1882
+ if (parentSignal) {
1883
+ const onParentAbort = () => this.abort(id);
1884
+ parentSignal.addEventListener("abort", onParentAbort, { once: true });
1885
+ detachParentSignal = () => parentSignal.removeEventListener("abort", onParentAbort);
1886
+ }
1887
+
1888
+ // Per-run side effects (output streaming) — see ResumeOptions.onStarted.
1889
+ // After the record is in its running shape, before the run is kicked off.
1890
+ try { options.onStarted?.(); } catch { /* ignore caller wiring errors */ }
1891
+
1892
+ const settle = () => {
1893
+ detachParentSignal?.();
1894
+ detachParentSignal = undefined;
1895
+ // Final flush of streaming output file
1896
+ if (record.outputCleanup) {
1897
+ try { record.outputCleanup(); } catch { /* ignore */ }
1898
+ record.outputCleanup = undefined;
1899
+ }
1900
+ // Children spawned during the resumed turn must not outlive it.
1901
+ this.abortOwnedChildren(id);
1902
+ if (occupiesPoolSlot(record)) this.runningBackground--;
1903
+ this.releaseLease(record);
1904
+ try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
1905
+ this.drainQueue();
1906
+ };
1907
+
1908
+ record.ranThisRun = true;
1909
+ const promise = resumeAgent(record.session, prompt, {
1910
+ clock: this.clock,
1911
+ type: record.type,
1912
+ maxTurns: options.maxTurns ?? record.invocation?.maxTurns,
1913
+ onTurnEnd: options.onTurnEnd,
1914
+ onTextDelta: options.onTextDelta,
1915
+ onModelCall: options.onModelCall,
1916
+ onToolActivity: (activity) => {
1917
+ if (activity.type === "end") record.toolUses++;
1918
+ options.onToolActivity?.(activity);
1919
+ },
1920
+ onAssistantUsage: (usage, message) => {
1921
+ relabelFromResponse(record, message);
1922
+ addUsage(record.lifetimeUsage, usage);
1923
+ this.onUsage?.(record, usage);
1924
+ options.onAssistantUsage?.(usage);
1925
+ },
1926
+ onCompaction: (info) => {
1927
+ record.compactionCount++;
1928
+ this.onCompact?.(record, info);
1929
+ options.onCompaction?.(info);
1930
+ },
1931
+ signal: abortController.signal,
1932
+ })
1933
+ .then((outcome) => {
1934
+ this.settleOutcome(record, outcome);
1935
+ settle();
1936
+ return outcome.text;
1937
+ })
1938
+ .catch((err) => {
1939
+ this.failResume(record, err);
1940
+ settle();
1941
+ return "";
1942
+ });
1943
+
1944
+ record.promise = promise;
1945
+ }
1946
+
1947
+ /**
1948
+ * Send a steering message to an agent from the UI (mirrors the steer_subagent
1949
+ * tool). A live session delivers it now — it interrupts the agent after its
1950
+ * current tool execution and appears as a user message. If the session isn't
1951
+ * ready yet, the message is queued on `pendingSteers` and flushed when the
1952
+ * session is created. Returns false if the agent can't accept steering
1953
+ * (unknown id, or no longer running/queued). Fires `onSteered` on either
1954
+ * accepted path, so the UI viewers, an `@handle` and the RPC all report
1955
+ * the steer the same way without each emitting by hand. The steer tools
1956
+ * bypass this method because they await delivery.
1957
+ */
1958
+ steer(id: string, message: string): boolean {
1959
+ const record = this.agents.get(id);
1960
+ if (!record) return false;
1961
+ if (record.status !== "running" && record.status !== "queued") return false;
1962
+ if (record.session) {
1963
+ record.session.steer(message).catch(() => {});
1964
+ } else {
1965
+ if (!record.pendingSteers) record.pendingSteers = [];
1966
+ record.pendingSteers.push(message);
1967
+ }
1968
+ this.onSteered?.(record, message);
1969
+ return true;
1970
+ }
1971
+
1972
+ getRecord(id: string): AgentRecord | undefined {
1973
+ return this.agents.get(id);
1974
+ }
1975
+
1976
+ /**
1977
+ * Handles held outside this manager, e.g. by live survivors of an orphaned one, which a fresh
1978
+ * spawn must not reuse: a listener keys lifecycle events by handle, so a twin would mix the two up.
1979
+ */
1980
+ reservedHandles: () => Iterable<string> = () => [];
1981
+
1982
+ /** Handles already in use, so a fresh spawn can pick an unclaimed one. */
1983
+ private takenHandles(): Set<string> {
1984
+ const taken = new Set<string>(this.reservedHandles());
1985
+ for (const record of this.agents.values()) {
1986
+ if (record.handle) taken.add(record.handle);
1987
+ if (record.alias) taken.add(record.alias);
1988
+ }
1989
+ // Tombstones hold their names too: an evicted `@explore` is still
1990
+ // resurrectable, so a later Explore must become `explore-2` rather than
1991
+ // shadowing a conversation the user can still reach.
1992
+ for (const entry of this.tombstones.values()) {
1993
+ taken.add(entry.handle);
1994
+ if (entry.alias) taken.add(entry.alias);
1995
+ }
1996
+ return taken;
1997
+ }
1998
+
1999
+ /**
2000
+ * Resolve an `@name` from the prompt. Matches a top-level agent's handle or
2001
+ * alias case-insensitively, then falls back to an exact agent id so
2002
+ * `@<agentId>` works too.
2003
+ */
2004
+ resolveMention(name: string): MentionResolution | undefined {
2005
+ const wanted = name.toLowerCase();
2006
+ for (const record of this.agents.values()) {
2007
+ if (record.parentAgentId !== undefined) continue;
2008
+ // Handle and alias share one lowercase namespace (`takenHandles`, and a
2009
+ // reopen reclaims the evicted record's own names), so at most one agent
2010
+ // answers a name and it makes no difference which of the two matched.
2011
+ if (record.handle === wanted || record.alias === wanted) return { kind: "live", record };
2012
+ }
2013
+ const byId = this.agents.get(name);
2014
+ if (byId?.parentAgentId === undefined && byId !== undefined) return { kind: "live", record: byId };
2015
+ // Only once nothing live answers: a tombstone is a conversation to reopen,
2016
+ // and reopening one while its record still exists would fork the session.
2017
+ for (const entry of this.tombstones.values()) {
2018
+ if (entry.handle.toLowerCase() === wanted || entry.alias?.toLowerCase() === wanted || entry.id === name) {
2019
+ return { kind: "tombstone", entry };
2020
+ }
2021
+ }
2022
+ return undefined;
2023
+ }
2024
+
2025
+ /**
2026
+ * Forget an evicted agent, by handle. For the case where its session file has
2027
+ * gone: the entry can then only ever fail, while still holding the name
2028
+ * against the type that would otherwise start a fresh agent under it.
2029
+ *
2030
+ * A *successful* resume does not drop its tombstone — the live record it
2031
+ * creates already wins in `resolveMention`, and overwrites the entry in place
2032
+ * when it is itself evicted.
2033
+ */
2034
+ dropTombstone(handle: string): void {
2035
+ this.tombstones.delete(handle);
2036
+ }
2037
+
2038
+ /** Evicted agents whose conversation can still be reopened, newest first. */
2039
+ listTombstones(): AgentTombstone[] {
2040
+ return [...this.tombstones.values()].sort((a, b) => b.completedAt - a.completedAt);
2041
+ }
2042
+
2043
+ listAgents(): AgentRecord[] {
2044
+ return [...this.agents.values()].sort(
2045
+ (a, b) => b.startedAt - a.startedAt,
2046
+ );
2047
+ }
2048
+
2049
+ /**
2050
+ * Stop a running or queued agent; false when it was neither.
2051
+ *
2052
+ * @param by `"parent"` when the agent that spawned it asked (`stop_subagent`);
2053
+ * omitted for a human stop. Written on every stop, so a resumed record never
2054
+ * carries an earlier run's answer.
2055
+ */
2056
+ abort(id: string, by?: AgentRecord["stoppedBy"]): boolean {
2057
+ const record = this.agents.get(id);
2058
+ if (!record) return false;
2059
+
2060
+ // Remove from queue if queued. No decrement — the slot was never taken —
2061
+ // and no onComplete, matching what a queued background abort has always
2062
+ // done; a blocking caller learns of the stop from its own tool result.
2063
+ if (record.status === "queued") {
2064
+ this.dequeue(q => q.id === id);
2065
+ record.stoppedBy = by;
2066
+ this.stopQueued(record);
2067
+ return true;
2068
+ }
2069
+
2070
+ if (record.status !== "running") return false;
2071
+ // Before the abort: the run settles asynchronously and its notice reads this.
2072
+ record.stoppedBy = by;
2073
+ record.abortController?.abort();
2074
+ record.status = "stopped";
2075
+ record.completedAt = this.clock.now();
2076
+ return true;
2077
+ }
2078
+
2079
+ /** Mark a dequeued record stopped and tell `onQueuedStop`; the queue entry is already gone. */
2080
+ private stopQueued(record: AgentRecord): void {
2081
+ record.status = "stopped";
2082
+ record.completedAt = this.clock.now();
2083
+ this.onQueuedStop?.(record);
2084
+ }
2085
+
2086
+ /** Dispose a record's session and remove it from the map. */
2087
+ private removeRecord(id: string, record: SettledRecord): void {
2088
+ this.tombstone(record);
2089
+ this.noteEviction(record);
2090
+ const session = record.session;
2091
+ // Detached before the shutdown starts, so the record leaves the map at once and
2092
+ // nothing can observe a session that is half torn down.
2093
+ record.session = undefined;
2094
+ this.agents.delete(id);
2095
+ // A failed startup keeps its (rejected) entry so a late awaitStartup still
2096
+ // sees it; drop it with the record so the map can't grow unbounded.
2097
+ this.startups.delete(id);
2098
+ // Fire-and-forget is right here and only here: this runs from the 60s cleanup timer
2099
+ // and from `clearCompleted()` on session boundaries, with the process staying alive,
2100
+ // so handlers get their full window. The quit path awaits instead — see dispose().
2101
+ void shutdownChildSession(session);
2102
+ }
2103
+
2104
+ /**
2105
+ * Preserve enough of a departing record for `@handle` to reopen its
2106
+ * conversation later. Nothing to keep unless it has both a handle to be
2107
+ * addressed by and a session file to reopen — an in-memory session leaves no
2108
+ * transcript, so the mention would have nothing to continue from.
2109
+ */
2110
+ private tombstone(record: SettledRecord): void {
2111
+ const entry = this.tombstoneOf(record);
2112
+ if (entry) this.storeTombstone(entry);
2113
+ }
2114
+
2115
+ /** What `@handle` needs to reopen `record`'s conversation; undefined without a handle and a session file. */
2116
+ private tombstoneOf(record: SettledRecord): AgentTombstone | undefined {
2117
+ if (!record.handle || !record.sessionFile) return undefined;
2118
+ return {
2119
+ handle: record.handle,
2120
+ alias: record.alias,
2121
+ id: record.id,
2122
+ type: record.type,
2123
+ description: record.description,
2124
+ sessionFile: record.sessionFile,
2125
+ completedAt: record.completedAt,
2126
+ ...(record.modelTier ? { modelTier: record.modelTier } : {}),
2127
+ ...(record.modelFallback ? { modelFallback: record.modelFallback } : {}),
2128
+ };
2129
+ }
2130
+
2131
+ /** Tombstones for whichever of `ids` are settled here and reopenable, to hand to a session that will never see these records. */
2132
+ evictionTombstones(ids: readonly string[]): AgentTombstone[] {
2133
+ return [...this.agents.values()]
2134
+ .filter(isSettled)
2135
+ .filter(record => isTopLevelAgent(record) && ids.includes(record.id))
2136
+ .flatMap(record => this.tombstoneOf(record) ?? []);
2137
+ }
2138
+
2139
+ /**
2140
+ * Keep `entry` reopenable by handle, alias and id. Also how an agent that settled in an
2141
+ * orphaned manager (`/new`, `/reload`) stays reopenable here (#330). A name already held
2142
+ * by another live agent, an evicted conversation or a handle reserved for another manager's
2143
+ * survivor stays with its holder (as `takenHandles` has it): two conversations must not
2144
+ * answer to one `@name`. The entry is then not kept and its note says why.
2145
+ */
2146
+ rememberTombstone(entry: AgentTombstone): void {
2147
+ const names = [entry.handle, ...(entry.alias ? [entry.alias] : [])];
2148
+ const reserved = new Set<string>(this.reservedHandles());
2149
+ const owns = (own: { id: string; handle?: string; alias?: string }, name: string) =>
2150
+ own.id !== entry.id && (name === own.handle || name === own.alias);
2151
+ const taken = names.find(
2152
+ name =>
2153
+ reserved.has(name) ||
2154
+ [...this.agents.values()].some(own => owns(own, name)) ||
2155
+ [...this.tombstones.values()].some(own => owns(own, name)),
2156
+ );
2157
+ if (taken !== undefined) {
2158
+ const note = this.evictedNotes.get(entry.id);
2159
+ if (note) note.nameTaken = taken;
2160
+ return;
2161
+ }
2162
+ this.storeTombstone(entry);
2163
+ }
2164
+
2165
+ private storeTombstone(entry: AgentTombstone): void {
2166
+ this.tombstones.set(entry.handle, entry);
2167
+ // Bound the memory a long session can accumulate. Oldest first, since the
2168
+ // agent someone still wants to reach is the one they used most recently.
2169
+ while (this.tombstones.size > MAX_TOMBSTONES) {
2170
+ const oldest = [...this.tombstones.values()].reduce((a, b) => (a.completedAt <= b.completedAt ? a : b));
2171
+ this.tombstones.delete(oldest.handle);
2172
+ }
2173
+ }
2174
+
2175
+ /** Remember that `record` existed, so its id still answers with something better than "not found". */
2176
+ private noteEviction(record: SettledRecord): void {
2177
+ // Only the top-level tools read notes, and an owned child (nested or a
2178
+ // workflow's) must stay unreachable from them after eviction as before it.
2179
+ if (!isTopLevelAgent(record)) return;
2180
+ this.rememberEvicted(this.evictionNote(record));
2181
+ }
2182
+
2183
+ /** Notes for whichever of `ids` are settled here, to hand to a session that will never see these records. */
2184
+ evictionNotes(ids: readonly string[]): EvictedNote[] {
2185
+ return [...this.agents.values()].filter(isSettled).filter(record => isTopLevelAgent(record) && ids.includes(record.id)).map(record => this.evictionNote(record));
2186
+ }
2187
+
2188
+ /** What a settled record leaves behind once it is gone from this manager. */
2189
+ private evictionNote(record: SettledRecord): EvictedNote {
2190
+ return {
2191
+ id: record.id,
2192
+ type: record.type,
2193
+ description: record.description,
2194
+ status: record.status,
2195
+ evictedAfterMin: Math.max(0, Math.round((this.clock.now() - record.completedAt) / 60_000)),
2196
+ transcript: record.outputFile ?? record.sessionFile,
2197
+ ...(record.modelTier ? { modelTier: record.modelTier } : {}),
2198
+ ...(record.modelFallback ? { modelFallback: record.modelFallback } : {}),
2199
+ };
2200
+ }
2201
+
2202
+ /**
2203
+ * Keep `note` addressable by its id, bounded by age. Also how a result handed
2204
+ * over by an orphaned manager (`/new`, `/reload`) stays answerable here: the
2205
+ * record settled in a manager this one never saw (#330).
2206
+ */
2207
+ rememberEvicted(note: EvictedNote): void {
2208
+ this.evictedNotes.delete(note.id);
2209
+ this.evictedNotes.set(note.id, note);
2210
+ // Insertion order is age order (the note was just re-inserted above).
2211
+ for (const oldest of this.evictedNotes.keys()) {
2212
+ if (this.evictedNotes.size <= MAX_EVICTED_NOTES) break;
2213
+ this.evictedNotes.delete(oldest);
2214
+ }
2215
+ }
2216
+
2217
+ /** Record that evicted `id` was reopened as `next`; bounded like the eviction notes. */
2218
+ linkSuccessor(id: string, next: string): void {
2219
+ this.successors.delete(id);
2220
+ this.successors.set(id, next);
2221
+ // One link per call, so at most one over the bound: drop the oldest.
2222
+ if (this.successors.size > MAX_EVICTED_NOTES) this.successors.delete(this.successors.keys().next().value as string);
2223
+ }
2224
+
2225
+ /** Forget a reopen that never started, so `id` resolves to its tombstone again. */
2226
+ unlinkSuccessor(id: string): void {
2227
+ this.successors.delete(id);
2228
+ }
2229
+
2230
+ /** The newest id `id` continues under, following every reopen; `id` itself when never reopened. */
2231
+ latestId(id: string): string {
2232
+ // Acyclic: every successor is a freshly generated id.
2233
+ let current = id;
2234
+ for (let next = this.successors.get(current); next !== undefined; next = this.successors.get(current)) current = next;
2235
+ return current;
2236
+ }
2237
+
2238
+ /** What became of an evicted agent, by id; undefined for an id this session never evicted. */
2239
+ getEvictedNote(id: string): EvictedNote | undefined {
2240
+ return this.evictedNotes.get(id);
2241
+ }
2242
+
2243
+ /**
2244
+ * Evict finished records past their window: 10 minutes from the moment the
2245
+ * result was consumed, 60 from completion while nobody has read it. A consumed
2246
+ * record with no stamp (set directly, not through `markConsumed`) is aged from
2247
+ * completion.
2248
+ */
2249
+ private cleanup() {
2250
+ const now = this.clock.now();
2251
+ for (const [id, record] of this.agents) {
2252
+ if (!isSettled(record)) continue;
2253
+ const { completedAt } = record;
2254
+ const expired = record.resultConsumed
2255
+ ? now - (record.consumedAt ?? completedAt) >= this.consumedTtlMs
2256
+ : now - completedAt >= this.unreadTtlMs;
2257
+ if (!expired) continue;
2258
+ const ran = record.ranThisRun === true && (record.result !== undefined || record.error !== undefined);
2259
+ this.removeRecord(id, record);
2260
+ // Evicting a result nobody read is a loss the parent must hear about. Owned
2261
+ // children never leave a note, and only their owner could have read them.
2262
+ // Nothing was lost when the parent saw the completion notice (a failure's
2263
+ // stays unread in FleetView), or when no run produced a result (stopped in
2264
+ // the queue, refused, or failed before the runner started).
2265
+ const note = record.resultConsumed || record.noticeDelivered || !ran ? undefined : this.evictedNotes.get(id);
2266
+ if (note) this.onReaped?.(note);
2267
+ }
2268
+ }
2269
+
2270
+ /**
2271
+ * Remove all completed/stopped/errored records immediately.
2272
+ * Called on session start/switch so tasks from a prior session don't persist.
2273
+ * Pass skipUnconsumed=true to preserve records the LLM hasn't read yet
2274
+ * (resultConsumed=false) — they will be evicted by the cleanup timer instead (60 minutes).
2275
+ */
2276
+ clearCompleted(skipUnconsumed = false): void {
2277
+ for (const [id, record] of this.agents) {
2278
+ if (!isSettled(record)) continue;
2279
+ if (skipUnconsumed && !record.resultConsumed) continue;
2280
+ this.removeRecord(id, record);
2281
+ }
2282
+ // Unconditional: both callers are session boundaries (`session_start` and
2283
+ // `session_before_switch`), and `skipUnconsumed` only spares records whose
2284
+ // results the LLM has yet to read — it does not make the sweep partial in
2285
+ // the sense that matters here. A new session means new handles, or
2286
+ // `@explore` would silently reach an agent the user never started. Claude
2287
+ // Code resets its registry on `/clear` for the same reason.
2288
+ this.tombstones.clear();
2289
+ // Ids are only meaningful within the session that issued them.
2290
+ this.evictedNotes.clear();
2291
+ this.successors.clear();
2292
+ }
2293
+
2294
+ /** Whether any agents are still running or queued. */
2295
+ hasRunning(): boolean {
2296
+ return [...this.agents.values()].some(
2297
+ r => r.status === "running" || r.status === "queued",
2298
+ );
2299
+ }
2300
+
2301
+ /**
2302
+ * The session's own agents (see `isTopLevelAgent`) still running or queued,
2303
+ * split by whether their completion wakes the parent. A background agent's
2304
+ * settle sends a completion notice that starts a turn, in every join mode;
2305
+ * a foreground one, or an RPC spawn that omits `isBackground`, is marked
2306
+ * consumed at settle and notifies nobody. Owned children report through their owner, so counting
2307
+ * them would double-count the same work.
2308
+ */
2309
+ ownedPending(): OwnedPending {
2310
+ const pending = [...this.agents.values()].filter(
2311
+ r => isTopLevelAgent(r) && (r.status === "running" || r.status === "queued"),
2312
+ );
2313
+ const notifying = pending.filter(r => r.isBackground).length;
2314
+ return { notifying, silent: pending.length - notifying };
2315
+ }
2316
+
2317
+ /**
2318
+ * Abort everything that cannot outlive its session and let the rest finish.
2319
+ * Survivors are running top-level background agents and their nested children:
2320
+ * they own their own session and report through a notification. Queued agents
2321
+ * would start later against a stale context, foreground agents belong to a
2322
+ * tool call that is being torn down, and workflow children die with their
2323
+ * workflow, so all of those are aborted. A QUEUED nested child is kept: its
2324
+ * parent is a survivor waiting on it (the process budget can queue one), and
2325
+ * if the parent is stopped instead, its settle aborts the child.
2326
+ *
2327
+ * @returns how many agents keep running.
2328
+ */
2329
+ detach(): number {
2330
+ const keepQueued = ({ record }: QueueEntry): boolean =>
2331
+ record.parentAgentId !== undefined && record.workflowId === undefined;
2332
+ const dropped = this.queue.filter(entry => !keepQueued(entry)).map(({ record }) => record);
2333
+ this.dequeue(entry => !keepQueued(entry));
2334
+ for (const record of dropped) this.stopQueued(record);
2335
+ let survivors = 0;
2336
+ for (const record of this.agents.values()) {
2337
+ if (record.status !== "running") continue;
2338
+ const outlives = record.workflowId === undefined && (!!record.isBackground || record.parentAgentId !== undefined);
2339
+ if (outlives) {
2340
+ survivors++;
2341
+ continue;
2342
+ }
2343
+ record.abortController?.abort();
2344
+ record.status = "stopped";
2345
+ record.completedAt = this.clock.now();
2346
+ }
2347
+ return survivors;
2348
+ }
2349
+
2350
+ /** Abort all running and queued agents immediately. */
2351
+ abortAll(): number {
2352
+ let count = 0;
2353
+ // Clear queued agents first
2354
+ const dropped = this.queue.map(({ record }) => record);
2355
+ this.dequeue(() => true);
2356
+ for (const record of dropped) {
2357
+ this.stopQueued(record);
2358
+ count++;
2359
+ }
2360
+ // Abort running agents
2361
+ for (const record of this.agents.values()) {
2362
+ if (record.status === "running") {
2363
+ record.abortController?.abort();
2364
+ record.status = "stopped";
2365
+ record.completedAt = this.clock.now();
2366
+ count++;
2367
+ }
2368
+ }
2369
+ return count;
2370
+ }
2371
+
2372
+ /** Wait for all running and queued agents to complete (including queued ones). */
2373
+ async waitForAll(): Promise<void> {
2374
+ // Loop because drainQueue respects the concurrency limit — as running
2375
+ // agents finish they start queued ones, which need awaiting too.
2376
+ while (true) {
2377
+ this.drainQueue();
2378
+ const pending: Promise<unknown>[] = [];
2379
+ for (const record of this.agents.values()) {
2380
+ if (record.status !== "running" && record.status !== "queued") continue;
2381
+ // An agent whose worktree is still being created is "running" with no
2382
+ // `promise` yet — without its startup the wait would return too early.
2383
+ const startup = this.startups.get(record.id);
2384
+ if (startup) pending.push(startup);
2385
+ if (record.promise) pending.push(record.promise);
2386
+ }
2387
+ // A queued start may be waiting on another manager's leases, with
2388
+ // nothing in this one to wait on: wait for it to leave the queue.
2389
+ for (const entry of this.queue) pending.push(entry.left);
2390
+ if (pending.length === 0) break;
2391
+ await Promise.allSettled(pending);
2392
+ }
2393
+ }
2394
+
2395
+ /**
2396
+ * @param pi - Needed to run `git worktree prune`, which is async now and so
2397
+ * cannot be reached through a stored spawn argument at shutdown. Omitting
2398
+ * it (tests, teardown of a manager that never spawned) skips the prune.
2399
+ */
2400
+ async dispose(pi?: ExtensionAPI): Promise<void> {
2401
+ this.cleanupInterval.cancel();
2402
+ // Clear queue — via dequeue, so anyone blocked in spawnAndWait is woken
2403
+ // rather than left awaiting a gate nothing will ever resolve.
2404
+ this.dequeue(() => true);
2405
+ const sessions = [...this.agents.values()].map(record => record.session);
2406
+ this.agents.clear();
2407
+ this.startups.clear();
2408
+ if (pi) {
2409
+ // Prune any orphaned git worktrees (crash recovery). Detached: dispose runs
2410
+ // on the shutdown path, which cannot wait for git. Started before the awaited
2411
+ // shutdown below rather than after it, so the git calls have that window to
2412
+ // finish in instead of racing the process exit that follows.
2413
+ const prune = (repo: string) => { pruneWorktrees(pi, repo).catch(() => {}); };
2414
+ prune(process.cwd());
2415
+ // Also prune repos that caller-supplied cwds created worktrees in — a clean
2416
+ // exit with in-flight agents would otherwise leave stale registrations there.
2417
+ for (const repo of this.worktreeRepos) prune(repo);
2418
+ }
2419
+ // Awaited, unlike the eviction path: pi awaits this extension's `session_shutdown`
2420
+ // handler and the process exits right after it returns, so anything left unawaited
2421
+ // here never runs at all. Bounded — each call carries its own ceiling, concurrently.
2422
+ await Promise.all(sessions.map(session => shutdownChildSession(session)));
2423
+ // Leases are NOT released here. `shutdownChildSession` is bounded and does
2424
+ // not wait for a run's promise, a worktree copy or its cleanup, so work can
2425
+ // still be going on; each run hands its lease back from its own settle
2426
+ // path (`settleRun`, the resume tails, the startup-failure and mid-copy
2427
+ // exits), which no longer needs this manager's maps. A run that never
2428
+ // settles keeps its slot, which is the honest count.
2429
+ this.drainQueue();
2430
+ }
2431
+ }