@herbertgao/pi-subagents 0.17.0 → 0.18.0

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 (50) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +427 -120
  3. package/docs/rpc.md +184 -0
  4. package/docs/workflows.md +466 -0
  5. package/examples/agent-tool-description.md +6 -6
  6. package/examples/workflows/compose.js +52 -0
  7. package/examples/workflows/fan-out-audit.js +56 -0
  8. package/examples/workflows/gated-fix.js +60 -0
  9. package/examples/workflows/lib/count-child.js +30 -0
  10. package/examples/workflows/review-panel.js +68 -0
  11. package/examples/workflows/structured-findings.js +81 -0
  12. package/package.json +12 -9
  13. package/src/agent-file-toggle.ts +52 -12
  14. package/src/agent-manager.ts +837 -146
  15. package/src/agent-runner.ts +213 -39
  16. package/src/cross-extension-rpc.ts +73 -14
  17. package/src/custom-agents.ts +101 -47
  18. package/src/index.ts +2249 -914
  19. package/src/invocation-config.ts +13 -0
  20. package/src/mention-clone.ts +215 -0
  21. package/src/mention.ts +147 -0
  22. package/src/model-resolver.ts +9 -1
  23. package/src/nested-tools.ts +40 -26
  24. package/src/output-file.ts +18 -8
  25. package/src/prompts.ts +46 -9
  26. package/src/schedule.ts +21 -16
  27. package/src/settings.ts +137 -7
  28. package/src/structured-output.ts +136 -0
  29. package/src/types.ts +126 -8
  30. package/src/ui/agent-mention.ts +274 -0
  31. package/src/ui/agent-widget.ts +20 -5
  32. package/src/ui/conversation-viewer.ts +14 -1
  33. package/src/ui/fleet-list.ts +167 -22
  34. package/src/ui/workflow-card.ts +555 -0
  35. package/src/ui/workflow-dialog.ts +1304 -0
  36. package/src/ui/workflow-menu.ts +226 -0
  37. package/src/workflow/collisions.ts +122 -0
  38. package/src/workflow/entry.ts +47 -0
  39. package/src/workflow/host.ts +463 -0
  40. package/src/workflow/journal.ts +164 -0
  41. package/src/workflow/json-schema.ts +142 -0
  42. package/src/workflow/meta.ts +401 -0
  43. package/src/workflow/progress.ts +622 -0
  44. package/src/workflow/runtime.ts +1399 -0
  45. package/src/workflow/saved.ts +230 -0
  46. package/src/workflow/task.ts +333 -0
  47. package/src/workflow/tool-description.ts +200 -0
  48. package/src/workflow/worker-source.ts +781 -0
  49. package/src/worktree.ts +97 -95
  50. package/src/xml.ts +13 -0
@@ -1,8 +1,17 @@
1
1
  /**
2
2
  * agent-manager.ts — Tracks agents, background execution, resume support.
3
3
  *
4
- * Background and blocking foreground agents use independent concurrency pools.
5
- * Nested children use neither pool, avoiding parent/child deadlocks.
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`.
6
15
  */
7
16
 
8
17
  import { randomUUID } from "node:crypto"
@@ -15,36 +24,87 @@ import type {
15
24
  ExtensionContext,
16
25
  } from "@earendil-works/pi-coding-agent"
17
26
  import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js"
27
+ import { assignHandle, handleBase } from "./mention.js"
18
28
  import { describeModel } from "./model-resolver.js"
19
29
  import type {
20
30
  AgentInvocation,
21
31
  AgentRecord,
32
+ AgentTombstone,
22
33
  IsolationMode,
34
+ MentionResolution,
23
35
  SubagentType,
24
36
  ThinkingLevel,
25
37
  } from "./types.js"
26
38
  import { addUsage, type LifetimeUsage } from "./usage.js"
39
+ import type { CompiledSchema } from "./workflow/json-schema.js"
27
40
  import {
28
41
  cleanupWorktree,
29
42
  createWorktree,
30
43
  isWorktreeIsolationEnabled,
31
44
  pruneWorktrees,
45
+ type WorktreeCleanupResult,
32
46
  } from "./worktree.js"
33
47
 
34
48
  export type OnAgentComplete = (record: AgentRecord) => void
35
49
  export type OnAgentStart = (record: AgentRecord) => void
36
50
  export type OnAgentCompact = (record: AgentRecord, info: CompactionInfo) => void
51
+ /**
52
+ * Fired once per assistant `message_end`, for EVERY agent this manager owns —
53
+ * top-level and nested alike, spawns and resumes. The one place where each
54
+ * message is seen exactly once: `AgentRecord.lifetimeUsage` is deliberately
55
+ * double-booked into ancestors (see `nested-tools.ts`) so a hidden child's spend
56
+ * shows up on the record a human can see, which makes those records useless as
57
+ * a basis for anything that must not count a message twice — parent-session
58
+ * accounting above all.
59
+ */
37
60
  export type OnAgentUsage = (record: AgentRecord, usage: LifetimeUsage) => void
38
61
  export type CompactionInfo = {
39
62
  reason: "manual" | "threshold" | "overflow"
40
63
  tokensBefore: number
41
64
  }
42
65
 
43
- /** Default max concurrent background agents. */
66
+ /**
67
+ * Default max concurrent background agents.
68
+ *
69
+ * Raised from 4 when top-level spawns started defaulting to background
70
+ * (`backgroundByDefault`): foreground agents bypass this pool entirely, so
71
+ * while foreground was the default a fan-out of six ran six. With background
72
+ * as the default every top-level agent takes a slot, and a limit of 4 would
73
+ * have silently queued the tail of exactly the parallel fan-outs the `Agent`
74
+ * tool description tells the model to send.
75
+ */
44
76
  const DEFAULT_MAX_CONCURRENT = 10
45
- /** Default max concurrent blocking agents. 0 means unlimited. */
77
+
78
+ /**
79
+ * Default max concurrent foreground (blocking) agents — `0` = unlimited, the
80
+ * extension's existing convention for "no ceiling" (`defaultMaxTurns`).
81
+ *
82
+ * Off by default because nothing here ever bounded foreground work, and pi
83
+ * dispatches a message's tool calls through `Promise.all`, so an unqualified
84
+ * fan-out of blocking `Agent` calls has always run all at once. Users who want
85
+ * it bounded — chiefly local models, where parallel agents thrash the prompt
86
+ * cache (#253) — opt in; everyone else keeps today's behaviour exactly.
87
+ */
46
88
  const DEFAULT_MAX_CONCURRENT_FOREGROUND = 0
47
89
 
90
+ /**
91
+ * How many evicted agents stay addressable by name. Only a bound on memory —
92
+ * a session that spawns hundreds of agents shouldn't retain every one — and
93
+ * far above the handful anyone keeps in their head.
94
+ */
95
+ const MAX_TOMBSTONES = 100
96
+
97
+ function applyWorktreeCleanupFailure(
98
+ record: AgentRecord,
99
+ result: WorktreeCleanupResult,
100
+ ): boolean {
101
+ if (!result.error || !result.path) return false
102
+ const message = `Worktree cleanup failed: ${result.error}\nAgent worktree remains at \`${result.path}\` for recovery.`
103
+ record.status = "error"
104
+ record.error = record.error ? `${record.error}\n${message}` : message
105
+ return true
106
+ }
107
+
48
108
  /**
49
109
  * Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset"
50
110
  * (parent cwd). Anything else must be an absolute path to an existing
@@ -81,17 +141,57 @@ function assertValidSpawnCwd(
81
141
  * spawn costs it a turn, which is unbounded when max turns is unlimited.
82
142
  */
83
143
  function occupiesPoolSlot(
84
- record: Pick<AgentRecord, "isBackground" | "parentAgentId">,
144
+ record: Pick<AgentRecord, "isBackground" | "parentAgentId" | "workflowId">,
145
+ ): boolean {
146
+ return !!record.isBackground && isTopLevelAgent(record)
147
+ }
148
+
149
+ /**
150
+ * Whether a record is one of the session's own agents, rather than something
151
+ * another agent or a workflow owns.
152
+ *
153
+ * The single definition behind every user-facing surface — the fleet list, the
154
+ * widget, the `/agents` menus, `@handle` resolution, and the completion events
155
+ * and session entries. An owned child reports through its owner, so surfacing
156
+ * it separately would double-count the same work in the places a person reads.
157
+ */
158
+ export function isTopLevelAgent(
159
+ record: Pick<AgentRecord, "parentAgentId" | "workflowId">,
85
160
  ): boolean {
86
- return !!record.isBackground && record.parentAgentId === undefined
161
+ return record.parentAgentId === undefined && record.workflowId === undefined
87
162
  }
