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

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 (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1244 -2
  3. package/package.json +75 -4
  4. package/src/abortable.ts +43 -0
  5. package/src/agent-color.ts +161 -0
  6. package/src/agent-file-toggle.ts +297 -0
  7. package/src/agent-manager.ts +1874 -0
  8. package/src/agent-runner.ts +1552 -0
  9. package/src/agent-source-badge.ts +75 -0
  10. package/src/agent-types.ts +346 -0
  11. package/src/child-context.ts +15 -0
  12. package/src/clock.ts +25 -0
  13. package/src/completion-outbox.ts +125 -0
  14. package/src/context.ts +58 -0
  15. package/src/cross-extension-rpc.ts +221 -0
  16. package/src/custom-agents.ts +436 -0
  17. package/src/default-agents.ts +126 -0
  18. package/src/detached-results.ts +134 -0
  19. package/src/enabled-models.ts +180 -0
  20. package/src/enterprise-local.ts +66 -0
  21. package/src/env.ts +33 -0
  22. package/src/group-join.ts +148 -0
  23. package/src/index.ts +4423 -0
  24. package/src/invocation-config.ts +189 -0
  25. package/src/memory.ts +179 -0
  26. package/src/mention-clone.ts +195 -0
  27. package/src/mention.ts +141 -0
  28. package/src/model-call.ts +83 -0
  29. package/src/model-resolver.ts +266 -0
  30. package/src/model-scope.ts +70 -0
  31. package/src/nested-tools.ts +448 -0
  32. package/src/output-file.ts +164 -0
  33. package/src/package-resources.ts +624 -0
  34. package/src/prompts.ts +156 -0
  35. package/src/quota-admission.ts +99 -0
  36. package/src/schedule-store.ts +153 -0
  37. package/src/schedule.ts +446 -0
  38. package/src/settings.ts +728 -0
  39. package/src/skill-loader.ts +137 -0
  40. package/src/startup-error.ts +29 -0
  41. package/src/status-note.ts +90 -0
  42. package/src/structured-output.ts +130 -0
  43. package/src/types.ts +404 -0
  44. package/src/ui/agent-mention.ts +216 -0
  45. package/src/ui/agent-row.ts +235 -0
  46. package/src/ui/agent-widget.ts +692 -0
  47. package/src/ui/conversation-viewer.ts +832 -0
  48. package/src/ui/fleet-list.ts +675 -0
  49. package/src/ui/notification-view.ts +120 -0
  50. package/src/ui/schedule-menu.ts +105 -0
  51. package/src/ui/select-item.ts +45 -0
  52. package/src/ui/viewer-keys.ts +39 -0
  53. package/src/ui/workflow-card.ts +462 -0
  54. package/src/ui/workflow-dialog.ts +1115 -0
  55. package/src/ui/workflow-menu.ts +193 -0
  56. package/src/usage.ts +167 -0
  57. package/src/workflow/collisions.ts +123 -0
  58. package/src/workflow/entry.ts +47 -0
  59. package/src/workflow/host.ts +401 -0
  60. package/src/workflow/journal.ts +164 -0
  61. package/src/workflow/json-schema.ts +129 -0
  62. package/src/workflow/meta.ts +326 -0
  63. package/src/workflow/progress.ts +549 -0
  64. package/src/workflow/runtime.ts +1216 -0
  65. package/src/workflow/saved.ts +259 -0
  66. package/src/workflow/task.ts +302 -0
  67. package/src/workflow/tool-description.ts +200 -0
  68. package/src/workflow/worker-source.ts +781 -0
  69. package/src/worktree.ts +230 -0
  70. package/src/xml.ts +13 -0
@@ -0,0 +1,1874 @@
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
+
17
+ import { randomUUID } from "node:crypto";
18
+ import { statSync } from "node:fs";
19
+ import { isAbsolute } from "node:path";
20
+ import type { Model } from "@earendil-works/pi-ai";
21
+ import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
22
+ import { type AssistantMessageInfo, resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
23
+ import { getAgentConfig } from "./agent-types.js";
24
+ import { type Clock, realClock, type TimerHandle } from "./clock.js";
25
+ import { assignHandle, handleBase } from "./mention.js";
26
+ import type { ModelCallEvent } from "./model-call.js";
27
+ import { describeModel, describePhysicalModel, describeVirtualModel, isVirtualModel } from "./model-resolver.js";
28
+ import type { AgentInvocation, AgentRecord, AgentTombstone, IsolationMode, MentionResolution, Pool, SubagentType, ThinkingLevel } from "./types.js";
29
+ import { addUsage, type LifetimeUsage } from "./usage.js";
30
+ import type { CompiledSchema } from "./workflow/json-schema.js";
31
+ import { cleanupWorktree, createWorktree, isWorktreeIsolationEnabled, pruneWorktrees, type WorktreeInfo } from "./worktree.js";
32
+
33
+ /**
34
+ * Relabel a record's model from the response that just finished. A virtual
35
+ * selection (`router/default`) is only a name until a request is routed; the
36
+ * assistant message is the one place that records the physical model, so the
37
+ * label follows it (and follows a router fallback to the next model).
38
+ */
39
+ function relabelFromResponse(record: AgentRecord, message: AssistantMessageInfo | undefined): void {
40
+ const registry = (record.session as { modelRegistry?: Parameters<typeof describePhysicalModel>[1] } | undefined)
41
+ ?.modelRegistry;
42
+ const physical = describePhysicalModel(message, registry);
43
+ if (!physical) return;
44
+ record.invocation ??= {};
45
+ Object.assign(record.invocation, physical);
46
+ }
47
+
48
+ export type OnAgentComplete = (record: AgentRecord) => void;
49
+ export type OnAgentStart = (record: AgentRecord) => void;
50
+ /**
51
+ * Fired by `steer()` once a message is delivered or queued. Every steer path
52
+ * goes through it except the steer tools (`steer_subagent` in index.ts, and its
53
+ * nested twin in nested-tools.ts), which await the session directly.
54
+ */
55
+ export type OnAgentSteered = (record: AgentRecord, message: string) => void;
56
+ export type OnAgentCompact = (record: AgentRecord, info: CompactionInfo) => void;
57
+ /**
58
+ * Fired once per assistant `message_end`, for EVERY agent this manager owns —
59
+ * top-level and nested alike, spawns and resumes. The one place where each
60
+ * message is seen exactly once: `AgentRecord.lifetimeUsage` is deliberately
61
+ * double-booked into ancestors (see `nested-tools.ts`) so a hidden child's spend
62
+ * shows up on the record a human can see, which makes those records useless as
63
+ * a basis for anything that must not count a message twice — parent-session
64
+ * accounting above all.
65
+ */
66
+ export type OnAgentUsage = (record: AgentRecord, usage: LifetimeUsage) => void;
67
+ export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; tokensBefore: number };
68
+
69
+ /**
70
+ * Default max concurrent background agents.
71
+ *
72
+ * Raised from 4 when top-level spawns started defaulting to background
73
+ * (`backgroundByDefault`): foreground agents bypass this pool entirely, so
74
+ * while foreground was the default a fan-out of six ran six. With background
75
+ * as the default every top-level agent takes a slot, and a limit of 4 would
76
+ * have silently queued the tail of exactly the parallel fan-outs the `Agent`
77
+ * tool description tells the model to send.
78
+ */
79
+ const DEFAULT_MAX_CONCURRENT = 10;
80
+
81
+ /**
82
+ * Default max concurrent foreground (blocking) agents — `0` = unlimited, the
83
+ * extension's existing convention for "no ceiling" (`defaultMaxTurns`).
84
+ *
85
+ * Off by default because nothing here ever bounded foreground work, and pi
86
+ * dispatches a message's tool calls through `Promise.all`, so an unqualified
87
+ * fan-out of blocking `Agent` calls has always run all at once. Users who want
88
+ * it bounded — chiefly local models, where parallel agents thrash the prompt
89
+ * cache (#253) — opt in; everyone else keeps today's behaviour exactly.
90
+ */
91
+ const DEFAULT_MAX_CONCURRENT_FOREGROUND = 0;
92
+
93
+ /**
94
+ * How long a finished record stays in memory once its result was consumed
95
+ * (fetched, or delivered inline to a blocking caller). Nothing is waiting on it
96
+ * any more; the window only covers a follow-up `get_subagent_result` or steer.
97
+ */
98
+ const CONSUMED_RECORD_TTL_MS = 10 * 60_000;
99
+
100
+ /**
101
+ * How long a finished record nobody has read stays in memory, measured from
102
+ * completion. Far longer than the consumed window: an orchestrator that was busy
103
+ * when the notice arrived still gets to fetch the full result an hour later.
104
+ */
105
+ const UNCONSUMED_RECORD_TTL_MS = 60 * 60_000;
106
+
107
+ /**
108
+ * Read a retention override in minutes (fractions and 0 allowed) from `name`,
109
+ * else `fallbackMs`. An env var rather than a setting: the windows are not a
110
+ * user knob, but eviction is otherwise unobservable in an end-to-end test,
111
+ * which cannot wait an hour. Invalid values warn and keep the default.
112
+ */
113
+ function retentionMs(name: string, fallbackMs: number): number {
114
+ const raw = process.env[name]?.trim();
115
+ if (!raw) return fallbackMs;
116
+ const minutes = Number(raw);
117
+ if (!Number.isFinite(minutes) || minutes < 0) {
118
+ console.warn(`[pi-subagents] ignoring ${name}="${raw}": expected minutes >= 0`);
119
+ return fallbackMs;
120
+ }
121
+ return minutes * 60_000;
122
+ }
123
+
124
+ /** Sweep cadence: every minute, or as often as the shortest window needs, never under a second. */
125
+ const MAX_SWEEP_INTERVAL_MS = 60_000;
126
+ const MIN_SWEEP_INTERVAL_MS = 1_000;
127
+
128
+ /** How many evicted ids keep an answer to "what happened to that agent". */
129
+ const MAX_EVICTED_NOTES = 500;
130
+
131
+ /** Mark a record's result as read, which shortens its retention and suppresses its notice. */
132
+ export function markConsumed(record: Pick<AgentRecord, "resultConsumed" | "consumedAt">, now = Date.now()): void {
133
+ record.resultConsumed = true;
134
+ record.consumedAt = now;
135
+ }
136
+
137
+ /**
138
+ * What survives a record's eviction, so a later `get_subagent_result` or steer
139
+ * can say what became of the agent instead of "not found".
140
+ */
141
+ export interface EvictedNote {
142
+ id: string;
143
+ type: SubagentType;
144
+ description: string;
145
+ status: AgentRecord["status"];
146
+ /** Whole minutes between completion and eviction. */
147
+ evictedAfterMin: number;
148
+ /** The `.output` transcript when one was written, else the session file. */
149
+ transcript?: string;
150
+ }
151
+
152
+ /** The text a tool returns for an id whose record was evicted. */
153
+ export function describeEvicted(note: EvictedNote): string {
154
+ const where = note.transcript ? `; transcript: ${note.transcript}` : "";
155
+ return `Agent "${note.id}" (${note.description}) ${note.status}, evicted after ${note.evictedAfterMin} min${where}`;
156
+ }
157
+
158
+ /**
159
+ * How many evicted agents stay addressable by name. Only a bound on memory —
160
+ * a session that spawns hundreds of agents shouldn't retain every one — and
161
+ * far above the handful anyone keeps in their head.
162
+ */
163
+ const MAX_TOMBSTONES = 100;
164
+
165
+ /**
166
+ * Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset"
167
+ * (parent cwd). Anything else must be an absolute path to an existing
168
+ * directory — curated errors instead of TypeErrors from path/fs internals
169
+ * (RPC callers send arbitrary JSON: null, numbers, file paths).
170
+ */
171
+ function assertValidSpawnCwd(cwd: unknown): asserts cwd is string | undefined | null {
172
+ if (cwd == null) return;
173
+ if (typeof cwd !== "string" || !isAbsolute(cwd)) {
174
+ throw new Error(`SpawnOptions.cwd must be an absolute path: "${String(cwd)}"`);
175
+ }
176
+ let isDirectory = false;
177
+ try {
178
+ isDirectory = statSync(cwd).isDirectory();
179
+ } catch {
180
+ throw new Error(`SpawnOptions.cwd does not exist: "${cwd}"`);
181
+ }
182
+ if (!isDirectory) {
183
+ throw new Error(`SpawnOptions.cwd is not a directory: "${cwd}"`);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Whether a record occupies one of the `maxConcurrent` background slots.
189
+ * Nested children don't: their parent already holds a slot, so counting (and
190
+ * therefore queueing) them would deadlock a parent that waits on its own child.
191
+ *
192
+ * Note this bounds nothing horizontally — the depth cap limits how DEEP nesting
193
+ * goes, not how WIDE. A parent's only limit on concurrent children is that each
194
+ * spawn costs it a turn, which is unbounded when max turns is unlimited.
195
+ */
196
+ function occupiesPoolSlot(
197
+ record: Pick<AgentRecord, "isBackground" | "parentAgentId" | "workflowId">,
198
+ ): boolean {
199
+ return !!record.isBackground && isTopLevelAgent(record);
200
+ }
201
+
202
+ /**
203
+ * Whether a record is one of the session's own agents, rather than something
204
+ * another agent or a workflow owns.
205
+ *
206
+ * The single definition behind every user-facing surface — the fleet list, the
207
+ * widget, the `/agents` menus, `@handle` resolution, and the completion events
208
+ * and session entries. An owned child reports through its owner, so surfacing
209
+ * it separately would double-count the same work in the places a person reads.
210
+ */
211
+ export function isTopLevelAgent(
212
+ record: Pick<AgentRecord, "parentAgentId" | "workflowId">,
213
+ ): boolean {
214
+ return record.parentAgentId === undefined && record.workflowId === undefined;
215
+ }
216
+
217
+ /**
218
+ * Whether a record occupies one of the `maxConcurrentForeground` slots.
219
+ *
220
+ * Keyed on `blocking` — a caller awaiting this record inline — rather than on
221
+ * `isBackground === false`, because `spawn()` is also the funnel for DETACHED
222
+ * starts (cross-extension RPC, `@handle` mentions, the registry) that may pass
223
+ * `isBackground: false` and are documented to run immediately regardless. Those
224
+ * block nobody, so bounding them buys nothing and would park a record with no
225
+ * one waiting to release it.
226
+ *
227
+ * Nested children are excluded for the same reason as `occupiesPoolSlot`, and
228
+ * more sharply: their parent is blocked *awaiting them*, so queueing a child
229
+ * behind its own parent is a guaranteed deadlock rather than a possible one.
230
+ * Enforced here rather than at the call site so no caller can reintroduce it.
231
+ *
232
+ * A workflow's children go out through `spawnAndWait` and so are `blocking`
233
+ * too, and are excluded on the same `isTopLevelAgent` test as the background
234
+ * pool: the run already caps how many of its agents run at once, and charging
235
+ * them here as well would let one fan-out queue behind a limit meant for the
236
+ * session's own work.
237
+ *
238
+ * Like the background pool this bounds width at the top level only — a parent's
239
+ * own fan-out is limited by nothing but its turn budget.
240
+ */
241
+ function occupiesForegroundSlot(
242
+ record: Pick<AgentRecord, "blocking" | "parentAgentId" | "workflowId">,
243
+ ): boolean {
244
+ return !!record.blocking && isTopLevelAgent(record);
245
+ }
246
+
247
+ interface SpawnArgs {
248
+ pi: ExtensionAPI;
249
+ ctx: ExtensionContext;
250
+ type: SubagentType;
251
+ prompt: string;
252
+ options: SpawnOptions;
253
+ }
254
+
255
+ interface SpawnOptions {
256
+ description: string;
257
+ /**
258
+ * Optional memorable name for this instance, becoming a second handle
259
+ * (`@auth-audit`) alongside the type-derived one. Slugged, not validated —
260
+ * anything unusable degrades via `handleBase` rather than failing the spawn.
261
+ */
262
+ name?: string;
263
+ /**
264
+ * Reopen this pi session file instead of starting a fresh conversation, so a
265
+ * mention of an evicted agent continues where it left off. The agent's
266
+ * definition is still resolved from its type, so the continuation runs under
267
+ * the type's CURRENT config.
268
+ */
269
+ resumeSessionFile?: string;
270
+ /**
271
+ * Take an evicted agent's names back verbatim instead of allocating fresh
272
+ * ones, so a resumed conversation keeps the handle the user just typed —
273
+ * `handleBase(type)` cannot reproduce a numbered `explore-2`. Safe without an
274
+ * `assignHandle` pass because tombstoned names are excluded from allocation
275
+ * (`takenHandles`), so nothing live can be holding them.
276
+ *
277
+ * Internal capability, like `resumeSessionFile`: a forged handle would
278
+ * duplicate a live agent's name and make `resolveMention` ambiguous, so
279
+ * `spawnTopLevel` strips it from anything a caller sends.
280
+ */
281
+ reclaim?: { handle: string; alias?: string };
282
+ model?: Model<any>;
283
+ maxTurns?: number;
284
+ isolated?: boolean;
285
+ inheritContext?: boolean;
286
+ thinkingLevel?: ThinkingLevel;
287
+ isBackground?: boolean;
288
+ /**
289
+ * Skip whichever pool's queue check applies to this spawn — start immediately
290
+ * even if the configured concurrency limit would otherwise queue it. The slot
291
+ * is still COUNTED once the run starts, so a bypassing spawn transiently
292
+ * exceeds the limit rather than being invisible to it.
293
+ *
294
+ * Used by the scheduler, so a fired job can't be deferred past its trigger
295
+ * window, and by the `/agents` agent-file generator, which has no way to
296
+ * cancel a wait (see its call site).
297
+ */
298
+ bypassQueue?: boolean;
299
+ /**
300
+ * A caller is awaiting this record inline (`spawnAndWait`) — what
301
+ * `maxConcurrentForeground` bounds. Set only by `spawnAndWait`; stripped from
302
+ * caller-supplied options by `spawnTopLevel`, since a forged `blocking` would
303
+ * defer a detached start behind a queue its caller cannot see or release.
304
+ */
305
+ blocking?: boolean;
306
+ /**
307
+ * The workflow run this child belongs to, when a workflow spawned it.
308
+ *
309
+ * Ownership, not decoration. A workflow's children are the workflow's — they
310
+ * report through its card, its notification and its dialog, so they are
311
+ * filtered out of every top-level surface exactly as nested children are, and
312
+ * they take no `maxConcurrent` slot: the run has its own concurrency cap, and
313
+ * counting them twice would let one workflow starve the whole session.
314
+ */
315
+ workflowId?: string;
316
+ /**
317
+ * Make the child report through a `StructuredOutput` tool built from this
318
+ * compiled schema. Set only by the workflow host, for `agent({ schema })`.
319
+ */
320
+ structuredOutput?: CompiledSchema;
321
+ /** Isolation mode — "worktree" creates a temp git worktree for the agent. */
322
+ isolation?: IsolationMode;
323
+ /**
324
+ * Working directory for the agent (absolute path). Default: parent session
325
+ * cwd. The agent's tools operate here, but .pi config (extensions, skills,
326
+ * settings, memory) still loads from the parent session's project — the
327
+ * target directory's `.pi` extensions never execute. With isolation:
328
+ * "worktree", the worktree is created FROM this directory and the result
329
+ * branch lands in that repo.
330
+ */
331
+ cwd?: string;
332
+ /**
333
+ * Last chance to look at an isolated agent's worktree, awaited immediately
334
+ * before it is committed to a branch and removed.
335
+ *
336
+ * Exists because that removal happens inside the settle path, before
337
+ * `spawnAndWait` resolves: by the time a caller has the finished record, the
338
+ * directory the child actually wrote in is gone. Anything that must inspect
339
+ * or verify that tree — a workflow `gate` is the motivating case — has to run
340
+ * here or it silently inspects the main tree instead.
341
+ *
342
+ * Fires only on the normal settle path, and only when a worktree was created.
343
+ * Not on the error path and not on the stop-during-copy guard: those are
344
+ * already failing, and delaying cleanup there would leak a copy for no gain.
345
+ * A rejection is swallowed — the hook can never keep the worktree alive.
346
+ */
347
+ onBeforeWorktreeCleanup?: (worktreePath: string) => Promise<void>;
348
+ /** Resolved invocation snapshot captured for UI display. */
349
+ invocation?: AgentInvocation;
350
+ /** Parent abort signal — when aborted, the subagent is also stopped. */
351
+ signal?: AbortSignal;
352
+ /**
353
+ * Called synchronously once the record is in the map and its promise is set,
354
+ * before `onSessionCreated` fires — where callers attach the output file.
355
+ *
356
+ * Carried on the options rather than parked on the manager for the duration
357
+ * of a spawn: with a foreground queue, `startAgent` can run at drain time,
358
+ * long after any such field would have been restored, and the callback would
359
+ * silently never fire (or fire into an unrelated caller's closure).
360
+ */
361
+ onSpawned?: (id: string) => void;
362
+ /**
363
+ * Called synchronously when the spawn is queued instead of started, with how
364
+ * many entries in its own pool are ahead of it. The foreground UI uses it to
365
+ * say so while it waits; nothing else needs it.
366
+ */
367
+ onQueued?: (id: string, ahead: number) => void;
368
+ /** Called on tool start/end with activity info (for streaming progress to UI). */
369
+ onToolActivity?: (activity: ToolActivity) => void;
370
+ /** Called on streaming text deltas from the assistant response. */
371
+ onTextDelta?: (delta: string, fullText: string) => void;
372
+ /** Called for every streaming event of each model call (for the in-flight activity line). */
373
+ onModelCall?: (event: ModelCallEvent) => void;
374
+ /** Called when the agent session is created (for accessing session stats). */
375
+ onSessionCreated?: (session: AgentSession) => void;
376
+ /** Called at the end of each agentic turn with the cumulative count. */
377
+ onTurnEnd?: (turnCount: number) => void;
378
+ /** Called once per assistant message_end with that message's usage delta. */
379
+ onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
380
+ /** Called when the session successfully compacts. */
381
+ onCompaction?: (info: CompactionInfo) => void;
382
+ /** Nesting depth: top-level subagent = 1. */
383
+ depth?: number;
384
+ /** Parent agent ID for ownership-scoped nested controls. */
385
+ parentAgentId?: string;
386
+ /** Effective inherited nesting cap for this branch. */
387
+ maxSubagentDepth?: number;
388
+ /** Config-discovery root inherited by nested launches when it differs from the working directory. */
389
+ configCwd?: string;
390
+ /** Project-trust decision for `configCwd`; only meaningful paired with it, set by nested launches. */
391
+ configProjectTrusted?: boolean;
392
+ /** Root session id, inherited by nested launches so transcripts stay grouped. */
393
+ rootSessionId?: string;
394
+ }
395
+
396
+ interface ResumeOptions {
397
+ /**
398
+ * Run the resumed turn detached in the background: return immediately with
399
+ * the record still "running" (or "queued" at the concurrency limit) and
400
+ * notify on completion via onComplete, exactly like a background spawn.
401
+ * Default (false/undefined) runs the resume inline and returns the settled
402
+ * record — the historical behavior.
403
+ */
404
+ isBackground?: boolean;
405
+ /**
406
+ * Explicit turn limit for the resumed run: this, else the limit the agent
407
+ * was spawned with, else its type's own `max_turns`, else the default —
408
+ * counted afresh from the resume prompt (#368).
409
+ */
410
+ maxTurns?: number;
411
+ /** Called on streaming text and model-call changes during this resume. */
412
+ onTextDelta?: (delta: string, fullText: string) => void;
413
+ onModelCall?: (event: ModelCallEvent) => void;
414
+ /** Called at the end of each resumed turn with the count since the resume. */
415
+ onTurnEnd?: (turnCount: number) => void;
416
+ /** Called on tool start/end with activity info (for streaming progress to UI). */
417
+ onToolActivity?: (activity: ToolActivity) => void;
418
+ /** Called once per assistant message_end with that message's usage delta. */
419
+ onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
420
+ /** Called when the session successfully compacts. */
421
+ onCompaction?: (info: CompactionInfo) => void;
422
+ /**
423
+ * Background resume only: called synchronously when the run actually starts —
424
+ * immediately, or later from drainQueue. Callers wire per-run side effects
425
+ * (output-file streaming) here rather than at the call site, so a resume that
426
+ * is stopped while still queued never leaves a subscription behind: `abort()`
427
+ * drops a queued record without reaching `settle()`, which is what would have
428
+ * torn that subscription down.
429
+ */
430
+ onStarted?: () => void;
431
+ }
432
+
433
+ /** Best-effort ceiling on one child's shutdown handlers, so teardown can't strand a quit. */
434
+ const CHILD_SHUTDOWN_TIMEOUT_MS = 3_000;
435
+
436
+ /**
437
+ * Close the extension lifecycle `runAgent` opened with `bindExtensions`, then dispose.
438
+ *
439
+ * `AgentSession.dispose()` only calls `ExtensionRunner.invalidate()` — pi emits the event
440
+ * itself in `AgentSessionRuntime.dispose()` beforehand, and this is the one place that binds
441
+ * extensions onto a session without going through that path. Without the emit, everything an
442
+ * extension armed in `session_start` leaks once per spawn, and its next tick throws
443
+ * `assertActive()` from a bare timer callback — an uncaughtException that kills pi (#242).
444
+ */
445
+ async function shutdownChildSession(session: AgentSession | undefined): Promise<void> {
446
+ try {
447
+ const runner = session?.extensionRunner;
448
+ // Optional all the way down: on a pi without the getter, or a stubbed session from a
449
+ // partial `onSessionCreated`, skip the emit — the same degrade as before this fix.
450
+ if (runner?.hasHandlers?.("session_shutdown")) {
451
+ // Raced, not awaited outright. `emit` runs every handler serially with no timeout of
452
+ // its own, and dispose() is reached from pi's own `session_shutdown` with the TUI
453
+ // already torn down — one hung handler would leave a dead terminal.
454
+ await Promise.race([
455
+ runner.emit({ type: "session_shutdown", reason: "quit" }),
456
+ new Promise<void>(resolve => setTimeout(resolve, CHILD_SHUTDOWN_TIMEOUT_MS).unref()),
457
+ ]);
458
+ }
459
+ } catch { /* a partial session must degrade, not take the teardown down with it */ }
460
+ // Always, even on timeout: disposal is what this function ultimately exists to do.
461
+ try { session?.dispose?.(); } catch { /* ignore */ }
462
+ }
463
+
464
+ export class AgentManager {
465
+ private agents = new Map<string, AgentRecord>();
466
+ private cleanupInterval: TimerHandle;
467
+ /** `CONSUMED_RECORD_TTL_MS` unless `PI_SUBAGENTS_CONSUMED_TTL_MIN` overrides it. */
468
+ private readonly consumedTtlMs = retentionMs("PI_SUBAGENTS_CONSUMED_TTL_MIN", CONSUMED_RECORD_TTL_MS);
469
+ /** `UNCONSUMED_RECORD_TTL_MS` unless `PI_SUBAGENTS_UNREAD_TTL_MIN` overrides it. */
470
+ private readonly unreadTtlMs = retentionMs("PI_SUBAGENTS_UNREAD_TTL_MIN", UNCONSUMED_RECORD_TTL_MS);
471
+ private onComplete?: OnAgentComplete;
472
+ private onStart?: OnAgentStart;
473
+ private onCompact?: OnAgentCompact;
474
+ private onUsage?: OnAgentUsage;
475
+ private onSteered?: OnAgentSteered;
476
+ private maxConcurrent: number;
477
+ private maxConcurrentForeground = DEFAULT_MAX_CONCURRENT_FOREGROUND;
478
+ /** Base repos worktrees were created from — so dispose() can prune them all,
479
+ * not just the parent repo (caller-supplied cwd can target other repos). */
480
+ private worktreeRepos = new Set<string>();
481
+
482
+ /**
483
+ * Startup phases, keyed by agent id. `spawn()` still returns synchronously,
484
+ * but an agent using worktree isolation is not running yet when it does —
485
+ * copying the repo is an awaited git call. This is what `awaitStartup` hands
486
+ * callers that must fail their tool call on a startup failure, and what
487
+ * `waitForAll` waits on while a record is "running" with no `promise` yet.
488
+ * Entries are dropped once the run is underway, and kept (rejected) after a
489
+ * startup failure so a late `awaitStartup` still sees it.
490
+ */
491
+ private startups = new Map<string, Promise<void>>();
492
+
493
+ /**
494
+ * Evicted agents that can still be reached by name, keyed by handle. Outlives
495
+ * the 10-minute record cleanup — that timer exists to bound memory, not to
496
+ * expire a conversation the user might still want — and is cleared alongside
497
+ * completed records on session start/switch.
498
+ */
499
+ private tombstones = new Map<string, AgentTombstone>();
500
+
501
+ /** Evicted records by id, oldest first (insertion order). */
502
+ private evictedNotes = new Map<string, EvictedNote>();
503
+
504
+ /**
505
+ * Agents waiting to start, tagged with the pool they wait on. One queue for
506
+ * both pools: `drainQueue` picks the earliest entry whose own pool has room,
507
+ * so neither can head-of-line-block the other, and every removal path
508
+ * (`abort`, `abortAll`, `dispose`) stays a single filter.
509
+ *
510
+ * `release` wakes a caller blocked in `spawnAndWait`, and is fired once the
511
+ * entry's `start` has SETTLED rather than at drain time: startup is async
512
+ * now, so releasing earlier would wake the caller before `record.promise`
513
+ * exists and it would read a still-starting agent as one that never ran.
514
+ * Removing an entry from this array MUST release it — a queued record has no
515
+ * promise to await, and pi has no tool-execution timeout to bail the caller
516
+ * out.
517
+ */
518
+ private queue: { id: string; pool: Pool; start: () => Promise<void>; release: () => void }[] = [];
519
+ /** Number of currently running background agents. */
520
+ private runningBackground = 0;
521
+ /** Number of currently running foreground (blocking) agents. */
522
+ private runningForeground = 0;
523
+
524
+ constructor(
525
+ onComplete?: OnAgentComplete,
526
+ maxConcurrent = DEFAULT_MAX_CONCURRENT,
527
+ onStart?: OnAgentStart,
528
+ onCompact?: OnAgentCompact,
529
+ onUsage?: OnAgentUsage,
530
+ onSteered?: OnAgentSteered,
531
+ private readonly clock: Clock = realClock,
532
+ ) {
533
+ this.onComplete = onComplete;
534
+ this.onStart = onStart;
535
+ this.onCompact = onCompact;
536
+ this.onUsage = onUsage;
537
+ this.onSteered = onSteered;
538
+ this.maxConcurrent = maxConcurrent;
539
+ // Sweep finished records past their retention window (see cleanup()).
540
+ const sweepMs = Math.min(
541
+ MAX_SWEEP_INTERVAL_MS,
542
+ Math.max(MIN_SWEEP_INTERVAL_MS, Math.min(this.consumedTtlMs, this.unreadTtlMs)),
543
+ );
544
+ this.cleanupInterval = this.clock.setInterval(() => this.cleanup(), sweepMs);
545
+ this.cleanupInterval.unref?.();
546
+ }
547
+
548
+ /** Update the max concurrent background agents limit. */
549
+ setMaxConcurrent(n: number) {
550
+ this.maxConcurrent = Math.max(1, n);
551
+ // Start queued agents if the new limit allows
552
+ this.drainQueue();
553
+ }
554
+
555
+ getMaxConcurrent(): number {
556
+ return this.maxConcurrent;
557
+ }
558
+
559
+ /** Update the max concurrent foreground (blocking) agents limit. 0 = unlimited. */
560
+ setMaxConcurrentForeground(n: number) {
561
+ // Floor 0, not 1: unlimited is a meaningful value here and the default.
562
+ this.maxConcurrentForeground = Math.max(0, n);
563
+ // Start queued agents if the new limit allows — including everything, when
564
+ // the limit is cleared back to unlimited mid-run.
565
+ this.drainQueue();
566
+ }
567
+
568
+ getMaxConcurrentForeground(): number {
569
+ return this.maxConcurrentForeground;
570
+ }
571
+
572
+ /**
573
+ * Which pool a spawn is charged to, or undefined for one that is charged to
574
+ * neither (nested children, detached non-background spawns).
575
+ *
576
+ * Nothing here queues when the limit is unset — `poolHasRoom` reports an
577
+ * unlimited pool as always having room, so that alone is what keeps the
578
+ * default path identical. The `> 0` guard is belt and braces on top: it also
579
+ * keeps the counter from churning and the settle path from calling a drain
580
+ * that would find nothing to do. Both are unobservable, which is why no test
581
+ * pins them; the observable half — that the default start stays synchronous —
582
+ * is pinned in `test/foreground-concurrency.test.ts`.
583
+ */
584
+ private poolFor(record: AgentRecord): Pool | undefined {
585
+ if (occupiesPoolSlot(record)) return "background";
586
+ if (this.maxConcurrentForeground > 0 && occupiesForegroundSlot(record)) return "foreground";
587
+ return undefined;
588
+ }
589
+
590
+ private poolHasRoom(pool: Pool): boolean {
591
+ return pool === "background"
592
+ ? this.runningBackground < this.maxConcurrent
593
+ : this.maxConcurrentForeground === 0 || this.runningForeground < this.maxConcurrentForeground;
594
+ }
595
+
596
+ /**
597
+ * Spawn an agent and return its ID immediately (for background use).
598
+ * If the concurrency limit is reached, the agent is queued.
599
+ *
600
+ * The id comes back synchronously, but with `isolation: "worktree"` the agent
601
+ * is not running yet when it does — the repo copy is an awaited git call.
602
+ * Callers that must fail a tool call on a startup failure await
603
+ * `awaitStartup(id)`; everyone else sees it on the record (status "error").
604
+ */
605
+ spawn(
606
+ pi: ExtensionAPI,
607
+ ctx: ExtensionContext,
608
+ type: SubagentType,
609
+ prompt: string,
610
+ options: SpawnOptions,
611
+ ): string {
612
+ // Validate before the queue branch — a queued spawn should fail at the
613
+ // call, not minutes later at drain. Throw (not warn): programmatic callers
614
+ // can fix and retry; the RPC layer converts throws into error envelopes.
615
+ assertValidSpawnCwd(options.cwd);
616
+
617
+ const id = randomUUID().slice(0, 17);
618
+ const abortController = new AbortController();
619
+ const record: AgentRecord = {
620
+ id,
621
+ type,
622
+ // Owned children — nested, or a workflow's — are filtered out of every
623
+ // top-level surface, so no handle: nothing can address them and they must
624
+ // not consume a name a top-level sibling could otherwise take.
625
+ handle: !isTopLevelAgent(options)
626
+ ? undefined
627
+ // A reclaimed handle is used as-is: it belongs to the conversation this
628
+ // spawn is reopening, and re-deriving it would lose the numbering.
629
+ : options.reclaim?.handle ?? assignHandle(handleBase(type), this.takenHandles()),
630
+ description: options.description,
631
+ // Reclaimed here, or filled in below from `name` — in which case it must
632
+ // see the handle this record just took, since both come out of the same
633
+ // namespace.
634
+ alias: isTopLevelAgent(options) ? options.reclaim?.alias : undefined,
635
+ // Overwritten below when the spawn is actually queued; a foreground spawn
636
+ // that queues flips to "queued" there rather than being guessed at here,
637
+ // since the pool decision needs the finished record.
638
+ status: options.isBackground ? "queued" : "running",
639
+ toolUses: 0,
640
+ startedAt: this.clock.now(),
641
+ abortController,
642
+ lifetimeUsage: { input: 0, output: 0, cacheWrite: 0, cost: 0 },
643
+ compactionCount: 0,
644
+ // Raw tri-state (not coerced to a boolean): true = background, false =
645
+ // foreground (has an inline tool-result surface), undefined = caller never
646
+ // declared it (e.g. a cross-extension RPC spawn). The widget's background-
647
+ // only filter excludes only explicit `false`, so undefined agents — which
648
+ // have no inline surface — stay visible instead of vanishing.
649
+ isBackground: options.isBackground,
650
+ // Whether anyone is awaiting this agent is a property of the agent, not
651
+ // of the call that made it — and both settle paths need it long after
652
+ // `options` has stopped being the interesting object.
653
+ blocking: options.blocking,
654
+ invocation: options.invocation,
655
+ depth: options.depth ?? 1,
656
+ parentAgentId: options.parentAgentId,
657
+ workflowId: options.workflowId,
658
+ maxSubagentDepth: options.maxSubagentDepth,
659
+ rootSessionId: options.rootSessionId,
660
+ };
661
+ this.agents.set(id, record);
662
+ // After the insert, so `takenHandles()` already counts this record's own
663
+ // handle — a spawn named after its own type gets `explore-2`, not a
664
+ // duplicate `explore` that would make resolution ambiguous.
665
+ if (record.handle !== undefined && record.alias === undefined && options.name !== undefined) {
666
+ record.alias = assignHandle(handleBase(options.name), this.takenHandles());
667
+ }
668
+
669
+ const args: SpawnArgs = { pi, ctx, type, prompt, options };
670
+
671
+ const pool = this.poolFor(record);
672
+ if (pool !== undefined && !options.bypassQueue && !this.poolHasRoom(pool)) {
673
+ // Queue it — started when a running agent in the same pool completes.
674
+ // Idempotent for background (already "queued"); the flip that matters is
675
+ // a blocking foreground spawn, optimistically marked "running" above.
676
+ record.status = "queued";
677
+ // A queued record never reaches startAgent's signal wiring, so arm the
678
+ // parent abort here or Esc could not release the position.
679
+ if (!this.armQueuedAbort(id, options.signal)) return id;
680
+ let release!: () => void;
681
+ record.startGate = new Promise<void>(resolve => { release = resolve; });
682
+ this.queue.push({
683
+ id,
684
+ pool,
685
+ start: () => this.launch(id, record, args, pool),
686
+ release: () => release(),
687
+ });
688
+ options.onQueued?.(id, this.queue.filter(e => e.pool === pool).length - 1);
689
+ return id;
690
+ }
691
+
692
+ this.launch(id, record, args, undefined);
693
+ return id;
694
+ }
695
+
696
+ /**
697
+ * Wire a parent abort signal for a record that is about to be QUEUED.
698
+ * `startAgent` does this for running agents, and a queued record never gets
699
+ * there, so without this Esc could not release a queue position.
700
+ *
701
+ * Returns false when the signal is ALREADY aborted, in which case the record
702
+ * is stopped here and must not be enqueued: `addEventListener` never fires on
703
+ * an aborted signal, so a `spawnAndWait` on it would wait forever — pi has no
704
+ * tool-execution timeout to bail it out.
705
+ *
706
+ * The listener is removable (`record.detachParentAbort`): `startAgent`
707
+ * replaces it with its own, and `sendToBackground` must be able to take it
708
+ * off, or the caller that armed it could stop a run it no longer owns.
709
+ */
710
+ private armQueuedAbort(id: string, signal?: AbortSignal): boolean {
711
+ if (signal === undefined) return true;
712
+ if (signal.aborted) {
713
+ const record = this.agents.get(id);
714
+ if (record) {
715
+ record.status = "stopped";
716
+ record.completedAt = this.clock.now();
717
+ }
718
+ return false;
719
+ }
720
+ const record = this.agents.get(id);
721
+ if (record) this.armParentAbort(record, signal);
722
+ return true;
723
+ }
724
+
725
+ /**
726
+ * Stop `record` when its caller's `signal` aborts, replacing any listener an
727
+ * earlier stage armed — at most one is live per record, so one call to
728
+ * `detachParentAbort` cuts the run loose from its caller.
729
+ */
730
+ private armParentAbort(record: AgentRecord, signal: AbortSignal | undefined): void {
731
+ this.detachParentAbort(record);
732
+ if (signal === undefined) return;
733
+ const onParentAbort = () => this.abort(record.id);
734
+ signal.addEventListener("abort", onParentAbort, { once: true });
735
+ record.detachParentAbort = () => signal.removeEventListener("abort", onParentAbort);
736
+ }
737
+
738
+ private detachParentAbort(record: AgentRecord): void {
739
+ record.detachParentAbort?.();
740
+ record.detachParentAbort = undefined;
741
+ }
742
+
743
+ /** Add `delta` to the running count of `pool`; no pool, no count. */
744
+ private chargePool(pool: Pool | undefined, delta: 1 | -1): void {
745
+ if (pool === "background") this.runningBackground += delta;
746
+ else if (pool === "foreground") this.runningForeground += delta;
747
+ }
748
+
749
+ /**
750
+ * Kick off an agent's startup and register it under `startups`. The returned
751
+ * promise never rejects — the failure is delivered through `awaitStartup`,
752
+ * and to the record.
753
+ *
754
+ * @param queuedPool - The pool this start was QUEUED on, or undefined for an
755
+ * immediate start. A queue drain can be minutes after `spawn()` returned,
756
+ * and nobody is awaiting `awaitStartup` by then, so a failure has to live
757
+ * on the record as status "error" — what drainQueue did when the throw was
758
+ * still synchronous. An immediate start instead drops the record, exactly
759
+ * as the throw out of `spawn()` did: no orphan in `listAgents()`, and the
760
+ * handle goes back.
761
+ */
762
+ private launch(id: string, record: AgentRecord, args: SpawnArgs, queuedPool: Pool | undefined): Promise<void> {
763
+ const startup = this.startAgent(id, record, args).then(
764
+ () => { this.startups.delete(id); },
765
+ (err) => {
766
+ this.startups.delete(id);
767
+ if (queuedPool !== undefined) {
768
+ // Mirrors settleRun: an inline caller gets this failure as a throw
769
+ // out of spawnAndWait, so an unconsumed record would ALSO nudge the
770
+ // session about it — the same failure reported twice.
771
+ if (queuedPool === "foreground") markConsumed(record, this.clock.now());
772
+ record.status = "error";
773
+ record.error = err instanceof Error ? err.message : String(err);
774
+ record.completedAt = this.clock.now();
775
+ this.onComplete?.(record);
776
+ } else {
777
+ this.agents.delete(id);
778
+ }
779
+ // The agent never kept its slot (startAgent gives it back on failure),
780
+ // so anything queued behind it can go now.
781
+ this.drainQueue();
782
+ throw err;
783
+ },
784
+ );
785
+ this.startups.set(id, startup);
786
+ // Nothing is obliged to await `startups` — swallow the rejection once here
787
+ // so an unawaited startup can't take the process down, and hand callers
788
+ // (drainQueue) that swallowed promise.
789
+ return startup.catch(() => {});
790
+ }
791
+
792
+ /**
793
+ * Resolves once the agent is actually running, and rejects with the startup
794
+ * failure (strict worktree isolation) that `spawn()` used to throw before the
795
+ * repo copy became async. Resolves immediately for an agent that is already
796
+ * running, still queued, or unknown — so callers can await it unconditionally.
797
+ *
798
+ * Call it in the same tick as the `spawn()` it belongs to: a failed startup
799
+ * takes its record (and this entry) with it, exactly as the throw did.
800
+ */
801
+ awaitStartup(id: string): Promise<void> {
802
+ return this.startups.get(id) ?? Promise.resolve();
803
+ }
804
+
805
+ /** Actually start an agent (called immediately or from queue drain). */
806
+ private async startAgent(
807
+ id: string,
808
+ record: AgentRecord,
809
+ { pi, ctx, type, prompt, options }: SpawnArgs,
810
+ ) {
811
+ // Re-validate a caller-supplied cwd: queued spawns can start minutes after
812
+ // spawn()'s check, and the directory may be gone by then (TOCTOU). Same
813
+ // curated errors; drainQueue parks a throw on the record as an error.
814
+ assertValidSpawnCwd(options.cwd);
815
+ // Single resolution point for the caller-supplied cwd — the worktree base
816
+ // repo and both cleanup calls below MUST agree on this value forever.
817
+ const customCwd = options.cwd ?? undefined; // null (RPC "unset") → undefined
818
+ const baseCwd = customCwd ?? ctx.cwd;
819
+
820
+ // Take the running state — and with it the concurrency slot — BEFORE the
821
+ // first await. Creating a worktree is an awaited git call, and drainQueue
822
+ // reads the pool counters synchronously in a loop: incrementing after the
823
+ // await would let it start every queued agent at once while the first is
824
+ // still copying its repo. Claiming "running" here also keeps abort() and
825
+ // abortAll() able to reach an agent whose worktree is still being created.
826
+ //
827
+ // The pool is resolved ONCE, here, and recorded for `settleRun`:
828
+ // `poolFor` reads `maxConcurrentForeground`, which the user can change from
829
+ // `/agents → Settings` mid-run, so recomputing it at settle time would
830
+ // decrement a pool this run never charged (counter underflow, limit
831
+ // silently lifted) or skip the decrement for one it did (leaked slot —
832
+ // every later blocking spawn queues forever). Only `sendToBackground`
833
+ // changes it afterwards, moving the counters with it. The two startup exits
834
+ // below never reach `settleRun`, so they hand the slot back themselves.
835
+ record.chargedPool = this.poolFor(record);
836
+ record.status = "running";
837
+ record.startedAt = this.clock.now();
838
+ record.startGate = undefined;
839
+ this.chargePool(record.chargedPool, 1);
840
+
841
+ // Worktree isolation: try to create a temporary git worktree. Strict —
842
+ // fail loud if not possible (no silent fallback to main tree). Done BEFORE
843
+ // the run is kicked off so a failure doesn't leave a half-running agent.
844
+ // The project switch is enforced here as well as at the tool boundary
845
+ // because cross-extension RPC forwards its options unvalidated — a schema
846
+ // that omits the field can't stop a caller that never saw the schema.
847
+ // The agent file's `isolation:` is resolved here too, not by each caller:
848
+ // mentions pass none, and RPC forwards only its caller's value. Workflow
849
+ // children are the exception, as in Claude Code, whose workflow agent()
850
+ // reads only the script's option and ignores the definition's.
851
+ const isolation = options.workflowId !== undefined
852
+ ? options.isolation
853
+ : getAgentConfig(type)?.isolation ?? options.isolation;
854
+ let worktreeCwd: string | undefined;
855
+ if (isolation === "worktree" && isWorktreeIsolationEnabled()) {
856
+ let wt: WorktreeInfo;
857
+ try {
858
+ wt = await createWorktree(pi, baseCwd, id);
859
+ } catch (err) {
860
+ this.chargePool(record.chargedPool, -1);
861
+ const cause = err instanceof Error ? err.message : String(err);
862
+ // Only a parent-cwd spawn can be fixed by pointing `cwd` elsewhere.
863
+ const hint = customCwd === undefined
864
+ ? " To isolate another repo, pass its path as the `Agent` tool's `cwd`; or omit `isolation`."
865
+ : " Pass a `cwd` inside a git repo with at least one commit, or omit `isolation`.";
866
+ throw new Error(`Cannot run with isolation: "worktree": ${cause}.${hint}`);
867
+ }
868
+ record.worktree = wt;
869
+ // workPath preserves subdirectory scoping for caller-supplied cwds: a
870
+ // cwd deep in a monorepo maps to the same subdir inside the copy, not
871
+ // the copied repo's root. Plain worktree spawns keep the historical
872
+ // behavior (agent at the copy's root) — moving them to workPath would
873
+ // also move .pi config discovery when the parent session sits in a repo
874
+ // subdirectory, silently dropping extensions/skills.
875
+ worktreeCwd = customCwd !== undefined ? wt.workPath : wt.path;
876
+ this.worktreeRepos.add(baseCwd);
877
+ // The widget's "worktree" tag reads this; only callers that resolve the
878
+ // agent file themselves seed it.
879
+ record.invocation ??= {};
880
+ record.invocation.isolation = "worktree";
881
+
882
+ // No longer "running" means a stop landed while the copy was being made
883
+ // (abort(), abortAll()) — a window that did not exist when creation was
884
+ // synchronous. The record is already terminal, so launching the run would
885
+ // burn tokens on work nobody is waiting for: discard the fresh (and by
886
+ // definition unchanged) worktree instead.
887
+ if (record.status !== "running") {
888
+ this.chargePool(record.chargedPool, -1);
889
+ record.worktreeResult = await cleanupWorktree(pi, baseCwd, wt, options.description);
890
+ this.drainQueue();
891
+ return;
892
+ }
893
+ }
894
+
895
+ this.onStart?.(record);
896
+
897
+ // Wire parent abort signal to stop the subagent when the parent is interrupted.
898
+ // A queued spawn can start minutes after the caller handed us its signal,
899
+ // by which time it may already be aborted — and `addEventListener` would
900
+ // never fire, leaving a child the parent can no longer reach.
901
+ if (options.signal?.aborted) this.abort(id);
902
+ else this.armParentAbort(record, options.signal);
903
+ const detach = () => this.detachParentAbort(record);
904
+
905
+ const promise = runAgent(ctx, type, prompt, {
906
+ pi,
907
+ agentId: id,
908
+ model: options.model,
909
+ maxTurns: options.maxTurns,
910
+ isolated: options.isolated,
911
+ inheritContext: options.inheritContext,
912
+ thinkingLevel: options.thinkingLevel,
913
+ structuredOutput: options.structuredOutput,
914
+ resumeSessionFile: options.resumeSessionFile,
915
+ nested: options.parentAgentId !== undefined,
916
+ workflow: options.workflowId !== undefined,
917
+ // Worktree wins for the working dir (the agent must run in the copy —
918
+ // which, with a custom cwd, was created from that target). Config stays
919
+ // with the parent project when a caller-supplied cwd is in play; it must
920
+ // stay undefined otherwise so plain worktree runs keep resolving config
921
+ // (incl. relative extension paths and memory) inside the worktree copy.
922
+ cwd: worktreeCwd ?? customCwd,
923
+ // Set iff a worktree was created (see above) — names the directory the
924
+ // copy came from, so the prompt can tell the agent not to work there.
925
+ worktreeBase: worktreeCwd ? baseCwd : undefined,
926
+ configCwd: options.configCwd ?? (customCwd !== undefined ? ctx.cwd : undefined),
927
+ configProjectTrusted: options.configCwd !== undefined ? options.configProjectTrusted : undefined,
928
+ signal: record.abortController!.signal,
929
+ onToolActivity: (activity) => {
930
+ if (activity.type === "end") record.toolUses++;
931
+ options.onToolActivity?.(activity);
932
+ },
933
+ onTurnEnd: options.onTurnEnd,
934
+ onTextDelta: options.onTextDelta,
935
+ onModelCall: options.onModelCall,
936
+ onAssistantUsage: (usage, message) => {
937
+ relabelFromResponse(record, message);
938
+ addUsage(record.lifetimeUsage, usage);
939
+ this.onUsage?.(record, usage);
940
+ options.onAssistantUsage?.(usage);
941
+ },
942
+ onCompaction: (info) => {
943
+ record.compactionCount++;
944
+ this.onCompact?.(record, info);
945
+ options.onCompaction?.(info);
946
+ },
947
+ nestedRuntime: {
948
+ manager: this,
949
+ parentAgentId: id,
950
+ depth: record.depth ?? 1,
951
+ maxSubagentDepth: record.maxSubagentDepth,
952
+ },
953
+ onSessionCreated: (session) => {
954
+ record.session = session;
955
+ // Capture now, while the session object exists: after eviction this
956
+ // path is the only thing that can reopen the conversation, and an
957
+ // in-memory session reports undefined, which correctly means
958
+ // "nothing to come back to".
959
+ // Optional chaining, not defensiveness for its own sake: this is the
960
+ // only field read off the session at creation, so an older pi or a
961
+ // stubbed session must degrade to "not resumable" rather than throw
962
+ // and take the whole spawn down with it.
963
+ record.sessionFile = session.sessionManager?.getSessionFile?.();
964
+ // Same reason, different field: the model and thinking level are only
965
+ // knowable once pi has resolved its defaults and clamped the level to
966
+ // what the model supports. Writing them back here makes the record
967
+ // authoritative, so every surface reads one place instead of each
968
+ // re-deriving "session, else the request" for itself.
969
+ if (session.model) {
970
+ record.invocation ??= {};
971
+ // Read the kept request first: a caller's level survives being clamped
972
+ // AND, one line later, being replaced by the effective one.
973
+ const requested = record.invocation.requestedThinking ?? record.invocation.thinking;
974
+ Object.assign(
975
+ record.invocation,
976
+ isVirtualModel(session.model) ? describeVirtualModel(session.model) : describeModel(session.model),
977
+ );
978
+ // Guarded for the reason above: a session that reports no level keeps
979
+ // the request rather than losing it. Overwriting unconditionally would
980
+ // turn an older or stubbed session into a blank `thinking:` tag, which
981
+ // is worse than the stale-but-true value it replaced.
982
+ if (session.thinkingLevel) {
983
+ record.invocation.thinking = session.thinkingLevel;
984
+ if (requested && requested !== session.thinkingLevel) {
985
+ record.invocation.requestedThinking = requested;
986
+ }
987
+ }
988
+ }
989
+ // Flush any steers that arrived before the session was ready
990
+ if (record.pendingSteers?.length) {
991
+ for (const msg of record.pendingSteers) {
992
+ session.steer(msg).catch(() => {});
993
+ }
994
+ record.pendingSteers = undefined;
995
+ }
996
+ options.onSessionCreated?.(session);
997
+ },
998
+ })
999
+ .then(async ({ responseText, session, aborted, steered, failure, structuredJson, structuredRetried }) => {
1000
+ this.settleOutcome(record, { text: responseText, failure, aborted, steered });
1001
+ // Kept beside `result`, never inside it: `result` is prose meant for a
1002
+ // reader — it is previewed, transcribed, and appended to below — while
1003
+ // this is a machine-readable payload one caller asked for by schema.
1004
+ record.structuredJson = structuredJson;
1005
+ record.structuredRetried = structuredRetried;
1006
+ record.session = session;
1007
+
1008
+ detach();
1009
+
1010
+ // Final flush of streaming output file
1011
+ if (record.outputCleanup) {
1012
+ try { record.outputCleanup(); } catch { /* ignore */ }
1013
+ record.outputCleanup = undefined;
1014
+ }
1015
+
1016
+ // Clean up worktree if used
1017
+ if (record.worktree) {
1018
+ // The one moment the child's tree still exists and the child is done
1019
+ // writing to it. try/catch, not decoration: a hook that throws must
1020
+ // not leave the worktree behind.
1021
+ if (options.onBeforeWorktreeCleanup) {
1022
+ try {
1023
+ await options.onBeforeWorktreeCleanup(record.worktree.path);
1024
+ } catch { /* ignore — never block cleanup */ }
1025
+ }
1026
+ const wtResult = await cleanupWorktree(pi, baseCwd, record.worktree, options.description);
1027
+ record.worktreeResult = wtResult;
1028
+ if (wtResult.hasChanges && wtResult.branch) {
1029
+ // With a caller-supplied cwd the branch lives in THAT repo, not the
1030
+ // parent session's — say so, or the orchestrator merges in the wrong repo.
1031
+ // Named by its toplevel: the cwd may be a subdirectory of it.
1032
+ const repo = record.worktree.repoRoot;
1033
+ const repoNote = customCwd !== undefined ? ` in \`${repo}\`` : "";
1034
+ // Appended to the prose only. A structured child's caller parses
1035
+ // `structuredJson`, which stays untouched — but `result` is also
1036
+ // what a human reads, so the note still belongs on it.
1037
+ record.result = (record.result ?? "") +
1038
+ `\n\n---\nChanges saved to branch \`${wtResult.branch}\`${repoNote}. Merge with: \`git merge ${wtResult.branch}\`${customCwd !== undefined ? ` (run in \`${repo}\`)` : ""}`;
1039
+ }
1040
+ }
1041
+
1042
+ this.abortOwnedChildren(id);
1043
+
1044
+ this.settleRun(record, true);
1045
+ return responseText;
1046
+ })
1047
+ .catch(async (err) => {
1048
+ // Don't overwrite status if externally stopped via abort()
1049
+ if (record.status !== "stopped") {
1050
+ record.status = "error";
1051
+ }
1052
+ record.error = err instanceof Error ? err.message : String(err);
1053
+ record.completedAt ??= this.clock.now();
1054
+
1055
+ detach();
1056
+
1057
+ // Final flush of streaming output file on error
1058
+ if (record.outputCleanup) {
1059
+ try { record.outputCleanup(); } catch { /* ignore */ }
1060
+ record.outputCleanup = undefined;
1061
+ }
1062
+
1063
+ // Best-effort worktree cleanup on error
1064
+ if (record.worktree) {
1065
+ try {
1066
+ const wtResult = await cleanupWorktree(pi, baseCwd, record.worktree, options.description);
1067
+ record.worktreeResult = wtResult;
1068
+ } catch { /* ignore cleanup errors */ }
1069
+ }
1070
+
1071
+ this.abortOwnedChildren(id);
1072
+
1073
+ this.settleRun(record, false);
1074
+ return "";
1075
+ });
1076
+
1077
+ record.promise = promise;
1078
+
1079
+ // Notify caller that spawn is complete (record is in the map, promise is set).
1080
+ // Called synchronously — onSessionCreated fires asynchronously inside runAgent.
1081
+ // Used by spawnAndWait to let the caller set up output files before streaming
1082
+ // starts. Read off the options, so a spawn that started from a queue drain
1083
+ // still reaches the caller that queued it.
1084
+ options.onSpawned?.(id);
1085
+ }
1086
+
1087
+ /**
1088
+ * The shared tail of both settle paths: release whatever pool slot the run
1089
+ * held, notify, and let the queue drain into the freed slot.
1090
+ *
1091
+ * The decrement lives HERE and nowhere else. `abort()` on a running record
1092
+ * only fires its controller and leaves the run to settle normally, so
1093
+ * decrementing there too would double-free — permanently lifting the limit.
1094
+ *
1095
+ * Foreground agents fire `onComplete` for lifecycle symmetry, with
1096
+ * `resultConsumed` set so the callback skips notifications the inline result
1097
+ * already delivered.
1098
+ *
1099
+ * @param guardCallback swallow a throwing `onComplete` (the success path does;
1100
+ * the error path historically did not, and keeps not doing so).
1101
+ *
1102
+ * Releases `record.chargedPool` — the pool the run was charged to at start
1103
+ * (or moved to by `sendToBackground`), never a recomputed one, so a mid-run
1104
+ * change to `maxConcurrentForeground` can't make the release disagree with
1105
+ * the acquire.
1106
+ */
1107
+ private settleRun(record: AgentRecord, guardCallback: boolean): void {
1108
+ if (!record.isBackground) markConsumed(record, this.clock.now());
1109
+ // Nobody waits on a settled run; a later foreground resume must not look
1110
+ // like a spawn `sendToBackground` could hand off.
1111
+ record.blocking = false;
1112
+ const pool = record.chargedPool;
1113
+ this.chargePool(pool, -1);
1114
+
1115
+ if (guardCallback) {
1116
+ try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
1117
+ } else {
1118
+ this.onComplete?.(record);
1119
+ }
1120
+
1121
+ // The isBackground half reproduces the pre-pool condition exactly — a
1122
+ // background settle has always drained, even for a nested child that held
1123
+ // no slot — so that path is unchanged whether or not the foreground pool is
1124
+ // on. The `pool` half only adds the drain a freed FOREGROUND slot needs.
1125
+ // A drain with nothing freed is a no-op anyway, but "no-op" is a claim
1126
+ // about reachability, and matching the old condition needs no such claim.
1127
+ if (record.isBackground || pool !== undefined) this.drainQueue();
1128
+ }
1129
+
1130
+ /**
1131
+ * Stop the nested children a settled parent owns. Nested records are hidden
1132
+ * from the UI and only their owner can consume them, so a child outliving its
1133
+ * parent would burn tokens unseen with no way to reach it. Grandchildren are
1134
+ * covered transitively — each abort lands in that child's own settle path.
1135
+ */
1136
+ private abortOwnedChildren(parentId: string): void {
1137
+ for (const [id, record] of this.agents) {
1138
+ if (record.parentAgentId === parentId) this.abort(id);
1139
+ }
1140
+ }
1141
+
1142
+ /**
1143
+ * Start queued agents up to each pool's concurrency limit.
1144
+ *
1145
+ * `findIndex` on the entry's OWN pool rather than `shift`: with one queue
1146
+ * serving two independent limits, a saturated foreground pool at the head
1147
+ * would otherwise stall every background agent behind it. Taking the earliest
1148
+ * eligible entry keeps FIFO within each pool, which is what callers see.
1149
+ */
1150
+ private drainQueue() {
1151
+ for (;;) {
1152
+ const i = this.queue.findIndex(e => this.poolHasRoom(e.pool));
1153
+ if (i === -1) return;
1154
+ const [next] = this.queue.splice(i, 1);
1155
+ const record = this.agents.get(next.id);
1156
+ // Stale entries (aborted while queued) are not started — but are still
1157
+ // released, since nothing else will.
1158
+ if (!record || record.status !== "queued") { next.release(); continue; }
1159
+ // Detached, and never rejects: a late failure (e.g. strict worktree
1160
+ // isolation) lands on the record inside `launch`, exactly as the
1161
+ // synchronous throw did here before, and draining continues either way.
1162
+ //
1163
+ // The release waits for that startup to SETTLE rather than firing here.
1164
+ // Startup is async now, so a release at drain time would wake a blocked
1165
+ // `spawnAndWait` while `record.promise` was still undefined, and it would
1166
+ // read a perfectly healthy agent as one that never ran.
1167
+ void next.start().then(() => next.release(), () => next.release());
1168
+ }
1169
+ }
1170
+
1171
+ /**
1172
+ * Remove queued entries and wake anyone blocked on them. The single point
1173
+ * that enforces "leaving the queue releases the waiter" — a missed release is
1174
+ * an unbounded hang, not a failed call.
1175
+ */
1176
+ private dequeue(pred: (entry: { id: string; pool: Pool }) => boolean): void {
1177
+ const kept: typeof this.queue = [];
1178
+ for (const entry of this.queue) {
1179
+ if (pred(entry)) entry.release();
1180
+ else kept.push(entry);
1181
+ }
1182
+ this.queue = kept;
1183
+ }
1184
+
1185
+ /**
1186
+ * Spawn an agent and wait for completion (foreground use).
1187
+ * Charged to the foreground pool (`maxConcurrentForeground`), which is
1188
+ * unlimited by default; never to the background one.
1189
+ * Returns { id, record } so callers can access the agent ID.
1190
+ *
1191
+ * @param onSpawned - Called synchronously once the run is kicked off, before
1192
+ * onSessionCreated fires. Use this to set record.outputFile so
1193
+ * streamToOutputFile can pick it up.
1194
+ */
1195
+ async spawnAndWait(
1196
+ pi: ExtensionAPI,
1197
+ ctx: ExtensionContext,
1198
+ type: SubagentType,
1199
+ prompt: string,
1200
+ options: Omit<SpawnOptions, "isBackground">,
1201
+ onSpawned?: (id: string) => void,
1202
+ ): Promise<{ id: string; record: AgentRecord }> {
1203
+ // `blocking` is what maxConcurrentForeground bounds, and this is its only
1204
+ // source. onSpawned rides on the options rather than on a field of this
1205
+ // manager: a queued spawn starts at drain time, long after any install/
1206
+ // restore pair around this call would have put the field back — and it now
1207
+ // fires after an await (worktree creation) even on the immediate path.
1208
+ const id = this.spawn(pi, ctx, type, prompt, {
1209
+ ...options,
1210
+ isBackground: false,
1211
+ blocking: true,
1212
+ onSpawned,
1213
+ });
1214
+ const record = this.agents.get(id)!;
1215
+
1216
+ // Queued: nothing to await yet — the promise appears when the drain starts
1217
+ // it. The gate resolves (never rejects) on every path out of the queue,
1218
+ // start and abort alike, so a rejection can never escape into the caller's
1219
+ // tool `execute` and take down pi's whole Promise.all tool batch.
1220
+ if (record.status === "queued") await record.startGate;
1221
+
1222
+ // The run promise only exists once startup is past its awaited repo copy —
1223
+ // without this the call would return before the agent had started at all.
1224
+ // A startup failure (strict worktree isolation) rejects here, which is what
1225
+ // the immediate path owes its caller: the Agent tool reports failure by
1226
+ // letting this throw out of `execute`. A queued spawn's failure landed on
1227
+ // the record instead (nobody was awaiting `startups` at drain time) and is
1228
+ // rethrown below, so the contract is the same either way.
1229
+ await this.awaitStartup(id);
1230
+
1231
+ // undefined when it was aborted while queued, or stopped mid-copy, and so
1232
+ // never ran — the record is already terminal with a completedAt, which is
1233
+ // what the caller renders.
1234
+ if (record.promise) await record.promise;
1235
+
1236
+ // A record that ended "error" without ever getting a promise never ran: the
1237
+ // same startup failure spawn() rethrows on the immediate path (#179). Keep
1238
+ // one contract rather than letting queue pressure decide whether a strict
1239
+ // worktree failure throws or returns as a result.
1240
+ if (record.promise === undefined && record.status === "error") {
1241
+ throw new Error(record.error ?? "Agent failed to start");
1242
+ }
1243
+ return { id, record };
1244
+ }
1245
+
1246
+ /**
1247
+ * Hand a live run to the background without restarting it. The session,
1248
+ * promise, in-flight turn, transcript and worktree all continue under the
1249
+ * same id; what changes is who answers for it. The caller's parent-abort
1250
+ * listener comes off (its Esc no longer reaches the run — `abort()` and
1251
+ * `/agents` still do), the slot moves to the background pool, and the run
1252
+ * then settles through the ordinary background path, since `settleRun`
1253
+ * reads the live `isBackground` and `chargedPool`.
1254
+ *
1255
+ * Releasing the caller is the caller's job — the `Agent` tool races its own
1256
+ * wake-up against `spawnAndWait`. This only refuses what it can see: a run
1257
+ * nobody is blocking on (a background or detached spawn, one already handed
1258
+ * off, or a foreground RESUME — `blocking` is cleared when the spawned run
1259
+ * settles, and a resume never charged the slot this would move), and one
1260
+ * that reports through something other than the session (a nested child, a
1261
+ * workflow's agent).
1262
+ *
1263
+ * The background slot is taken even when the pool is full: suspending a
1264
+ * live run is not an option, so it runs over the limit and queued
1265
+ * background work waits for the count to drop, as with `bypassQueue`.
1266
+ */
1267
+ sendToBackground(id: string): boolean {
1268
+ const record = this.agents.get(id);
1269
+ if (!record?.blocking || !isTopLevelAgent(record) || record.status !== "running" || !record.promise) return false;
1270
+
1271
+ this.detachParentAbort(record);
1272
+ this.chargePool(record.chargedPool, -1);
1273
+ record.chargedPool = "background";
1274
+ this.chargePool(record.chargedPool, 1);
1275
+ record.blocking = false;
1276
+ record.isBackground = true;
1277
+ record.resultConsumed = false;
1278
+ // A freed foreground slot may have unblocked a queued blocking spawn.
1279
+ this.drainQueue();
1280
+ return true;
1281
+ }
1282
+
1283
+ /**
1284
+ * Resume an existing agent session with a new prompt.
1285
+ */
1286
+ async resume(
1287
+ id: string,
1288
+ prompt: string,
1289
+ signal?: AbortSignal,
1290
+ options?: ResumeOptions,
1291
+ ): Promise<AgentRecord | undefined> {
1292
+ const record = this.agents.get(id);
1293
+ if (!record?.session) return undefined;
1294
+
1295
+ // Background resume: settle asynchronously and notify on completion exactly
1296
+ // like a background spawn, returning immediately with the record still
1297
+ // "running" — or "queued" when at the concurrency limit. Previously
1298
+ // run_in_background was ignored on resume (the Agent tool's resume branch
1299
+ // returned before its background branch, and resume() only ever awaited
1300
+ // inline), so a resumed agent always blocked the caller until it finished.
1301
+ if (options?.isBackground) {
1302
+ // Never re-enter a run that is still in flight. Detaching means the caller
1303
+ // gets control back while the record stays "running", so nothing stops the
1304
+ // model from resuming the same agent again. Starting a second run would
1305
+ // overwrite record.abortController — orphaning the live run beyond the
1306
+ // reach of `/agents` stop and abortAll() — double-count the pool slot, and
1307
+ // then reject from session.prompt() with "Agent is already processing",
1308
+ // whose settle path would abort the LIVE run's children and report a
1309
+ // failure for a run that is still going. Refuse instead, leaving the
1310
+ // record untouched; the caller decides whether to wait or steer.
1311
+ if (record.status === "running" || record.status === "queued") return undefined;
1312
+
1313
+ record.isBackground = true;
1314
+ record.resultConsumed = false;
1315
+ record.consumedAt = undefined;
1316
+ record.result = undefined;
1317
+ record.error = undefined;
1318
+ record.completedAt = undefined;
1319
+ record.status = "queued";
1320
+
1321
+ const start = () => this.startResume(id, record, prompt, signal, options);
1322
+ if (occupiesPoolSlot(record) && !this.poolHasRoom("background")) {
1323
+ // At the concurrency limit — queue it, drains when a slot frees. A
1324
+ // detached resume has no inline caller, hence nothing to release. The
1325
+ // queue is shared with spawns, whose startup is async, so entries are
1326
+ // promise-shaped even though a resume starts synchronously; failures
1327
+ // land on the record here, since drainQueue no longer catches.
1328
+ this.queue.push({
1329
+ id,
1330
+ pool: "background",
1331
+ start: async () => {
1332
+ try {
1333
+ start();
1334
+ } catch (err) {
1335
+ record.status = "error";
1336
+ record.error = err instanceof Error ? err.message : String(err);
1337
+ record.completedAt = this.clock.now();
1338
+ this.onComplete?.(record);
1339
+ }
1340
+ },
1341
+ release: () => {},
1342
+ });
1343
+ } else {
1344
+ start();
1345
+ }
1346
+ return record;
1347
+ }
1348
+
1349
+ // Foreground resume: run inline and return the settled record.
1350
+ record.status = "running";
1351
+ record.startedAt = this.clock.now();
1352
+ record.completedAt = undefined;
1353
+ record.result = undefined;
1354
+ record.error = undefined;
1355
+ // Fresh controller, as on the detached path: `abort()` fires whatever
1356
+ // `record.abortController` holds, and the spawn's has long settled — so a
1357
+ // stop from `/agents` or the caller's Esc would mark the record stopped
1358
+ // while the resumed prompt ran on to completion.
1359
+ const abortController = new AbortController();
1360
+ record.abortController = abortController;
1361
+ this.armParentAbort(record, signal);
1362
+ // `addEventListener` never fires on a signal that is already aborted, and
1363
+ // the runner skips the prompt only when ITS signal is — stop now, so that
1364
+ // holds for a resume issued on a call that was cancelled first.
1365
+ if (signal?.aborted) this.abort(id);
1366
+
1367
+ try {
1368
+ this.settleOutcome(record, await resumeAgent(record.session, prompt, {
1369
+ type: record.type,
1370
+ maxTurns: options?.maxTurns ?? record.invocation?.maxTurns,
1371
+ onTurnEnd: options?.onTurnEnd,
1372
+ onTextDelta: options?.onTextDelta,
1373
+ onModelCall: options?.onModelCall,
1374
+ onToolActivity: (activity) => {
1375
+ if (activity.type === "end") record.toolUses++;
1376
+ options?.onToolActivity?.(activity);
1377
+ },
1378
+ onAssistantUsage: (usage, message) => {
1379
+ relabelFromResponse(record, message);
1380
+ addUsage(record.lifetimeUsage, usage);
1381
+ this.onUsage?.(record, usage);
1382
+ options?.onAssistantUsage?.(usage);
1383
+ },
1384
+ onCompaction: (info) => {
1385
+ record.compactionCount++;
1386
+ this.onCompact?.(record, info);
1387
+ options?.onCompaction?.(info);
1388
+ },
1389
+ signal: abortController.signal,
1390
+ }));
1391
+ } catch (err) {
1392
+ this.failResume(record, err);
1393
+ }
1394
+ this.detachParentAbort(record);
1395
+
1396
+ // Same contract as the spawn settle paths: children spawned during the
1397
+ // resumed turn must not outlive it — nothing else can see or reach them.
1398
+ this.abortOwnedChildren(id);
1399
+
1400
+ return record;
1401
+ }
1402
+
1403
+ /**
1404
+ * Write a run's outcome onto its record — a spawn's or a resume's, with one
1405
+ * precedence: an external stop keeps "stopped"; a hard abort (turn limit +
1406
+ * grace) is "aborted"; a failed final turn (a provider error pi resolved
1407
+ * instead of rejecting, #144) is an "error", not a completion with an empty
1408
+ * or stale result; otherwise "steered" or "completed". The run's text stays
1409
+ * available in every case.
1410
+ */
1411
+ private settleOutcome(
1412
+ record: AgentRecord,
1413
+ { text, failure, aborted, steered }: Awaited<ReturnType<typeof resumeAgent>>,
1414
+ ): void {
1415
+ if (record.status !== "stopped") {
1416
+ if (aborted) {
1417
+ record.status = "aborted";
1418
+ } else if (failure) {
1419
+ record.status = "error";
1420
+ record.error = failure;
1421
+ } else {
1422
+ record.status = steered ? "steered" : "completed";
1423
+ }
1424
+ }
1425
+ record.result = text;
1426
+ record.completedAt ??= this.clock.now();
1427
+ }
1428
+
1429
+ /** A resume whose runner rejected: an error, unless an external stop got there first. */
1430
+ private failResume(record: AgentRecord, err: unknown): void {
1431
+ if (record.status !== "stopped") {
1432
+ record.status = "error";
1433
+ record.error = err instanceof Error ? err.message : String(err);
1434
+ }
1435
+ record.completedAt ??= this.clock.now();
1436
+ }
1437
+
1438
+ /**
1439
+ * Start a background resume run: detached, settling and notifying like
1440
+ * startAgent's background path. Invoked immediately, or from drainQueue when
1441
+ * a concurrency slot frees. The session already exists (resume reuses it), so
1442
+ * there is no onSessionCreated to hang per-run wiring off — callers use
1443
+ * `options.onStarted`, which fires on both the immediate and the drained path.
1444
+ */
1445
+ private startResume(
1446
+ id: string,
1447
+ record: AgentRecord,
1448
+ prompt: string,
1449
+ parentSignal: AbortSignal | undefined,
1450
+ options: ResumeOptions,
1451
+ ) {
1452
+ if (!record.session) return;
1453
+
1454
+ record.status = "running";
1455
+ record.startedAt = this.clock.now();
1456
+ if (occupiesPoolSlot(record)) this.runningBackground++;
1457
+ this.onStart?.(record);
1458
+
1459
+ // Fresh abort controller so /agents stop and steering target THIS run rather
1460
+ // than the previous one's settled controller.
1461
+ const abortController = new AbortController();
1462
+ record.abortController = abortController;
1463
+ // Optional, and NOT what the Agent tool passes for a detached resume: a
1464
+ // parent signal aborts on the parent's own interrupt (user Esc), which is
1465
+ // right for a foreground run whose result the caller is awaiting, and wrong
1466
+ // for a detached one — background spawns omit it for exactly this reason.
1467
+ let detachParentSignal: (() => void) | undefined;
1468
+ if (parentSignal) {
1469
+ const onParentAbort = () => this.abort(id);
1470
+ parentSignal.addEventListener("abort", onParentAbort, { once: true });
1471
+ detachParentSignal = () => parentSignal.removeEventListener("abort", onParentAbort);
1472
+ }
1473
+
1474
+ // Per-run side effects (output streaming) — see ResumeOptions.onStarted.
1475
+ // After the record is in its running shape, before the run is kicked off.
1476
+ try { options.onStarted?.(); } catch { /* ignore caller wiring errors */ }
1477
+
1478
+ const settle = () => {
1479
+ detachParentSignal?.();
1480
+ detachParentSignal = undefined;
1481
+ // Final flush of streaming output file
1482
+ if (record.outputCleanup) {
1483
+ try { record.outputCleanup(); } catch { /* ignore */ }
1484
+ record.outputCleanup = undefined;
1485
+ }
1486
+ // Children spawned during the resumed turn must not outlive it.
1487
+ this.abortOwnedChildren(id);
1488
+ if (occupiesPoolSlot(record)) this.runningBackground--;
1489
+ try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
1490
+ this.drainQueue();
1491
+ };
1492
+
1493
+ const promise = resumeAgent(record.session, prompt, {
1494
+ type: record.type,
1495
+ maxTurns: options.maxTurns ?? record.invocation?.maxTurns,
1496
+ onTurnEnd: options.onTurnEnd,
1497
+ onTextDelta: options.onTextDelta,
1498
+ onModelCall: options.onModelCall,
1499
+ onToolActivity: (activity) => {
1500
+ if (activity.type === "end") record.toolUses++;
1501
+ options.onToolActivity?.(activity);
1502
+ },
1503
+ onAssistantUsage: (usage, message) => {
1504
+ relabelFromResponse(record, message);
1505
+ addUsage(record.lifetimeUsage, usage);
1506
+ this.onUsage?.(record, usage);
1507
+ options.onAssistantUsage?.(usage);
1508
+ },
1509
+ onCompaction: (info) => {
1510
+ record.compactionCount++;
1511
+ this.onCompact?.(record, info);
1512
+ options.onCompaction?.(info);
1513
+ },
1514
+ signal: abortController.signal,
1515
+ })
1516
+ .then((outcome) => {
1517
+ this.settleOutcome(record, outcome);
1518
+ settle();
1519
+ return outcome.text;
1520
+ })
1521
+ .catch((err) => {
1522
+ this.failResume(record, err);
1523
+ settle();
1524
+ return "";
1525
+ });
1526
+
1527
+ record.promise = promise;
1528
+ }
1529
+
1530
+ /**
1531
+ * Send a steering message to an agent from the UI (mirrors the steer_subagent
1532
+ * tool). A live session delivers it now — it interrupts the agent after its
1533
+ * current tool execution and appears as a user message. If the session isn't
1534
+ * ready yet, the message is queued on `pendingSteers` and flushed when the
1535
+ * session is created. Returns false if the agent can't accept steering
1536
+ * (unknown id, or no longer running/queued). Fires `onSteered` on either
1537
+ * accepted path, so the UI viewers, an `@handle` and the RPC all report
1538
+ * the steer the same way without each emitting by hand. The steer tools
1539
+ * bypass this method because they await delivery.
1540
+ */
1541
+ steer(id: string, message: string): boolean {
1542
+ const record = this.agents.get(id);
1543
+ if (!record) return false;
1544
+ if (record.status !== "running" && record.status !== "queued") return false;
1545
+ if (record.session) {
1546
+ record.session.steer(message).catch(() => {});
1547
+ } else {
1548
+ if (!record.pendingSteers) record.pendingSteers = [];
1549
+ record.pendingSteers.push(message);
1550
+ }
1551
+ this.onSteered?.(record, message);
1552
+ return true;
1553
+ }
1554
+
1555
+ getRecord(id: string): AgentRecord | undefined {
1556
+ return this.agents.get(id);
1557
+ }
1558
+
1559
+ /** Handles already in use, so a fresh spawn can pick an unclaimed one. */
1560
+ private takenHandles(): Set<string> {
1561
+ const taken = new Set<string>();
1562
+ for (const record of this.agents.values()) {
1563
+ if (record.handle) taken.add(record.handle);
1564
+ if (record.alias) taken.add(record.alias);
1565
+ }
1566
+ // Tombstones hold their names too: an evicted `@explore` is still
1567
+ // resurrectable, so a later Explore must become `explore-2` rather than
1568
+ // shadowing a conversation the user can still reach.
1569
+ for (const entry of this.tombstones.values()) {
1570
+ taken.add(entry.handle);
1571
+ if (entry.alias) taken.add(entry.alias);
1572
+ }
1573
+ return taken;
1574
+ }
1575
+
1576
+ /**
1577
+ * Resolve an `@name` from the prompt. Matches a top-level agent's handle
1578
+ * case-insensitively, preferring one that can still be steered and otherwise
1579
+ * the most recently started (which is the one a resume should continue), then
1580
+ * falls back to an exact agent id so `@<agentId>` works too.
1581
+ */
1582
+ resolveMention(name: string): MentionResolution | undefined {
1583
+ const wanted = name.toLowerCase();
1584
+ let fallback: AgentRecord | undefined;
1585
+ for (const record of this.agents.values()) {
1586
+ if (record.parentAgentId !== undefined) continue;
1587
+ // Handle and alias share one namespace, so at most one agent answers a
1588
+ // name and it makes no difference which of the two matched.
1589
+ if (record.handle?.toLowerCase() !== wanted && record.alias?.toLowerCase() !== wanted) continue;
1590
+ if (record.status === "running" || record.status === "queued") return { kind: "live", record };
1591
+ if (!fallback || record.startedAt > fallback.startedAt) fallback = record;
1592
+ }
1593
+ if (fallback) return { kind: "live", record: fallback };
1594
+ const byId = this.agents.get(name);
1595
+ if (byId?.parentAgentId === undefined && byId !== undefined) return { kind: "live", record: byId };
1596
+ // Only once nothing live answers: a tombstone is a conversation to reopen,
1597
+ // and reopening one while its record still exists would fork the session.
1598
+ for (const entry of this.tombstones.values()) {
1599
+ if (entry.handle.toLowerCase() === wanted || entry.alias?.toLowerCase() === wanted || entry.id === name) {
1600
+ return { kind: "tombstone", entry };
1601
+ }
1602
+ }
1603
+ return undefined;
1604
+ }
1605
+
1606
+ /**
1607
+ * Forget an evicted agent, by handle. For the case where its session file has
1608
+ * gone: the entry can then only ever fail, while still holding the name
1609
+ * against the type that would otherwise start a fresh agent under it.
1610
+ *
1611
+ * A *successful* resume does not drop its tombstone — the live record it
1612
+ * creates already wins in `resolveMention`, and overwrites the entry in place
1613
+ * when it is itself evicted.
1614
+ */
1615
+ dropTombstone(handle: string): void {
1616
+ this.tombstones.delete(handle);
1617
+ }
1618
+
1619
+ /** Evicted agents whose conversation can still be reopened, newest first. */
1620
+ listTombstones(): AgentTombstone[] {
1621
+ return [...this.tombstones.values()].sort((a, b) => b.completedAt - a.completedAt);
1622
+ }
1623
+
1624
+ listAgents(): AgentRecord[] {
1625
+ return [...this.agents.values()].sort(
1626
+ (a, b) => b.startedAt - a.startedAt,
1627
+ );
1628
+ }
1629
+
1630
+ abort(id: string): boolean {
1631
+ const record = this.agents.get(id);
1632
+ if (!record) return false;
1633
+
1634
+ // Remove from queue if queued. No decrement — the slot was never taken —
1635
+ // and no onComplete, matching what a queued background abort has always
1636
+ // done; a blocking caller learns of the stop from its own tool result.
1637
+ if (record.status === "queued") {
1638
+ this.dequeue(q => q.id === id);
1639
+ record.status = "stopped";
1640
+ record.completedAt = this.clock.now();
1641
+ return true;
1642
+ }
1643
+
1644
+ if (record.status !== "running") return false;
1645
+ record.abortController?.abort();
1646
+ record.status = "stopped";
1647
+ record.completedAt = this.clock.now();
1648
+ return true;
1649
+ }
1650
+
1651
+ /** Dispose a record's session and remove it from the map. */
1652
+ private removeRecord(id: string, record: AgentRecord): void {
1653
+ this.tombstone(record);
1654
+ this.noteEviction(record);
1655
+ const session = record.session;
1656
+ // Detached before the shutdown starts, so the record leaves the map at once and
1657
+ // nothing can observe a session that is half torn down.
1658
+ record.session = undefined;
1659
+ this.agents.delete(id);
1660
+ // A failed startup keeps its (rejected) entry so a late awaitStartup still
1661
+ // sees it; drop it with the record so the map can't grow unbounded.
1662
+ this.startups.delete(id);
1663
+ // Fire-and-forget is right here and only here: this runs from the 60s cleanup timer
1664
+ // and from `clearCompleted()` on session boundaries, with the process staying alive,
1665
+ // so handlers get their full window. The quit path awaits instead — see dispose().
1666
+ void shutdownChildSession(session);
1667
+ }
1668
+
1669
+ /**
1670
+ * Preserve enough of a departing record for `@handle` to reopen its
1671
+ * conversation later. Nothing to keep unless it has both a handle to be
1672
+ * addressed by and a session file to reopen — an in-memory session leaves no
1673
+ * transcript, so the mention would have nothing to continue from.
1674
+ */
1675
+ private tombstone(record: AgentRecord): void {
1676
+ if (!record.handle || !record.sessionFile) return;
1677
+ this.tombstones.set(record.handle, {
1678
+ handle: record.handle,
1679
+ alias: record.alias,
1680
+ id: record.id,
1681
+ type: record.type,
1682
+ description: record.description,
1683
+ sessionFile: record.sessionFile,
1684
+ completedAt: record.completedAt ?? this.clock.now(),
1685
+ });
1686
+ // Bound the memory a long session can accumulate. Oldest first, since the
1687
+ // agent someone still wants to reach is the one they used most recently.
1688
+ while (this.tombstones.size > MAX_TOMBSTONES) {
1689
+ const oldest = [...this.tombstones.values()].reduce((a, b) => (a.completedAt <= b.completedAt ? a : b));
1690
+ this.tombstones.delete(oldest.handle);
1691
+ }
1692
+ }
1693
+
1694
+ /** Remember that `record` existed, so its id still answers with something better than "not found". */
1695
+ private noteEviction(record: AgentRecord): void {
1696
+ const completedAt = record.completedAt ?? this.clock.now();
1697
+ this.evictedNotes.delete(record.id);
1698
+ this.evictedNotes.set(record.id, {
1699
+ id: record.id,
1700
+ type: record.type,
1701
+ description: record.description,
1702
+ status: record.status,
1703
+ evictedAfterMin: Math.max(0, Math.round((this.clock.now() - completedAt) / 60_000)),
1704
+ transcript: record.outputFile ?? record.sessionFile,
1705
+ });
1706
+ while (this.evictedNotes.size > MAX_EVICTED_NOTES) {
1707
+ const oldest = this.evictedNotes.keys().next().value;
1708
+ if (oldest === undefined) break;
1709
+ this.evictedNotes.delete(oldest);
1710
+ }
1711
+ }
1712
+
1713
+ /** What became of an evicted agent, by id; undefined for an id this session never evicted. */
1714
+ getEvictedNote(id: string): EvictedNote | undefined {
1715
+ return this.evictedNotes.get(id);
1716
+ }
1717
+
1718
+ /**
1719
+ * Evict finished records past their window: 10 minutes from the moment the
1720
+ * result was consumed, 60 from completion while nobody has read it. A consumed
1721
+ * record with no stamp (set directly, not through `markConsumed`) is aged from
1722
+ * completion.
1723
+ */
1724
+ private cleanup() {
1725
+ const now = this.clock.now();
1726
+ for (const [id, record] of this.agents) {
1727
+ if (record.status === "running" || record.status === "queued") continue;
1728
+ const completedAt = record.completedAt ?? 0;
1729
+ const expired = record.resultConsumed
1730
+ ? now - (record.consumedAt ?? completedAt) >= this.consumedTtlMs
1731
+ : now - completedAt >= this.unreadTtlMs;
1732
+ if (expired) this.removeRecord(id, record);
1733
+ }
1734
+ }
1735
+
1736
+ /**
1737
+ * Remove all completed/stopped/errored records immediately.
1738
+ * Called on session start/switch so tasks from a prior session don't persist.
1739
+ * Pass skipUnconsumed=true to preserve records the LLM hasn't read yet
1740
+ * (resultConsumed=false) — they will be evicted by the cleanup timer instead (60 minutes).
1741
+ */
1742
+ clearCompleted(skipUnconsumed = false): void {
1743
+ for (const [id, record] of this.agents) {
1744
+ if (record.status === "running" || record.status === "queued") continue;
1745
+ if (skipUnconsumed && !record.resultConsumed) continue;
1746
+ this.removeRecord(id, record);
1747
+ }
1748
+ // Unconditional: both callers are session boundaries (`session_start` and
1749
+ // `session_before_switch`), and `skipUnconsumed` only spares records whose
1750
+ // results the LLM has yet to read — it does not make the sweep partial in
1751
+ // the sense that matters here. A new session means new handles, or
1752
+ // `@explore` would silently reach an agent the user never started. Claude
1753
+ // Code resets its registry on `/clear` for the same reason.
1754
+ this.tombstones.clear();
1755
+ // Ids are only meaningful within the session that issued them.
1756
+ this.evictedNotes.clear();
1757
+ }
1758
+
1759
+ /** Whether any agents are still running or queued. */
1760
+ hasRunning(): boolean {
1761
+ return [...this.agents.values()].some(
1762
+ r => r.status === "running" || r.status === "queued",
1763
+ );
1764
+ }
1765
+
1766
+ /**
1767
+ * Abort everything that cannot outlive its session and let the rest finish.
1768
+ * Survivors are running top-level background agents and their nested children:
1769
+ * they own their own session and report through a notification. Queued agents
1770
+ * would start later against a stale context, foreground agents belong to a
1771
+ * tool call that is being torn down, and workflow children die with their
1772
+ * workflow, so all of those are aborted.
1773
+ *
1774
+ * @returns how many agents keep running.
1775
+ */
1776
+ detach(): number {
1777
+ for (const queued of this.queue) {
1778
+ const record = this.agents.get(queued.id);
1779
+ if (record) {
1780
+ record.status = "stopped";
1781
+ record.completedAt = this.clock.now();
1782
+ }
1783
+ }
1784
+ this.dequeue(() => true);
1785
+ let survivors = 0;
1786
+ for (const record of this.agents.values()) {
1787
+ if (record.status !== "running") continue;
1788
+ const outlives = record.workflowId === undefined && (!!record.isBackground || record.parentAgentId !== undefined);
1789
+ if (outlives) {
1790
+ survivors++;
1791
+ continue;
1792
+ }
1793
+ record.abortController?.abort();
1794
+ record.status = "stopped";
1795
+ record.completedAt = this.clock.now();
1796
+ }
1797
+ return survivors;
1798
+ }
1799
+
1800
+ /** Abort all running and queued agents immediately. */
1801
+ abortAll(): number {
1802
+ let count = 0;
1803
+ // Clear queued agents first
1804
+ for (const queued of this.queue) {
1805
+ const record = this.agents.get(queued.id);
1806
+ if (record) {
1807
+ record.status = "stopped";
1808
+ record.completedAt = this.clock.now();
1809
+ count++;
1810
+ }
1811
+ }
1812
+ this.dequeue(() => true);
1813
+ // Abort running agents
1814
+ for (const record of this.agents.values()) {
1815
+ if (record.status === "running") {
1816
+ record.abortController?.abort();
1817
+ record.status = "stopped";
1818
+ record.completedAt = this.clock.now();
1819
+ count++;
1820
+ }
1821
+ }
1822
+ return count;
1823
+ }
1824
+
1825
+ /** Wait for all running and queued agents to complete (including queued ones). */
1826
+ async waitForAll(): Promise<void> {
1827
+ // Loop because drainQueue respects the concurrency limit — as running
1828
+ // agents finish they start queued ones, which need awaiting too.
1829
+ while (true) {
1830
+ this.drainQueue();
1831
+ const pending: Promise<unknown>[] = [];
1832
+ for (const record of this.agents.values()) {
1833
+ if (record.status !== "running" && record.status !== "queued") continue;
1834
+ // An agent whose worktree is still being created is "running" with no
1835
+ // `promise` yet — without its startup the wait would return too early.
1836
+ const startup = this.startups.get(record.id);
1837
+ if (startup) pending.push(startup);
1838
+ if (record.promise) pending.push(record.promise);
1839
+ }
1840
+ if (pending.length === 0) break;
1841
+ await Promise.allSettled(pending);
1842
+ }
1843
+ }
1844
+
1845
+ /**
1846
+ * @param pi - Needed to run `git worktree prune`, which is async now and so
1847
+ * cannot be reached through a stored spawn argument at shutdown. Omitting
1848
+ * it (tests, teardown of a manager that never spawned) skips the prune.
1849
+ */
1850
+ async dispose(pi?: ExtensionAPI): Promise<void> {
1851
+ this.cleanupInterval.cancel();
1852
+ // Clear queue — via dequeue, so anyone blocked in spawnAndWait is woken
1853
+ // rather than left awaiting a gate nothing will ever resolve.
1854
+ this.dequeue(() => true);
1855
+ const sessions = [...this.agents.values()].map(record => record.session);
1856
+ this.agents.clear();
1857
+ this.startups.clear();
1858
+ if (pi) {
1859
+ // Prune any orphaned git worktrees (crash recovery). Detached: dispose runs
1860
+ // on the shutdown path, which cannot wait for git. Started before the awaited
1861
+ // shutdown below rather than after it, so the git calls have that window to
1862
+ // finish in instead of racing the process exit that follows.
1863
+ const prune = (repo: string) => { pruneWorktrees(pi, repo).catch(() => {}); };
1864
+ prune(process.cwd());
1865
+ // Also prune repos that caller-supplied cwds created worktrees in — a clean
1866
+ // exit with in-flight agents would otherwise leave stale registrations there.
1867
+ for (const repo of this.worktreeRepos) prune(repo);
1868
+ }
1869
+ // Awaited, unlike the eviction path: pi awaits this extension's `session_shutdown`
1870
+ // handler and the process exits right after it returns, so anything left unawaited
1871
+ // here never runs at all. Bounded — each call carries its own ceiling, concurrently.
1872
+ await Promise.all(sessions.map(session => shutdownChildSession(session)));
1873
+ }
1874
+ }