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