88
163
 
164
+ /**
165
+ * Whether a record occupies one of the `maxConcurrentForeground` slots.
166
+ *
167
+ * Keyed on `blocking` — a caller awaiting this record inline — rather than on
168
+ * `isBackground === false`, because `spawn()` is also the funnel for DETACHED
169
+ * starts (cross-extension RPC, `@handle` mentions, the registry) that may pass
170
+ * `isBackground: false` and are documented to run immediately regardless. Those
171
+ * block nobody, so bounding them buys nothing and would park a record with no
172
+ * one waiting to release it.
173
+ *
174
+ * Nested children are excluded for the same reason as `occupiesPoolSlot`, and
175
+ * more sharply: their parent is blocked *awaiting them*, so queueing a child
176
+ * behind its own parent is a guaranteed deadlock rather than a possible one.
177
+ * Enforced here rather than at the call site so no caller can reintroduce it.
178
+ *
179
+ * A workflow's children go out through `spawnAndWait` and so are `blocking`
180
+ * too, and are excluded on the same `isTopLevelAgent` test as the background
181
+ * pool: the run already caps how many of its agents run at once, and charging
182
+ * them here as well would let one fan-out queue behind a limit meant for the
183
+ * session's own work.
184
+ *
185
+ * Like the background pool this bounds width at the top level only — a parent's
186
+ * own fan-out is limited by nothing but its turn budget.
187
+ */
89
188
  function occupiesForegroundSlot(
90
- record: Pick<AgentRecord, "blocking" | "parentAgentId">,
189
+ record: Pick<AgentRecord, "blocking" | "parentAgentId" | "workflowId">,
91
190
  ): boolean {
92
- return !!record.blocking && record.parentAgentId === undefined
191
+ return !!record.blocking && isTopLevelAgent(record)
93
192
  }
94
193
 
194
+ /** Which concurrency pool a spawn is charged to, if any. */
95
195
  type Pool = "background" | "foreground"
96
196
 
