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