@esso0428/pi-subagents 0.17.6 → 0.17.7

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