97
197
  interface SpawnArgs {
@@ -104,6 +204,31 @@ interface SpawnArgs {
104
204
 
105
205
  interface SpawnOptions {
106
206
  description: string
207
+ /**
208
+ * Optional memorable name for this instance, becoming a second handle
209
+ * (`@auth-audit`) alongside the type-derived one. Slugged, not validated —
210
+ * anything unusable degrades via `handleBase` rather than failing the spawn.
211
+ */
212
+ name?: string
213
+ /**
214
+ * Reopen this pi session file instead of starting a fresh conversation, so a
215
+ * mention of an evicted agent continues where it left off. The agent's
216
+ * definition is still resolved from its type, so the continuation runs under
217
+ * the type's CURRENT config.
218
+ */
219
+ resumeSessionFile?: string
220
+ /**
221
+ * Take an evicted agent's names back verbatim instead of allocating fresh
222
+ * ones, so a resumed conversation keeps the handle the user just typed —
223
+ * `handleBase(type)` cannot reproduce a numbered `explore-2`. Safe without an
224
+ * `assignHandle` pass because tombstoned names are excluded from allocation
225
+ * (`takenHandles`), so nothing live can be holding them.
226
+ *
227
+ * Internal capability, like `resumeSessionFile`: a forged handle would
228
+ * duplicate a live agent's name and make `resolveMention` ambiguous, so
229
+ * `spawnTopLevel` strips it from anything a caller sends.
230
+ */
231
+ reclaim?: { handle: string; alias?: string }
107
232
  model?: Model<any>
108
233
  maxTurns?: number
109
234
  isolated?: boolean
@@ -111,13 +236,38 @@ interface SpawnOptions {
111
236
  thinkingLevel?: ThinkingLevel
112
237
  isBackground?: boolean
113
238
  /**
114
- * Skip the maxConcurrent queue check for this spawn — start immediately even
115
- * if the configured concurrency limit would otherwise queue it. Used by the
116
- * scheduler so a fired job can't be deferred past its trigger window.
239
+ * Skip whichever pool's queue check applies to this spawn — start immediately
240
+ * even if the configured concurrency limit would otherwise queue it. The slot
241
+ * is still COUNTED once the run starts, so a bypassing spawn transiently
242
+ * exceeds the limit rather than being invisible to it.
243
+ *
244
+ * Used by the scheduler, so a fired job can't be deferred past its trigger
245
+ * window, and by the `/agents` agent-file generator, which has no way to
246
+ * cancel a wait (see its call site).
117
247
  */
118
248
  bypassQueue?: boolean
119
- /** Set only by spawnAndWait; detached/RPC spawns do not occupy the foreground pool. */
249
+ /**
250
+ * A caller is awaiting this record inline (`spawnAndWait`) — what
251
+ * `maxConcurrentForeground` bounds. Set only by `spawnAndWait`; stripped from
252
+ * caller-supplied options by `spawnTopLevel`, since a forged `blocking` would
253
+ * defer a detached start behind a queue its caller cannot see or release.
254
+ */
120
255
  blocking?: boolean
256
+ /**
257
+ * The workflow run this child belongs to, when a workflow spawned it.
258
+ *
259
+ * Ownership, not decoration. A workflow's children are the workflow's — they
260
+ * report through its card, its notification and its dialog, so they are
261
+ * filtered out of every top-level surface exactly as nested children are, and
262
+ * they take no `maxConcurrent` slot: the run has its own concurrency cap, and
263
+ * counting them twice would let one workflow starve the whole session.
264
+ */
265
+ workflowId?: string
266
+ /**
267
+ * Make the child report through a `StructuredOutput` tool built from this
268
+ * compiled schema. Set only by the workflow host, for `agent({ schema })`.
269
+ */
270
+ structuredOutput?: CompiledSchema
121
271
  /** Isolation mode — "worktree" creates a temp git worktree for the agent. */
122
272
  isolation?: IsolationMode
123
273
  /**
@@ -129,13 +279,41 @@ interface SpawnOptions {
129
279
  * branch lands in that repo.
130
280
  */
131
281
  cwd?: string
282
+ /**
283
+ * Last chance to look at an isolated agent's worktree, awaited immediately
284
+ * before it is committed to a branch and removed.
285
+ *
286
+ * Exists because that removal happens inside the settle path, before
287
+ * `spawnAndWait` resolves: by the time a caller has the finished record, the
288
+ * directory the child actually wrote in is gone. Anything that must inspect
289
+ * or verify that tree — a workflow `gate` is the motivating case — has to run
290
+ * here or it silently inspects the main tree instead.
291
+ *
292
+ * Fires only on the normal settle path, and only when a worktree was created.
293
+ * Not on the error path and not on the stop-during-copy guard: those are
294
+ * already failing, and delaying cleanup there would leak a copy for no gain.
295
+ * A rejection is swallowed — the hook can never keep the worktree alive.
296
+ */
297
+ onBeforeWorktreeCleanup?: (worktreePath: string) => Promise<void>
132
298
  /** Resolved invocation snapshot captured for UI display. */
133
299
  invocation?: AgentInvocation
134
300
  /** Parent abort signal — when aborted, the subagent is also stopped. */
135
301
  signal?: AbortSignal
136
- /** Called when this specific record has started and has a promise. */
302
+ /**
303
+ * Called synchronously once the record is in the map and its promise is set,
304
+ * before `onSessionCreated` fires — where callers attach the output file.
305
+ *
306
+ * Carried on the options rather than parked on the manager for the duration
307
+ * of a spawn: with a foreground queue, `startAgent` can run at drain time,
308
+ * long after any such field would have been restored, and the callback would
309
+ * silently never fire (or fire into an unrelated caller's closure).
310
+ */
137
311
  onSpawned?: (id: string) => void
138
- /** Called when this record enters its pool's queue. */
312
+ /**
313
+ * Called synchronously when the spawn is queued instead of started, with how
314
+ * many entries in its own pool are ahead of it. The foreground UI uses it to
315
+ * say so while it waits; nothing else needs it.
316
+ */
139
317
  onQueued?: (id: string, ahead: number) => void
140
318
  /** Called on tool start/end with activity info (for streaming progress to UI). */
141
319
  onToolActivity?: (activity: ToolActivity) => void
@@ -146,7 +324,11 @@ interface SpawnOptions {
146
324
  /** Called at the end of each agentic turn with the cumulative count. */
147
325
  onTurnEnd?: (turnCount: number) => void
148
326
  /** Called once per assistant message_end with that message's usage delta. */
149
- onAssistantUsage?: (usage: LifetimeUsage) => void
327
+ onAssistantUsage?: (usage: {
328
+ input: number
329
+ output: number
330
+ cacheWrite: number
331
+ }) => void
150
332
  /** Called when the session successfully compacts. */
151
333
  onCompaction?: (info: CompactionInfo) => void
152
334
  /** Nesting depth: top-level subagent = 1. */
@@ -172,10 +354,12 @@ interface ResumeOptions {
172
354
  isBackground?: boolean
173
355
  /** Called on tool start/end with activity info (for streaming progress to UI). */
174
356
  onToolActivity?: (activity: ToolActivity) => void
175
- /** Called at the end of each resumed agentic turn with the cumulative count. */
176
- onTurnEnd?: (turnCount: number) => void
177
357
  /** Called once per assistant message_end with that message's usage delta. */
178
- onAssistantUsage?: (usage: LifetimeUsage) => void
358
+ onAssistantUsage?: (usage: {
359
+ input: number
360
+ output: number
361
+ cacheWrite: number
362
+ }) => void
179
363
  /** Called when the session successfully compacts. */
180
364
  onCompaction?: (info: CompactionInfo) => void
181
365
  /**
@@ -189,14 +373,29 @@ interface ResumeOptions {
189
373
  onStarted?: () => void
190
374
  }
191
375
 
376
+ /** Best-effort ceiling on one child's shutdown handlers, so teardown can't strand a quit. */
192
377
  const CHILD_SHUTDOWN_TIMEOUT_MS = 3_000
193
378
 
379
+ /**
380
+ * Close the extension lifecycle `runAgent` opened with `bindExtensions`, then dispose.
381
+ *
382
+ * `AgentSession.dispose()` only calls `ExtensionRunner.invalidate()` — pi emits the event
383
+ * itself in `AgentSessionRuntime.dispose()` beforehand, and this is the one place that binds
384
+ * extensions onto a session without going through that path. Without the emit, everything an
385
+ * extension armed in `session_start` leaks once per spawn, and its next tick throws
386
+ * `assertActive()` from a bare timer callback — an uncaughtException that kills pi (#242).
387
+ */
194
388
  async function shutdownChildSession(
195
389
  session: AgentSession | undefined,
196
390
  ): Promise<void> {
197
391
  try {
198
392
  const runner = session?.extensionRunner
393
+ // Optional all the way down: on a pi without the getter, or a stubbed session from a
394
+ // partial `onSessionCreated`, skip the emit — the same degrade as before this fix.
199
395
  if (runner?.hasHandlers?.("session_shutdown")) {
396
+ // Raced, not awaited outright. `emit` runs every handler serially with no timeout of
397
+ // its own, and dispose() is reached from pi's own `session_shutdown` with the TUI
398
+ // already torn down — one hung handler would leave a dead terminal.
200
399
  await Promise.race([
201
400
  runner.emit({ type: "session_shutdown", reason: "quit" }),
202
401
  new Promise<void>((resolve) =>
@@ -205,8 +404,9 @@ async function shutdownChildSession(
205
404
  ])
206
405
  }
207
406
  } catch {
208
- /* best-effort lifecycle cleanup */
407
+ /* a partial session must degrade, not take the teardown down with it */
209
408
  }
409
+ // Always, even on timeout: disposal is what this function ultimately exists to do.
210
410
  try {
211
411
  session?.dispose?.()
212
412
  } catch {
@@ -227,14 +427,48 @@ export class AgentManager {
227
427
  * not just the parent repo (caller-supplied cwd can target other repos). */
228
428
  private worktreeRepos = new Set<string>()
229
429
 
230
- /** Agents waiting on either independent concurrency pool. */
430
+ /**
431
+ * Startup phases, keyed by agent id. `spawn()` still returns synchronously,
432
+ * but an agent using worktree isolation is not running yet when it does —
433
+ * copying the repo is an awaited git call. This is what `awaitStartup` hands
434
+ * callers that must fail their tool call on a startup failure, and what
435
+ * `waitForAll` waits on while a record is "running" with no `promise` yet.
436
+ * Entries are dropped once the run is underway, and kept (rejected) after a
437
+ * startup failure so a late `awaitStartup` still sees it.
438
+ */
439
+ private startups = new Map<string, Promise<void>>()
440
+
441
+ /**
442
+ * Evicted agents that can still be reached by name, keyed by handle. Outlives
443
+ * the 10-minute record cleanup — that timer exists to bound memory, not to
444
+ * expire a conversation the user might still want — and is cleared alongside
445
+ * completed records on session start/switch.
446
+ */
447
+ private tombstones = new Map<string, AgentTombstone>()
448
+
449
+ /**
450
+ * Agents waiting to start, tagged with the pool they wait on. One queue for
451
+ * both pools: `drainQueue` picks the earliest entry whose own pool has room,
452
+ * so neither can head-of-line-block the other, and every removal path
453
+ * (`abort`, `abortAll`, `dispose`) stays a single filter.
454
+ *
455
+ * `release` wakes a caller blocked in `spawnAndWait`, and is fired once the
456
+ * entry's `start` has SETTLED rather than at drain time: startup is async
457
+ * now, so releasing earlier would wake the caller before `record.promise`
458
+ * exists and it would read a still-starting agent as one that never ran.
459
+ * Removing an entry from this array MUST release it — a queued record has no
460
+ * promise to await, and pi has no tool-execution timeout to bail the caller
461
+ * out.
462
+ */
231
463
  private queue: {
232
464
  id: string
233
465
  pool: Pool
234
- start: () => void
466
+ start: () => Promise<void>
235
467
  release: () => void
236
468
  }[] = []
469
+ /** Number of currently running background agents. */
237
470
  private runningBackground = 0
471
+ /** Number of currently running foreground (blocking) agents. */
238
472
  private runningForeground = 0
239
473
 
240
474
  constructor(
@@ -265,9 +499,12 @@ export class AgentManager {
265
499
  return this.maxConcurrent
266
500
  }
267
501
 
268
- /** Update the max concurrent blocking agents limit. 0 means unlimited. */
502
+ /** Update the max concurrent foreground (blocking) agents limit. 0 = unlimited. */
269
503
  setMaxConcurrentForeground(n: number) {
504
+ // Floor 0, not 1: unlimited is a meaningful value here and the default.
270
505
  this.maxConcurrentForeground = Math.max(0, n)
506
+ // Start queued agents if the new limit allows — including everything, when
507
+ // the limit is cleared back to unlimited mid-run.
271
508
  this.drainQueue()
272
509
  }
273
510
 
@@ -275,11 +512,22 @@ export class AgentManager {
275
512
  return this.maxConcurrentForeground
276
513
  }
277
514
 
515
+ /**
516
+ * Which pool a spawn is charged to, or undefined for one that is charged to
517
+ * neither (nested children, detached non-background spawns).
518
+ *
519
+ * Nothing here queues when the limit is unset — `poolHasRoom` reports an
520
+ * unlimited pool as always having room, so that alone is what keeps the
521
+ * default path identical. The `> 0` guard is belt and braces on top: it also
522
+ * keeps the counter from churning and the settle path from calling a drain
523
+ * that would find nothing to do. Both are unobservable, which is why no test
524
+ * pins them; the observable half — that the default start stays synchronous —
525
+ * is pinned in `test/foreground-concurrency.test.ts`.
526
+ */
278
527
  private poolFor(record: AgentRecord): Pool | undefined {
279
528
  if (occupiesPoolSlot(record)) return "background"
280
- if (this.maxConcurrentForeground > 0 && occupiesForegroundSlot(record)) {
529
+ if (this.maxConcurrentForeground > 0 && occupiesForegroundSlot(record))
281
530
  return "foreground"
282
- }
283
531
  return undefined
284
532
  }
285
533
 
@@ -293,6 +541,11 @@ export class AgentManager {
293
541
  /**
294
542
  * Spawn an agent and return its ID immediately (for background use).
295
543
  * If the concurrency limit is reached, the agent is queued.
544
+ *
545
+ * The id comes back synchronously, but with `isolation: "worktree"` the agent
546
+ * is not running yet when it does — the repo copy is an awaited git call.
547
+ * Callers that must fail a tool call on a startup failure await
548
+ * `awaitStartup(id)`; everyone else sees it on the record (status "error").
296
549
  */
297
550
  spawn(
298
551
  pi: ExtensionAPI,
@@ -311,12 +564,28 @@ export class AgentManager {
311
564
  const record: AgentRecord = {
312
565
  id,
313
566
  type,
567
+ // Owned children — nested, or a workflow's — are filtered out of every
568
+ // top-level surface, so no handle: nothing can address them and they must
569
+ // not consume a name a top-level sibling could otherwise take.
570
+ handle: !isTopLevelAgent(options)
571
+ ? undefined
572
+ : // A reclaimed handle is used as-is: it belongs to the conversation this
573
+ // spawn is reopening, and re-deriving it would lose the numbering.
574
+ (options.reclaim?.handle ??
575
+ assignHandle(handleBase(type), this.takenHandles())),
314
576
  description: options.description,
577
+ // Reclaimed here, or filled in below from `name` — in which case it must
578
+ // see the handle this record just took, since both come out of the same
579
+ // namespace.
580
+ alias: isTopLevelAgent(options) ? options.reclaim?.alias : undefined,
581
+ // Overwritten below when the spawn is actually queued; a foreground spawn
582
+ // that queues flips to "queued" there rather than being guessed at here,
583
+ // since the pool decision needs the finished record.
315
584
  status: options.isBackground ? "queued" : "running",
316
585
  toolUses: 0,
317
586
  startedAt: Date.now(),
318
587
  abortController,
319
- lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
588
+ lifetimeUsage: { input: 0, output: 0, cacheWrite: 0, cost: 0 },
320
589
  compactionCount: 0,
321
590
  // Raw tri-state (not coerced to a boolean): true = background, false =
322
591
  // foreground (has an inline tool-result surface), undefined = caller never
@@ -324,20 +593,39 @@ export class AgentManager {
324
593
  // only filter excludes only explicit `false`, so undefined agents — which
325
594
  // have no inline surface — stay visible instead of vanishing.
326
595
  isBackground: options.isBackground,
596
+ // Whether anyone is awaiting this agent is a property of the agent, not
597
+ // of the call that made it — and both settle paths need it long after
598
+ // `options` has stopped being the interesting object.
327
599
  blocking: options.blocking,
328
600
  invocation: options.invocation,
329
601
  depth: options.depth ?? 1,
330
602
  parentAgentId: options.parentAgentId,
603
+ workflowId: options.workflowId,
331
604
  maxSubagentDepth: options.maxSubagentDepth,
332
605
  rootSessionId: options.rootSessionId,
333
606
  }
334
607
  this.agents.set(id, record)
608
+ // After the insert, so `takenHandles()` already counts this record's own
609
+ // handle — a spawn named after its own type gets `explore-2`, not a
610
+ // duplicate `explore` that would make resolution ambiguous.
611
+ if (
612
+ record.handle !== undefined &&
613
+ record.alias === undefined &&
614
+ options.name !== undefined
615
+ ) {
616
+ record.alias = assignHandle(handleBase(options.name), this.takenHandles())
617
+ }
335
618
 
336
619
  const args: SpawnArgs = { pi, ctx, type, prompt, options }
337
620
 
338
621
  const pool = this.poolFor(record)
339
- if (pool && !options.bypassQueue && !this.poolHasRoom(pool)) {
622
+ if (pool !== undefined && !options.bypassQueue && !this.poolHasRoom(pool)) {
623
+ // Queue it — started when a running agent in the same pool completes.
624
+ // Idempotent for background (already "queued"); the flip that matters is
625
+ // a blocking foreground spawn, optimistically marked "running" above.
340
626
  record.status = "queued"
627
+ // A queued record never reaches startAgent's signal wiring, so arm the
628
+ // parent abort here or Esc could not release the position.
341
629
  if (!this.armQueuedAbort(id, options.signal)) return id
342
630
  let release!: () => void
343
631
  record.startGate = new Promise<void>((resolve) => {
@@ -346,29 +634,37 @@ export class AgentManager {
346
634
  this.queue.push({
347
635
  id,
348
636
  pool,
349
- start: () => this.startAgent(id, record, args),
350
- release,
637
+ start: () => this.launch(id, record, args, pool),
638
+ release: () => release(),
351
639
  })
352
640
  options.onQueued?.(
353
641
  id,
354
- this.queue.filter((entry) => entry.pool === pool).length - 1,
642
+ this.queue.filter((e) => e.pool === pool).length - 1,
355
643
  )
356
644
  return id
357
645
  }
358
646
 
359
- // startAgent can throw (e.g. strict worktree-isolation failure) — clean
360
- // up the record so callers don't see an orphan in `listAgents()`.
361
- try {
362
- this.startAgent(id, record, args)
363
- } catch (err) {
364
- this.agents.delete(id)
365
- throw err
366
- }
647
+ this.launch(id, record, args, undefined)
367
648
  return id
368
649
  }
369
650
 
651
+ /**
652
+ * Wire a parent abort signal for a record that is about to be QUEUED.
653
+ * `startAgent` does this for running agents, and a queued record never gets
654
+ * there, so without this Esc could not release a queue position.
655
+ *
656
+ * Returns false when the signal is ALREADY aborted, in which case the record
657
+ * is stopped here and must not be enqueued: `addEventListener` never fires on
658
+ * an aborted signal, so a `spawnAndWait` on it would wait forever — pi has no
659
+ * tool-execution timeout to bail it out.
660
+ *
661
+ * The listener is left in place when the agent starts. `startAgent` adds its
662
+ * own, so both fire on a later abort, but `abort()` on an already-stopped
663
+ * record is a no-op — so detaching would only be tidiness, and tidiness the
664
+ * `abortAll`/`dispose` paths could not offer anyway.
665
+ */
370
666
  private armQueuedAbort(id: string, signal?: AbortSignal): boolean {
371
- if (!signal) return true
667
+ if (signal === undefined) return true
372
668
  if (signal.aborted) {
373
669
  const record = this.agents.get(id)
374
670
  if (record) {
@@ -381,8 +677,71 @@ export class AgentManager {
381
677
  return true
382
678
  }
383
679
 
680
+ /**
681
+ * Kick off an agent's startup and register it under `startups`. The returned
682
+ * promise never rejects — the failure is delivered through `awaitStartup`,
683
+ * and to the record.
684
+ *
685
+ * @param queuedPool - The pool this start was QUEUED on, or undefined for an
686
+ * immediate start. A queue drain can be minutes after `spawn()` returned,
687
+ * and nobody is awaiting `awaitStartup` by then, so a failure has to live
688
+ * on the record as status "error" — what drainQueue did when the throw was
689
+ * still synchronous. An immediate start instead drops the record, exactly
690
+ * as the throw out of `spawn()` did: no orphan in `listAgents()`, and the
691
+ * handle goes back.
692
+ */
693
+ private launch(
694
+ id: string,
695
+ record: AgentRecord,
696
+ args: SpawnArgs,
697
+ queuedPool: Pool | undefined,
698
+ ): Promise<void> {
699
+ const startup = this.startAgent(id, record, args).then(
700
+ () => {
701
+ this.startups.delete(id)
702
+ },
703
+ (err) => {
704
+ this.startups.delete(id)
705
+ if (queuedPool !== undefined) {
706
+ // Mirrors settleRun: an inline caller gets this failure as a throw
707
+ // out of spawnAndWait, so an unconsumed record would ALSO nudge the
708
+ // session about it — the same failure reported twice.
709
+ if (queuedPool === "foreground") record.resultConsumed = true
710
+ record.status = "error"
711
+ record.error = err instanceof Error ? err.message : String(err)
712
+ record.completedAt = Date.now()
713
+ this.onComplete?.(record)
714
+ } else {
715
+ this.agents.delete(id)
716
+ }
717
+ // The agent never kept its slot (startAgent gives it back on failure),
718
+ // so anything queued behind it can go now.
719
+ this.drainQueue()
720
+ throw err
721
+ },
722
+ )
723
+ this.startups.set(id, startup)
724
+ // Nothing is obliged to await `startups` — swallow the rejection once here
725
+ // so an unawaited startup can't take the process down, and hand callers
726
+ // (drainQueue) that swallowed promise.
727
+ return startup.catch(() => {})
728
+ }
729
+
730
+ /**
731
+ * Resolves once the agent is actually running, and rejects with the startup
732
+ * failure (strict worktree isolation) that `spawn()` used to throw before the
733
+ * repo copy became async. Resolves immediately for an agent that is already
734
+ * running, still queued, or unknown — so callers can await it unconditionally.
735
+ *
736
+ * Call it in the same tick as the `spawn()` it belongs to: a failed startup
737
+ * takes its record (and this entry) with it, exactly as the throw did.
738
+ */
739
+ awaitStartup(id: string): Promise<void> {
740
+ return this.startups.get(id) ?? Promise.resolve()
741
+ }
742
+
384
743
  /** Actually start an agent (called immediately or from queue drain). */
385
- private startAgent(
744
+ private async startAgent(
386
745
  id: string,
387
746
  record: AgentRecord,
388
747
  { pi, ctx, type, prompt, options }: SpawnArgs,
@@ -396,13 +755,42 @@ export class AgentManager {
396
755
  const customCwd = options.cwd ?? undefined // null (RPC "unset") → undefined
397
756
  const baseCwd = customCwd ?? ctx.cwd
398
757
 
758
+ // Take the running state — and with it the concurrency slot — BEFORE the
759
+ // first await. Creating a worktree is an awaited git call, and drainQueue
760
+ // reads the pool counters synchronously in a loop: incrementing after the
761
+ // await would let it start every queued agent at once while the first is
762
+ // still copying its repo. Claiming "running" here also keeps abort() and
763
+ // abortAll() able to reach an agent whose worktree is still being created.
764
+ //
765
+ // The pool is resolved ONCE, here, and carried to `settleRun` below:
766
+ // `poolFor` reads `maxConcurrentForeground`, which the user can change from
767
+ // `/agents → Settings` mid-run, so recomputing it at settle time would
768
+ // decrement a pool this run never charged (counter underflow, limit
769
+ // silently lifted) or skip the decrement for one it did (leaked slot —
770
+ // every later blocking spawn queues forever). The two startup exits below
771
+ // never reach `settleRun`, so they hand the slot back themselves.
772
+ const pool = this.poolFor(record)
773
+ const releaseSlot = () => {
774
+ if (pool === "background") this.runningBackground--
775
+ else if (pool === "foreground") this.runningForeground--
776
+ }
777
+ record.status = "running"
778
+ record.startedAt = Date.now()
779
+ record.startGate = undefined
780
+ if (pool === "background") this.runningBackground++
781
+ else if (pool === "foreground") this.runningForeground++
782
+
399
783
  // Worktree isolation: try to create a temporary git worktree. Strict —
400
- // fail loud if not possible (no silent fallback to main tree). Done
401
- // BEFORE state mutation so a throw doesn't leave the record half-running.
784
+ // fail loud if not possible (no silent fallback to main tree). Done BEFORE
785
+ // the run is kicked off so a failure doesn't leave a half-running agent.
786
+ // The project switch is enforced here as well as at the tool boundary
787
+ // because cross-extension RPC forwards its options unvalidated — a schema
788
+ // that omits the field can't stop a caller that never saw the schema.
402
789
  let worktreeCwd: string | undefined
403
790
  if (options.isolation === "worktree" && isWorktreeIsolationEnabled()) {
404
- const wt = createWorktree(baseCwd, id)
791
+ const wt = await createWorktree(pi, baseCwd, id)
405
792
  if (!wt) {
793
+ releaseSlot()
406
794
  throw new Error(
407
795
  'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
408
796
  "Initialize git and commit at least once, or omit `isolation`.",
@@ -417,22 +805,38 @@ export class AgentManager {
417
805
  // subdirectory, silently dropping extensions/skills.
418
806
  worktreeCwd = customCwd !== undefined ? wt.workPath : wt.path
419
807
  this.worktreeRepos.add(baseCwd)
808
+
809
+ // No longer "running" means a stop landed while the copy was being made
810
+ // (abort(), abortAll()) — a window that did not exist when creation was
811
+ // synchronous. The record is already terminal, so launching the run would
812
+ // burn tokens on work nobody is waiting for: discard the fresh (and by
813
+ // definition unchanged) worktree instead.
814
+ if (record.status !== "running") {
815
+ releaseSlot()
816
+ record.worktreeResult = await cleanupWorktree(
817
+ pi,
818
+ baseCwd,
819
+ wt,
820
+ options.description,
821
+ )
822
+ if (applyWorktreeCleanupFailure(record, record.worktreeResult)) {
823
+ throw new Error(record.error)
824
+ }
825
+ this.drainQueue()
826
+ return
827
+ }
420
828
  }
421
829
 
422
- record.status = "running"
423
- record.startedAt = Date.now()
424
- record.startGate = undefined
425
- const pool = this.poolFor(record)
426
- if (pool === "background") this.runningBackground++
427
- else if (pool === "foreground") this.runningForeground++
428
830
  this.onStart?.(record)
429
831
 
430
832
  // Wire parent abort signal to stop the subagent when the parent is interrupted
431
833
  let detachParentSignal: (() => void) | undefined
432
834
  if (options.signal) {
433
- if (options.signal.aborted) {
434
- this.abort(id)
435
- } else {
835
+ // A queued spawn can start minutes after the caller handed us its signal,
836
+ // by which time it may already be aborted — and `addEventListener` would
837
+ // never fire, leaving a child the parent can no longer reach.
838
+ if (options.signal.aborted) this.abort(id)
839
+ else {
436
840
  const onParentAbort = () => this.abort(id)
437
841
  options.signal.addEventListener("abort", onParentAbort, { once: true })
438
842
  detachParentSignal = () =>
@@ -452,12 +856,18 @@ export class AgentManager {
452
856
  isolated: options.isolated,
453
857
  inheritContext: options.inheritContext,
454
858
  thinkingLevel: options.thinkingLevel,
859
+ structuredOutput: options.structuredOutput,
860
+ resumeSessionFile: options.resumeSessionFile,
861
+ nested: options.parentAgentId !== undefined,
862
+ workflow: options.workflowId !== undefined,
455
863
  // Worktree wins for the working dir (the agent must run in the copy —
456
864
  // which, with a custom cwd, was created from that target). Config stays
457
865
  // with the parent project when a caller-supplied cwd is in play; it must
458
866
  // stay undefined otherwise so plain worktree runs keep resolving config
459
867
  // (incl. relative extension paths and memory) inside the worktree copy.
460
868
  cwd: worktreeCwd ?? customCwd,
869
+ // Set iff a worktree was created (see above) — names the directory the
870
+ // copy came from, so the prompt can tell the agent not to work there.
461
871
  worktreeBase: worktreeCwd ? baseCwd : undefined,
462
872
  configCwd:
463
873
  options.configCwd ?? (customCwd !== undefined ? ctx.cwd : undefined),
@@ -486,11 +896,31 @@ export class AgentManager {
486
896
  },
487
897
  onSessionCreated: (session) => {
488
898
  record.session = session
899
+ // Capture now, while the session object exists: after eviction this
900
+ // path is the only thing that can reopen the conversation, and an
901
+ // in-memory session reports undefined, which correctly means
902
+ // "nothing to come back to".
903
+ // Optional chaining, not defensiveness for its own sake: this is the
904
+ // only field read off the session at creation, so an older pi or a
905
+ // stubbed session must degrade to "not resumable" rather than throw
906
+ // and take the whole spawn down with it.
907
+ record.sessionFile = session.sessionManager?.getSessionFile?.()
908
+ // Same reason, different field: the model and thinking level are only
909
+ // knowable once pi has resolved its defaults and clamped the level to
910
+ // what the model supports. Writing them back here makes the record
911
+ // authoritative, so every surface reads one place instead of each
912
+ // re-deriving "session, else the request" for itself.
489
913
  if (session.model) {
490
914
  record.invocation ??= {}
915
+ // Read the kept request first: a caller's level survives being clamped
916
+ // AND, one line later, being replaced by the effective one.
491
917
  const requested =
492
918
  record.invocation.requestedThinking ?? record.invocation.thinking
493
919
  Object.assign(record.invocation, describeModel(session.model))
920
+ // Guarded for the reason above: a session that reports no level keeps
921
+ // the request rather than losing it. Overwriting unconditionally would
922
+ // turn an older or stubbed session into a blank `thinking:` tag, which
923
+ // is worse than the stale-but-true value it replaced.
494
924
  if (session.thinkingLevel) {
495
925
  record.invocation.thinking = session.thinkingLevel
496
926
  if (requested && requested !== session.thinkingLevel) {
@@ -508,61 +938,92 @@ export class AgentManager {
508
938
  options.onSessionCreated?.(session)
509
939
  },
510
940
  })
511
- .then(({ responseText, session, aborted, steered, failure }) => {
512
- // Don't overwrite status if externally stopped via abort()
513
- if (record.status !== "stopped") {
514
- // Precedence: a hard abort keeps "aborted"; then a failed final turn
515
- // (provider error that pi resolved instead of rejecting, #144) is an
516
- // honest "error" — not a completion with an empty or stale result.
517
- if (aborted) {
518
- record.status = "aborted"
519
- } else if (failure) {
520
- record.status = "error"
521
- record.error = failure
522
- } else {
523
- record.status = steered ? "steered" : "completed"
941
+ .then(
942
+ async ({
943
+ responseText,
944
+ session,
945
+ aborted,
946
+ steered,
947
+ failure,
948
+ structuredJson,
949
+ structuredRetried,
950
+ }) => {
951
+ // Don't overwrite status if externally stopped via abort()
952
+ if (record.status !== "stopped") {
953
+ // Precedence: a hard abort keeps "aborted"; then a failed final turn
954
+ // (provider error that pi resolved instead of rejecting, #144) is an
955
+ // honest "error" — not a completion with an empty or stale result.
956
+ if (aborted) {
957
+ record.status = "aborted"
958
+ } else if (failure) {
959
+ record.status = "error"
960
+ record.error = failure
961
+ } else {
962
+ record.status = steered ? "steered" : "completed"
963
+ }
524
964
  }
525
- }
526
- record.result = responseText
527
- record.session = session
528
- record.completedAt ??= Date.now()
529
-
530
- detach()
531
-
532
- // Final flush of streaming output file
533
- if (record.outputCleanup) {
534
- try {
535
- record.outputCleanup()
536
- } catch {
537
- /* ignore */
965
+ record.result = responseText
966
+ // Kept beside `result`, never inside it: `result` is prose meant for a
967
+ // reader — it is previewed, transcribed, and appended to below — while
968
+ // this is a machine-readable payload one caller asked for by schema.
969
+ record.structuredJson = structuredJson
970
+ record.structuredRetried = structuredRetried
971
+ record.session = session
972
+ record.completedAt ??= Date.now()
973
+
974
+ detach()
975
+
976
+ // Final flush of streaming output file
977
+ if (record.outputCleanup) {
978
+ try {
979
+ record.outputCleanup()
980
+ } catch {
981
+ /* ignore */
982
+ }
983
+ record.outputCleanup = undefined
538
984
  }
539
- record.outputCleanup = undefined
540
- }
541
985
 
542
- // Clean up worktree if used
543
- if (record.worktree) {
544
- const wtResult = cleanupWorktree(
545
- baseCwd,
546
- record.worktree,
547
- options.description,
548
- )
549
- record.worktreeResult = wtResult
550
- if (wtResult.hasChanges && wtResult.branch) {
551
- // With a caller-supplied cwd the branch lives in THAT repo, not the
552
- // parent session's — say so, or the orchestrator merges in the wrong repo.
553
- const repoNote = customCwd !== undefined ? ` in \`${baseCwd}\`` : ""
554
- record.result =
555
- (record.result ?? "") +
556
- `\n\n---\nChanges saved to branch \`${wtResult.branch}\`${repoNote}. Merge with: \`git merge ${wtResult.branch}\`${customCwd !== undefined ? ` (run in \`${baseCwd}\`)` : ""}`
986
+ // Clean up worktree if used
987
+ if (record.worktree) {
988
+ // The one moment the child's tree still exists and the child is done
989
+ // writing to it. try/catch, not decoration: a hook that throws must
990
+ // not leave the worktree behind.
991
+ if (options.onBeforeWorktreeCleanup) {
992
+ try {
993
+ await options.onBeforeWorktreeCleanup(record.worktree.path)
994
+ } catch {
995
+ /* ignore — never block cleanup */
996
+ }
997
+ }
998
+ const wtResult = await cleanupWorktree(
999
+ pi,
1000
+ baseCwd,
1001
+ record.worktree,
1002
+ options.description,
1003
+ )
1004
+ record.worktreeResult = wtResult
1005
+ const cleanupFailed = applyWorktreeCleanupFailure(record, wtResult)
1006
+ if (!cleanupFailed && wtResult.hasChanges && wtResult.branch) {
1007
+ // With a caller-supplied cwd the branch lives in THAT repo, not the
1008
+ // parent session's — say so, or the orchestrator merges in the wrong repo.
1009
+ const repoNote =
1010
+ customCwd !== undefined ? ` in \`${baseCwd}\`` : ""
1011
+ // Appended to the prose only. A structured child's caller parses
1012
+ // `structuredJson`, which stays untouched — but `result` is also
1013
+ // what a human reads, so the note still belongs on it.
1014
+ record.result =
1015
+ (record.result ?? "") +
1016
+ `\n\n---\nChanges saved to branch \`${wtResult.branch}\`${repoNote}. Merge with: \`git merge ${wtResult.branch}\`${customCwd !== undefined ? ` (run in \`${baseCwd}\`)` : ""}`
1017
+ }
557
1018
  }
558
- }
559
1019
 
560
- this.abortOwnedChildren(id)
1020
+ this.abortOwnedChildren(id)
561
1021
 
562
- this.settleRun(record, true, pool)
563
- return responseText
564
- })
565
- .catch((err) => {
1022
+ this.settleRun(record, true, pool)
1023
+ return responseText
1024
+ },
1025
+ )
1026
+ .catch(async (err) => {
566
1027
  // Don't overwrite status if externally stopped via abort()
567
1028
  if (record.status !== "stopped") {
568
1029
  record.status = "error"
@@ -585,12 +1046,14 @@ export class AgentManager {
585
1046
  // Best-effort worktree cleanup on error
586
1047
  if (record.worktree) {
587
1048
  try {
588
- const wtResult = cleanupWorktree(
1049
+ const wtResult = await cleanupWorktree(
1050
+ pi,
589
1051
  baseCwd,
590
1052
  record.worktree,
591
1053
  options.description,
592
1054
  )
593
1055
  record.worktreeResult = wtResult
1056
+ applyWorktreeCleanupFailure(record, wtResult)
594
1057
  } catch {
595
1058
  /* ignore cleanup errors */
596
1059
  }
@@ -604,10 +1067,32 @@ export class AgentManager {
604
1067
 
605
1068
  record.promise = promise
606
1069
 
607
- // Per-call hook: safe for parallel and deferred foreground starts.
1070
+ // Notify caller that spawn is complete (record is in the map, promise is set).
1071
+ // Called synchronously — onSessionCreated fires asynchronously inside runAgent.
1072
+ // Used by spawnAndWait to let the caller set up output files before streaming
1073
+ // starts. Read off the options, so a spawn that started from a queue drain
1074
+ // still reaches the caller that queued it.
608
1075
  options.onSpawned?.(id)
609
1076
  }
610
1077
 
1078
+ /**
1079
+ * The shared tail of both settle paths: release whatever pool slot the run
1080
+ * held, notify, and let the queue drain into the freed slot.
1081
+ *
1082
+ * The decrement lives HERE and nowhere else. `abort()` on a running record
1083
+ * only fires its controller and leaves the run to settle normally, so
1084
+ * decrementing there too would double-free — permanently lifting the limit.
1085
+ *
1086
+ * Foreground agents fire `onComplete` for lifecycle symmetry, with
1087
+ * `resultConsumed` set so the callback skips notifications the inline result
1088
+ * already delivered.
1089
+ *
1090
+ * @param guardCallback swallow a throwing `onComplete` (the success path does;
1091
+ * the error path historically did not, and keeps not doing so).
1092
+ * @param pool the pool this run was CHARGED TO at start time — passed in, not
1093
+ * recomputed, so a mid-run change to `maxConcurrentForeground` can't make
1094
+ * the release disagree with the acquire.
1095
+ */
611
1096
  private settleRun(
612
1097
  record: AgentRecord,
613
1098
  guardCallback: boolean,
@@ -626,7 +1111,14 @@ export class AgentManager {
626
1111
  } else {
627
1112
  this.onComplete?.(record)
628
1113
  }
629
- if (record.isBackground || pool) this.drainQueue()
1114
+
1115
+ // The isBackground half reproduces the pre-pool condition exactly — a
1116
+ // background settle has always drained, even for a nested child that held
1117
+ // no slot — so that path is unchanged whether or not the foreground pool is
1118
+ // on. The `pool` half only adds the drain a freed FOREGROUND slot needs.
1119
+ // A drain with nothing freed is a no-op anyway, but "no-op" is a claim
1120
+ // about reachability, and matching the old condition needs no such claim.
1121
+ if (record.isBackground || pool !== undefined) this.drainQueue()
630
1122
  }
631
1123
 
632
1124
  /**
@@ -641,31 +1133,46 @@ export class AgentManager {
641
1133
  }
642
1134
  }
643
1135
 
644
- /** Start the earliest queued agent whose own pool has room. */
1136
+ /**
1137
+ * Start queued agents up to each pool's concurrency limit.
1138
+ *
1139
+ * `findIndex` on the entry's OWN pool rather than `shift`: with one queue
1140
+ * serving two independent limits, a saturated foreground pool at the head
1141
+ * would otherwise stall every background agent behind it. Taking the earliest
1142
+ * eligible entry keeps FIFO within each pool, which is what callers see.
1143
+ */
645
1144
  private drainQueue() {
646
1145
  for (;;) {
647
- const index = this.queue.findIndex((entry) =>
648
- this.poolHasRoom(entry.pool),
649
- )
650
- if (index === -1) return
651
- const [next] = this.queue.splice(index, 1)
1146
+ const i = this.queue.findIndex((e) => this.poolHasRoom(e.pool))
1147
+ if (i === -1) return
1148
+ const [next] = this.queue.splice(i, 1)
652
1149
  const record = this.agents.get(next.id)
653
- try {
654
- if (record?.status === "queued") next.start()
655
- } catch (err) {
656
- if (record) {
657
- if (next.pool === "foreground") record.resultConsumed = true
658
- record.status = "error"
659
- record.error = err instanceof Error ? err.message : String(err)
660
- record.completedAt = Date.now()
661
- this.onComplete?.(record)
662
- }
663
- } finally {
1150
+ // Stale entries (aborted while queued) are not started — but are still
1151
+ // released, since nothing else will.
1152
+ if (record?.status !== "queued") {
664
1153
  next.release()
1154
+ continue
665
1155
  }
1156
+ // Detached, and never rejects: a late failure (e.g. strict worktree
1157
+ // isolation) lands on the record inside `launch`, exactly as the
1158
+ // synchronous throw did here before, and draining continues either way.
1159
+ //
1160
+ // The release waits for that startup to SETTLE rather than firing here.
1161
+ // Startup is async now, so a release at drain time would wake a blocked
1162
+ // `spawnAndWait` while `record.promise` was still undefined, and it would
1163
+ // read a perfectly healthy agent as one that never ran.
1164
+ void next.start().then(
1165
+ () => next.release(),
1166
+ () => next.release(),
1167
+ )
666
1168
  }
667
1169
  }
668
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
+ */
669
1176
  private dequeue(pred: (entry: { id: string; pool: Pool }) => boolean): void {
670
1177
  const kept: typeof this.queue = []
671
1178
  for (const entry of this.queue) {
@@ -675,7 +1182,16 @@ export class AgentManager {
675
1182
  this.queue = kept
676
1183
  }
677
1184
 
678
- /** Spawn an agent, applying the blocking foreground pool, and await it. */
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
+ */
679
1195
  async spawnAndWait(
680
1196
  pi: ExtensionAPI,
681
1197
  ctx: ExtensionContext,
@@ -684,6 +1200,11 @@ export class AgentManager {
684
1200
  options: Omit<SpawnOptions, "isBackground">,
685
1201
  onSpawned?: (id: string) => void,
686
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.
687
1208
  const id = this.spawn(pi, ctx, type, prompt, {
688
1209
  ...options,
689
1210
  isBackground: false,
@@ -691,9 +1212,32 @@ export class AgentManager {
691
1212
  onSpawned,
692
1213
  })
693
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.
694
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: pi only marks a tool result failed
1226
+ // when `execute` throws. A queued spawn's failure landed on the record
1227
+ // instead (nobody was awaiting `startups` at drain time) and is rethrown
1228
+ // 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.
695
1234
  if (record.promise) await record.promise
696
- if (!record.promise && record.status === "error") {
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") {
697
1241
  throw new Error(record.error ?? "Agent failed to start")
698
1242
  }
699
1243
  return { id, record }
@@ -739,11 +1283,24 @@ export class AgentManager {
739
1283
 
740
1284
  const start = () => this.startResume(id, record, prompt, signal, options)
741
1285
  if (occupiesPoolSlot(record) && !this.poolHasRoom("background")) {
742
- // Detached resumes remain on the background pool only.
1286
+ // At the concurrency limit — queue it, drains when a slot frees. A
1287
+ // detached resume has no inline caller, hence nothing to release. The
1288
+ // queue is shared with spawns, whose startup is async, so entries are
1289
+ // promise-shaped even though a resume starts synchronously; failures
1290
+ // land on the record here, since drainQueue no longer catches.
743
1291
  this.queue.push({
744
1292
  id,
745
1293
  pool: "background",
746
- start,
1294
+ start: async () => {
1295
+ try {
1296
+ start()
1297
+ } catch (err) {
1298
+ record.status = "error"
1299
+ record.error = err instanceof Error ? err.message : String(err)
1300
+ record.completedAt = Date.now()
1301
+ this.onComplete?.(record)
1302
+ }
1303
+ },
747
1304
  release: () => {},
748
1305
  })
749
1306
  } else {
@@ -765,7 +1322,6 @@ export class AgentManager {
765
1322
  if (activity.type === "end") record.toolUses++
766
1323
  options?.onToolActivity?.(activity)
767
1324
  },
768
- onTurnEnd: options?.onTurnEnd,
769
1325
  onAssistantUsage: (usage) => {
770
1326
  addUsage(record.lifetimeUsage, usage)
771
1327
  this.onUsage?.(record, usage)
@@ -870,7 +1426,6 @@ export class AgentManager {
870
1426
  if (activity.type === "end") record.toolUses++
871
1427
  options.onToolActivity?.(activity)
872
1428
  },
873
- onTurnEnd: options.onTurnEnd,
874
1429
  onAssistantUsage: (usage) => {
875
1430
  addUsage(record.lifetimeUsage, usage)
876
1431
  this.onUsage?.(record, usage)
@@ -934,6 +1489,83 @@ export class AgentManager {
934
1489
  return this.agents.get(id)
935
1490
  }
936
1491
 
1492
+ /** Handles already in use, so a fresh spawn can pick an unclaimed one. */
1493
+ private takenHandles(): Set<string> {
1494
+ const taken = new Set<string>()
1495
+ for (const record of this.agents.values()) {
1496
+ if (record.handle) taken.add(record.handle)
1497
+ if (record.alias) taken.add(record.alias)
1498
+ }
1499
+ // Tombstones hold their names too: an evicted `@explore` is still
1500
+ // resurrectable, so a later Explore must become `explore-2` rather than
1501
+ // shadowing a conversation the user can still reach.
1502
+ for (const entry of this.tombstones.values()) {
1503
+ taken.add(entry.handle)
1504
+ if (entry.alias) taken.add(entry.alias)
1505
+ }
1506
+ return taken
1507
+ }
1508
+
1509
+ /**
1510
+ * Resolve an `@name` from the prompt. Matches a top-level agent's handle
1511
+ * case-insensitively, preferring one that can still be steered and otherwise
1512
+ * the most recently started (which is the one a resume should continue), then
1513
+ * falls back to an exact agent id so `@<agentId>` works too.
1514
+ */
1515
+ resolveMention(name: string): MentionResolution | undefined {
1516
+ const wanted = name.toLowerCase()
1517
+ let fallback: AgentRecord | undefined
1518
+ for (const record of this.agents.values()) {
1519
+ if (record.parentAgentId !== undefined) continue
1520
+ // Handle and alias share one namespace, so at most one agent answers a
1521
+ // name and it makes no difference which of the two matched.
1522
+ if (
1523
+ record.handle?.toLowerCase() !== wanted &&
1524
+ record.alias?.toLowerCase() !== wanted
1525
+ )
1526
+ continue
1527
+ if (record.status === "running" || record.status === "queued")
1528
+ return { kind: "live", record }
1529
+ if (!fallback || record.startedAt > fallback.startedAt) fallback = record
1530
+ }
1531
+ if (fallback) return { kind: "live", record: fallback }
1532
+ const byId = this.agents.get(name)
1533
+ if (byId?.parentAgentId === undefined && byId !== undefined)
1534
+ return { kind: "live", record: byId }
1535
+ // Only once nothing live answers: a tombstone is a conversation to reopen,
1536
+ // and reopening one while its record still exists would fork the session.
1537
+ for (const entry of this.tombstones.values()) {
1538
+ if (
1539
+ entry.handle.toLowerCase() === wanted ||
1540
+ entry.alias?.toLowerCase() === wanted ||
1541
+ entry.id === name
1542
+ ) {
1543
+ return { kind: "tombstone", entry }
1544
+ }
1545
+ }
1546
+ return undefined
1547
+ }
1548
+
1549
+ /**
1550
+ * Forget an evicted agent, by handle. For the case where its session file has
1551
+ * gone: the entry can then only ever fail, while still holding the name
1552
+ * against the type that would otherwise start a fresh agent under it.
1553
+ *
1554
+ * A *successful* resume does not drop its tombstone — the live record it
1555
+ * creates already wins in `resolveMention`, and overwrites the entry in place
1556
+ * when it is itself evicted.
1557
+ */
1558
+ dropTombstone(handle: string): void {
1559
+ this.tombstones.delete(handle)
1560
+ }
1561
+
1562
+ /** Evicted agents whose conversation can still be reopened, newest first. */
1563
+ listTombstones(): AgentTombstone[] {
1564
+ return [...this.tombstones.values()].sort(
1565
+ (a, b) => b.completedAt - a.completedAt,
1566
+ )
1567
+ }
1568
+
937
1569
  listAgents(): AgentRecord[] {
938
1570
  return [...this.agents.values()].sort((a, b) => b.startedAt - a.startedAt)
939
1571
  }
@@ -942,7 +1574,9 @@ export class AgentManager {
942
1574
  const record = this.agents.get(id)
943
1575
  if (!record) return false
944
1576
 
945
- // Remove from queue if queued and release any blocking waiter.
1577
+ // Remove from queue if queued. No decrement — the slot was never taken —
1578
+ // and no onComplete, matching what a queued background abort has always
1579
+ // done; a blocking caller learns of the stop from its own tool result.
946
1580
  if (record.status === "queued") {
947
1581
  this.dequeue((q) => q.id === id)
948
1582
  record.status = "stopped"
@@ -959,12 +1593,48 @@ export class AgentManager {
959
1593
 
960
1594
  /** Dispose a record's session and remove it from the map. */
961
1595
  private removeRecord(id: string, record: AgentRecord): void {
1596
+ this.tombstone(record)
962
1597
  const session = record.session
1598
+ // Detached before the shutdown starts, so the record leaves the map at once and
1599
+ // nothing can observe a session that is half torn down.
963
1600
  record.session = undefined
964
1601
  this.agents.delete(id)
1602
+ // A failed startup keeps its (rejected) entry so a late awaitStartup still
1603
+ // sees it; drop it with the record so the map can't grow unbounded.
1604
+ this.startups.delete(id)
1605
+ // Fire-and-forget is right here and only here: this runs from the 60s cleanup timer
1606
+ // and from `clearCompleted()` on session boundaries, with the process staying alive,
1607
+ // so handlers get their full window. The quit path awaits instead — see dispose().
965
1608
  void shutdownChildSession(session)
966
1609
  }
967
1610
 
1611
+ /**
1612
+ * Preserve enough of a departing record for `@handle` to reopen its
1613
+ * conversation later. Nothing to keep unless it has both a handle to be
1614
+ * addressed by and a session file to reopen — an in-memory session leaves no
1615
+ * transcript, so the mention would have nothing to continue from.
1616
+ */
1617
+ private tombstone(record: AgentRecord): void {
1618
+ if (!record.handle || !record.sessionFile) return
1619
+ this.tombstones.set(record.handle, {
1620
+ handle: record.handle,
1621
+ alias: record.alias,
1622
+ id: record.id,
1623
+ type: record.type,
1624
+ description: record.description,
1625
+ sessionFile: record.sessionFile,
1626
+ completedAt: record.completedAt ?? Date.now(),
1627
+ })
1628
+ // Bound the memory a long session can accumulate. Oldest first, since the
1629
+ // agent someone still wants to reach is the one they used most recently.
1630
+ while (this.tombstones.size > MAX_TOMBSTONES) {
1631
+ const oldest = [...this.tombstones.values()].reduce((a, b) =>
1632
+ a.completedAt <= b.completedAt ? a : b,
1633
+ )
1634
+ this.tombstones.delete(oldest.handle)
1635
+ }
1636
+ }
1637
+
968
1638
  private cleanup() {
969
1639
  const cutoff = Date.now() - 10 * 60_000
970
1640
  for (const [id, record] of this.agents) {
@@ -986,6 +1656,13 @@ export class AgentManager {
986
1656
  if (skipUnconsumed && !record.resultConsumed) continue
987
1657
  this.removeRecord(id, record)
988
1658
  }
1659
+ // Unconditional: both callers are session boundaries (`session_start` and
1660
+ // `session_before_switch`), and `skipUnconsumed` only spares records whose
1661
+ // results the LLM has yet to read — it does not make the sweep partial in
1662
+ // the sense that matters here. A new session means new handles, or
1663
+ // `@explore` would silently reach an agent the user never started. Claude
1664
+ // Code resets its registry on `/clear` for the same reason.
1665
+ this.tombstones.clear()
989
1666
  }
990
1667
 
991
1668
  /** Whether any agents are still running or queued. */
@@ -1026,35 +1703,49 @@ export class AgentManager {
1026
1703
  // agents finish they start queued ones, which need awaiting too.
1027
1704
  while (true) {
1028
1705
  this.drainQueue()
1029
- const pending = [...this.agents.values()]
1030
- .filter((r) => r.status === "running" || r.status === "queued")
1031
- .map((r) => r.promise)
1032
- .filter(Boolean)
1706
+ const pending: Promise<unknown>[] = []
1707
+ for (const record of this.agents.values()) {
1708
+ if (record.status !== "running" && record.status !== "queued") continue
1709
+ // An agent whose worktree is still being created is "running" with no
1710
+ // `promise` yet — without its startup the wait would return too early.
1711
+ const startup = this.startups.get(record.id)
1712
+ if (startup) pending.push(startup)
1713
+ if (record.promise) pending.push(record.promise)
1714
+ }
1033
1715
  if (pending.length === 0) break
1034
1716
  await Promise.allSettled(pending)
1035
1717
  }
1036
1718
  }
1037
1719
 
1038
- async dispose(): Promise<void> {
1720
+ /**
1721
+ * @param pi - Needed to run `git worktree prune`, which is async now and so
1722
+ * cannot be reached through a stored spawn argument at shutdown. Omitting
1723
+ * it (tests, teardown of a manager that never spawned) skips the prune.
1724
+ */
1725
+ async dispose(pi?: ExtensionAPI): Promise<void> {
1039
1726
  clearInterval(this.cleanupInterval)
1727
+ // Clear queue — via dequeue, so anyone blocked in spawnAndWait is woken
1728
+ // rather than left awaiting a gate nothing will ever resolve.
1040
1729
  this.dequeue(() => true)
1041
1730
  const sessions = [...this.agents.values()].map((record) => record.session)
1042
1731
  this.agents.clear()
1043
- await Promise.all(sessions.map((session) => shutdownChildSession(session)))
1044
- // Prune any orphaned git worktrees (crash recovery)
1045
- try {
1046
- pruneWorktrees(process.cwd())
1047
- } catch {
1048
- /* ignore */
1049
- }
1050
- // Also prune repos that caller-supplied cwds created worktrees in — a clean
1051
- // exit with in-flight agents would otherwise leave stale registrations there.
1052
- for (const repo of this.worktreeRepos) {
1053
- try {
1054
- pruneWorktrees(repo)
1055
- } catch {
1056
- /* ignore */
1732
+ this.startups.clear()
1733
+ if (pi) {
1734
+ // Prune any orphaned git worktrees (crash recovery). Detached: dispose runs
1735
+ // on the shutdown path, which cannot wait for git. Started before the awaited
1736
+ // shutdown below rather than after it, so the git calls have that window to
1737
+ // finish in instead of racing the process exit that follows.
1738
+ const prune = (repo: string) => {
1739
+ pruneWorktrees(pi, repo).catch(() => {})
1057
1740
  }
1741
+ prune(process.cwd())
1742
+ // Also prune repos that caller-supplied cwds created worktrees in — a clean
1743
+ // exit with in-flight agents would otherwise leave stale registrations there.
1744
+ for (const repo of this.worktreeRepos) prune(repo)
1058
1745
  }
1746
+ // Awaited, unlike the eviction path: pi awaits this extension's `session_shutdown`
1747
+ // handler and the process exits right after it returns, so anything left unawaited
1748
+ // here never runs at all. Bounded — each call carries its own ceiling, concurrently.
1749
+ await Promise.all(sessions.map((session) => shutdownChildSession(session)))
1059
1750
  }
1060
1751
  }