@esso0428/pi-subagents 0.17.16 → 0.17.18

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