@esso0428/pi-subagents 0.17.15 → 0.17.17

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 +2 -3
  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
package/src/index.ts CHANGED
@@ -10,36 +10,28 @@
10
10
  * /agents — Interactive agent management menu
11
11
  */
12
12
 
13
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
14
- import { isAbsolute, join } from "node:path";
13
+ import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
14
+ import { join } from "node:path";
15
15
  import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir, getSelectListTheme, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
16
- import { Container, isKeyRelease, Key, matchesKey, SelectList, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
16
+ import { Container, Key, matchesKey, SelectList, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
17
17
  import { Type } from "@sinclair/typebox";
18
- import { abortable } from "./abortable.js";
19
- import { hasAgentBadge, renderAgentName } from "./agent-color.js";
20
- import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile, personalAgentsDir, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.js";
21
- import { readAgentHistory } from "./agent-history.js";
22
- import { canOpenAgentHistory, formatAgentHistoryOption, splitAgentRecords } from "./agent-history-list.js";
23
- import { AgentManager, isTopLevelAgent } from "./agent-manager.js";
24
- import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, steerAgent } from "./agent-runner.js";
25
- import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getConfig, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
26
- import { inChildSessionContext } from "./child-context.js";
18
+ import { agentHistoryLocator, createAgentHistoryPath, readAgentHistory, readAgentHistoryResult } from "./agent-history.js";
19
+ import { buildAgentStatusMenuEntries, canOpenActiveAgent, canOpenAgentHistory, formatAgentHistoryOption, splitAgentRecords } from "./agent-history-list.js";
20
+ import { AgentManager } from "./agent-manager.js";
21
+ import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
22
+ import { applyNicoOverrides, BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, isDefaultsDisabled, registerAgents, resolveType, setDefaultsDisabled } from "./agent-types.js";
27
23
  import { type RpcHandle, registerRpcHandlers } from "./cross-extension-rpc.js";
28
24
  import { loadCustomAgents } from "./custom-agents.js";
25
+ import { isModelInScope, readEnabledModels, resolveEnabledModels } from "./enabled-models.js";
29
26
  import { GroupJoinManager } from "./group-join.js";
30
- import { isolationParam, resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
31
- import { describeMention, handleBase, isReservedHandle, parseMention, resolveHandleToType, stripAgentPrefix } from "./mention.js";
32
- import { runMentionClone } from "./mention-clone.js";
33
- import { describeModel, type ModelRegistry, resolveModel } from "./model-resolver.js";
34
- import { checkModelScope, isScopeModelsEnabled, setScopeModelsEnabled } from "./model-scope.js";
35
- import { getMaxSubagentDepth, setMaxSubagentDepth } from "./nested-tools.js";
36
- import { createOutputFilePath, ensureOutputFile, getOutputTranscriptDefault, sessionTaskDir, setOutputTranscriptDefault, streamToOutputFile, writeInitialEntry } from "./output-file.js";
27
+ import { resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
28
+ import { type ModelRegistry, resolveModel } from "./model-resolver.js";
29
+ import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
37
30
  import { SubagentScheduler } from "./schedule.js";
38
31
  import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
39
- import { applyAndEmitLoaded, loadSettings, type SubagentsSettings, saveAndEmitChanged, type ToolDescriptionMode } from "./settings.js";
40
- import { getForegroundOutcomeNote, getStatusNote, partialOutputSuffix } from "./status-note.js";
41
- import { type AgentConfig, type AgentInvocation, type AgentMentionMode, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType, type ViewerMarkdownMode, type WidgetMode } from "./types.js";
42
- import { createMentionProvider, mentionRoster, type TypeInfo } from "./ui/agent-mention.js";
32
+ import { applyAndEmitLoaded, type SubagentsSettings, saveAndEmitChanged, type ToolDescriptionMode } from "./settings.js";
33
+ import { getStatusNote } from "./status-note.js";
34
+ import { type AgentConfig, type AgentInvocation, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType, type WidgetMode } from "./types.js";
43
35
  import {
44
36
  type AgentActivity,
45
37
  type AgentDetails,
@@ -47,7 +39,6 @@ import {
47
39
  buildInvocationTags,
48
40
  describeActivity,
49
41
  fgPreservingNestedStyles,
50
- formatCost,
51
42
  formatDuration,
52
43
  formatMs,
53
44
  formatTokens,
@@ -59,21 +50,7 @@ import {
59
50
  type UICtx,
60
51
  } from "./ui/agent-widget.js";
61
52
  import { showSchedulesMenu } from "./ui/schedule-menu.js";
62
- import { renderWorkflowCard, renderWorkflowEntryCard } from "./ui/workflow-card.js";
63
- import { showWorkflowsMenu, type WorkflowMenuDeps } from "./ui/workflow-menu.js";
64
- import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, PendingUsagePool, toReportedUsage } from "./usage.js";
65
- import { decideWorkflowCollision, FOREIGN_WORKFLOW_TOOL_NAMES } from "./workflow/collisions.js";
66
- import { WORKFLOW_ENTRY_TYPE, type WorkflowEntryData, workflowEntryData } from "./workflow/entry.js";
67
- import { createWorkflowHost } from "./workflow/host.js";
68
- import { appendJournal, readJournal, type WorkflowJournalEntry } from "./workflow/journal.js";
69
- import { extractMeta, type WorkflowMeta, workflowCallName } from "./workflow/meta.js";
70
- import { elapsedMs } from "./workflow/progress.js";
71
- import { runWorkflow } from "./workflow/runtime.js";
72
- import { resolveWorkflowScript } from "./workflow/saved.js";
73
- import { completeWorkflowTask, createWorkflowTask, failWorkflowTask, formatWorkflowNotification, resolveResumeTarget, updateWorkflowProgressBatch, type WorkflowTask, workflowResultText, workflowRunId } from "./workflow/task.js";
74
- import { fullWorkflowToolDescription } from "./workflow/tool-description.js";
75
- import { isWorktreeIsolationEnabled, setWorktreeIsolationEnabled } from "./worktree.js";
76
- import { escapeXml } from "./xml.js";
53
+ import { addUsage, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage } from "./usage.js";
77
54
 
78
55
  // ---- Shared helpers ----
79
56
 
@@ -82,6 +59,39 @@ function textResult(msg: string, details?: AgentDetails) {
82
59
  return { content: [{ type: "text" as const, text: msg }], details: details as any };
83
60
  }
84
61
 
62
+ /** Await a promise until it settles or the caller cancels, without aborting the underlying work. */
63
+ function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
64
+ if (!signal) return promise;
65
+ if (signal.aborted) return Promise.reject(signal.reason);
66
+
67
+ return new Promise<T>((resolve, reject) => {
68
+ let settled = false;
69
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
70
+ const onAbort = () => {
71
+ if (settled) return;
72
+ settled = true;
73
+ cleanup();
74
+ reject(signal.reason);
75
+ };
76
+
77
+ signal.addEventListener("abort", onAbort, { once: true });
78
+ promise.then(
79
+ (value) => {
80
+ if (settled) return;
81
+ settled = true;
82
+ cleanup();
83
+ resolve(value);
84
+ },
85
+ (error: unknown) => {
86
+ if (settled) return;
87
+ settled = true;
88
+ cleanup();
89
+ reject(error);
90
+ },
91
+ );
92
+ });
93
+ }
94
+
85
95
  export function renderRunningAgentStatus(
86
96
  frame: string,
87
97
  statsText: string,
@@ -138,9 +148,8 @@ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
138
148
  onSessionCreated: (session: any) => {
139
149
  state.session = session;
140
150
  },
141
- // Spend is accumulated on the AgentRecord (agent-manager), which is what
142
- // every surface reads; this callback exists here only to repaint on it.
143
- onAssistantUsage: (_usage: LifetimeUsage) => {
151
+ onAssistantUsage: (usage: { input: number; output: number; cacheWrite: number }) => {
152
+ addUsage(state.lifetimeUsage, usage);
144
153
  onStreamUpdate?.();
145
154
  },
146
155
  };
@@ -157,6 +166,16 @@ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
157
166
  */
158
167
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
159
168
 
169
+ /**
170
+ * Salvaged partial output of a failed run, as a labeled suffix for the error
171
+ * surfaces (or "" if the run produced nothing). `record.result` is bounded to
172
+ * the run's own turns, so this is never a stale earlier answer (#144).
173
+ */
174
+ function partialOutputSuffix(record: AgentRecord, fallback?: string): string {
175
+ const partial = record.result?.trim() || fallback?.trim();
176
+ return partial ? `\n\nPartial output before the failure:\n${partial}` : "";
177
+ }
178
+
160
179
  /** Human-readable status label for agent completion. */
161
180
  function getStatusLabel(status: string, error?: string): string {
162
181
  switch (status) {
@@ -168,18 +187,19 @@ function getStatusLabel(status: string, error?: string): string {
168
187
  }
169
188
  }
170
189
 
190
+ /** Escape XML special characters to prevent injection in structured notifications. */
191
+ function escapeXml(s: string): string {
192
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
193
+ }
194
+
171
195
  /** Format a structured task notification matching Claude Code's <task-notification> XML. */
172
- function formatTaskNotification(record: AgentRecord, resultMaxLen: number, showCost = false): string {
196
+ function formatTaskNotification(record: AgentRecord, resultMaxLen: number): string {
173
197
  const status = getStatusLabel(record.status, record.error);
174
198
  const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
175
199
  const totalTokens = getLifetimeTotal(record.lifetimeUsage);
176
200
  const contextPercent = getSessionContextPercent(record.session);
177
201
  const ctxXml = contextPercent !== null ? `<context_percent>${Math.round(contextPercent)}</context_percent>` : "";
178
202
  const compactXml = record.compactionCount ? `<compactions>${record.compactionCount}</compactions>` : "";
179
- // Only under `showCost`: this is LLM context, and a figure the orchestrator
180
- // did not ask for is a figure it may start reporting unprompted.
181
- const cost = showCost ? getLifetimeCost(record.lifetimeUsage) : 0;
182
- const costXml = cost > 0 ? `<estimated_cost_usd>${cost.toFixed(4)}</estimated_cost_usd>` : "";
183
203
 
184
204
  const resultPreview = record.result
185
205
  ? record.result.length > resultMaxLen
@@ -195,7 +215,7 @@ function formatTaskNotification(record: AgentRecord, resultMaxLen: number, showC
195
215
  `<status>${escapeXml(status)}</status>`,
196
216
  `<summary>Agent "${escapeXml(record.description)}" ${record.status}${getStatusNote(record.status)}</summary>`,
197
217
  `<result>${escapeXml(resultPreview)}</result>`,
198
- `<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}${costXml}<duration_ms>${durationMs}</duration_ms></usage>`,
218
+ `<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}<duration_ms>${durationMs}</duration_ms></usage>`,
199
219
  `</task-notification>`,
200
220
  ].filter(Boolean).join('\n');
201
221
  }
@@ -211,10 +231,6 @@ function buildDetails(
211
231
  ...base,
212
232
  toolUses: record.toolUses,
213
233
  tokens: formatLifetimeTokens(record),
214
- // Raw, and unconditional: `tokens` is preformatted because it is one stat,
215
- // but a cost is joined by "·" in one surface, "," in another and "|" in a
216
- // third — so it travels as a number and each renderer punctuates its own.
217
- cost: getLifetimeCost(record.lifetimeUsage),
218
234
  turnCount: activity?.turnCount,
219
235
  maxTurns: activity?.maxTurns,
220
236
  durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
@@ -237,10 +253,6 @@ function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, act
237
253
  turnCount: activity?.turnCount ?? 0,
238
254
  maxTurns: activity?.maxTurns,
239
255
  totalTokens,
240
- // Carried unconditionally; the renderer gates on the setting. Details are
241
- // data, and a notification rendered before a mid-session toggle should not
242
- // be stuck with the old answer.
243
- totalCost: getLifetimeCost(record.lifetimeUsage),
244
256
  durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
245
257
  outputFile: record.outputFile,
246
258
  error: record.error,
@@ -252,59 +264,7 @@ function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, act
252
264
  };
253
265
  }
254
266
 
255
- /**
256
- * Format an agent's tool scope for the Agent tool description.
257
- *
258
- * This suffix describes BUILT-IN scope only — extension tools are resolved when
259
- * the agent runs (extensions can register asynchronously), so they cannot be
260
- * enumerated while the description is being built. That is why an agent with
261
- * `tools: "*, ext:mcp/search"` renders "*" and always has.
262
- *
263
- * Two distinctions matter, both of them capability claims the orchestrator acts on:
264
- *
265
- * - absent vs empty. `builtinToolNames: undefined` means the agent never narrowed
266
- * its tools (the shipped defaults); `[]` is what `tools: none` and an `ext:`-only
267
- * `tools:` parse to, and the runtime really does hand those agents no built-ins.
268
- * Rendering both "*" tells the orchestrator a tool-less agent can run `bash`.
269
- * - empty-with-extensions vs empty-without. Zero built-ins does NOT imply zero
270
- * tools: `tools: none` alongside `extensions:` still surfaces every extension
271
- * tool (see test/fixtures/.pi/agents/tools-none.md, which expects three). Calling
272
- * that "none" understates the agent instead of overstating it — better, but still
273
- * wrong, and it would route work away from the only agent able to do it. "none"
274
- * is therefore reserved for agents that genuinely can call nothing: `isolated`
275
- * agents and those with `extensions: false`.
276
- */
277
- export function formatToolsSuffix(cfg: AgentConfig | undefined): string {
278
- const tools = cfg?.builtinToolNames;
279
- if (!tools) return "*";
280
- if (tools.length === 0) {
281
- // `isolated` overrides extensions to false in the runner, so both mean the
282
- // agent has no extension tools either — and then it truly has nothing.
283
- const noExtensionTools = cfg?.isolated === true || cfg?.extensions === false;
284
- return noExtensionTools ? "none" : "no built-ins, extension tools only";
285
- }
286
- const isFullSet =
287
- tools.length === BUILTIN_TOOL_NAMES.length
288
- && BUILTIN_TOOL_NAMES.every((t) => tools.includes(t));
289
- return isFullSet ? "*" : tools.join(", ");
290
- }
291
-
292
- /** CLI flag that runs a workflow script at session start. */
293
- export const WORKFLOW_FILE_FLAG = "subagents-workflow-file";
294
-
295
- /**
296
- * Re-exported from where they now live, because this is where they were
297
- * defined and a consumer (or a test) that matched a session entry on
298
- * {@link WORKFLOW_ENTRY_TYPE} imports it from here.
299
- */
300
- export { FOREIGN_WORKFLOW_TOOL_NAMES, WORKFLOW_ENTRY_TYPE, type WorkflowEntryData, workflowEntryData };
301
-
302
267
  export default function (pi: ExtensionAPI) {
303
- // Child AgentSessions load normal extensions. Re-entering this extension there
304
- // would create another manager and leak handlers. Nested orchestration is
305
- // injected as scoped custom tools by the existing manager instead.
306
- if (inChildSessionContext()) return;
307
-
308
268
  // ---- Register custom notification renderer ----
309
269
  pi.registerMessageRenderer<NotificationDetails>(
310
270
  "subagent-notification",
@@ -327,10 +287,6 @@ export default function (pi: ExtensionAPI) {
327
287
  if (d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns));
328
288
  if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
329
289
  if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens));
330
- if (showCost) {
331
- const costText = formatCost(d.totalCost ?? 0);
332
- if (costText) parts.push(costText);
333
- }
334
290
  if (d.durationMs > 0) parts.push(formatMs(d.durationMs));
335
291
  if (parts.length) {
336
292
  line += "\n " + parts.map(p => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
@@ -354,91 +310,23 @@ export default function (pi: ExtensionAPI) {
354
310
  }
355
311
 
356
312
  const all = [d, ...(d.others ?? [])];
357
- const rendered = all.map(renderOne);
358
- // A group of agents lands as one notification, and the number a user wants
359
- // from it is what the batch cost — not four figures to add up by hand.
360
- // Derived from the per-agent details rather than carried alongside them:
361
- // one source, so the total can never disagree with the rows above it.
362
- if (showCost && all.length > 1) {
363
- const total = formatCost(all.reduce((sum, a) => sum + (a.totalCost ?? 0), 0));
364
- if (total) {
365
- const tokens = all.reduce((sum, a) => sum + a.totalTokens, 0);
366
- rendered.unshift(theme.fg("dim", `${all.length} agents · ${formatTokens(tokens)} · ${total}`));
367
- }
368
- }
369
- return new Text(rendered.join("\n"), 0, 0);
313
+ return new Text(all.map(renderOne).join("\n"), 0, 0);
370
314
  }
371
315
  );
372
316
 
373
- // ---- Workflow run rendered as a session entry ----
374
- // A workflow launched from the CLI flag has no tool call to hang its result
375
- // card on, so it renders here instead — through the SAME layout the tool
376
- // result uses, not a second one. Custom entries with no registered renderer
377
- // are silently dropped by the host, which is why this is registered at
378
- // activation rather than lazily.
379
- if (typeof pi.registerEntryRenderer === "function") {
380
- pi.registerEntryRenderer<WorkflowEntryData>(WORKFLOW_ENTRY_TYPE, (entry, _options, theme) =>
381
- renderWorkflowEntryCard(entry.data, theme));
382
- }
383
-
384
- // Registered at activation; READ from session_start. The host applies CLI
385
- // values after every extension factory has run, so `getFlag` here would only
386
- // ever hand back the registered default (see the read site below).
387
- if (typeof pi.registerFlag === "function") {
388
- pi.registerFlag(WORKFLOW_FILE_FLAG, {
389
- type: "string",
390
- description:
391
- `Run a workflow script at startup: --${WORKFLOW_FILE_FLAG}=<path>. ` +
392
- "Use the `=` form — the space form consumes the next argument, which would swallow a following prompt.",
393
- });
394
- }
395
-
396
- // Read directly rather than waiting for applyAndEmitLoaded below: this decides
397
- // the initial load, which happens hundreds of lines before settings are applied.
398
- let strictAgentFiles = loadSettings(process.cwd()).strictAgentFiles === true;
399
-
400
317
  /** Reload agents from project/global custom agent dirs and merge with defaults (called on init and each Agent invocation). */
401
- const reloadCustomAgents = (strict = false) => {
402
- const userAgents = loadCustomAgents(process.cwd(), strict);
318
+ const reloadCustomAgents = () => {
319
+ const userAgents = loadCustomAgents(process.cwd());
403
320
  registerAgents(userAgents);
321
+ applyNicoOverrides();
404
322
  };
405
323
 
406
- // Initial load — the only strict one. A bad edit mid-session must not kill the
407
- // session on the next unrelated spawn, so every later reload keeps warning.
408
- reloadCustomAgents(strictAgentFiles);
324
+ // Initial load
325
+ reloadCustomAgents();
409
326
 
410
327
  // ---- Agent activity tracking + widget ----
411
328
  const agentActivity = new Map<string, AgentActivity>();
412
329
 
413
- // ---- Usage reporting (both off by default; see SubagentsSettings) ----
414
- /** Attach subagent spend to tool results, so the parent session counts it. */
415
- let reportUsage = false;
416
- function isReportUsageEnabled(): boolean { return reportUsage; }
417
- function setReportUsage(b: boolean): void {
418
- reportUsage = b;
419
- // Whatever accumulated while it was on is stale the moment it goes off:
420
- // draining it later would bill the parent for a window the user opted out
421
- // of, in one lump, on some unrelated later tool call.
422
- if (!b) pendingUsage.drain();
423
- }
424
- /** Show `~$X` next to token counts in the subagent surfaces. */
425
- let showCost = false;
426
- function isShowCostEnabled(): boolean { return showCost; }
427
- function setShowCost(b: boolean): void { showCost = b; widget.update(); }
428
- /** Name the model and thinking level on the widget's running rows. */
429
- let showModel = false;
430
- function isShowModelEnabled(): boolean { return showModel; }
431
- function setShowModel(b: boolean): void { showModel = b; widget.update(); }
432
- /**
433
- * How much of the conversation viewer renders as Markdown. Read through a
434
- * getter by the viewer rather than captured like `showCost`, because the
435
- * viewer's `m` key writes back here while the overlay is on screen.
436
- */
437
- let viewerMarkdown: ViewerMarkdownMode = "assistant";
438
- function getViewerMarkdown(): ViewerMarkdownMode { return viewerMarkdown; }
439
- function setViewerMarkdown(mode: ViewerMarkdownMode): void { viewerMarkdown = mode; }
440
- const pendingUsage = new PendingUsagePool();
441
-
442
330
  // ---- Cancellable pending notifications ----
443
331
  // Holds notifications briefly so get_subagent_result can cancel them
444
332
  // before they reach pi.sendMessage (fire-and-forget).
@@ -468,7 +356,7 @@ export default function (pi: ExtensionAPI) {
468
356
  function emitIndividualNudge(record: AgentRecord) {
469
357
  if (record.resultConsumed) return; // re-check at send time
470
358
 
471
- const notification = formatTaskNotification(record, 500, showCost);
359
+ const notification = formatTaskNotification(record, 500);
472
360
  const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : '';
473
361
 
474
362
  pi.sendMessage<NotificationDetails>({
@@ -495,9 +383,12 @@ export default function (pi: ExtensionAPI) {
495
383
  scheduleNudge(groupKey, () => {
496
384
  // Re-check at send time
497
385
  const unconsumed = records.filter(r => !r.resultConsumed);
498
- if (unconsumed.length === 0) { widget.update(); return; }
386
+ if (unconsumed.length === 0) {
387
+ widget.update();
388
+ return;
389
+ }
499
390
 
500
- const notifications = unconsumed.map(r => formatTaskNotification(r, 300, showCost)).join('\n\n');
391
+ const notifications = unconsumed.map(r => formatTaskNotification(r, 300)).join('\n\n');
501
392
  const label = partial
502
393
  ? `${unconsumed.length} agent(s) finished (partial — others still running)`
503
394
  : `${unconsumed.length} agent(s) finished`;
@@ -532,44 +423,21 @@ export default function (pi: ExtensionAPI) {
532
423
  const tokens = total > 0
533
424
  ? { input: u.input, output: u.output, total }
534
425
  : undefined;
535
- // The whole run's spend as a pi `Usage` — pi's convention for handing spend
536
- // to a consumer, so `usage.cost.total` and `usage.cacheRead` are where a
537
- // listener already expects them and anything pi adds to `Usage` arrives
538
- // without a change here. Omitted when nothing was spent, so "spent nothing"
539
- // and "never ran" stay distinguishable. Ungated by `showCost`: that setting
540
- // governs what a human is shown, not what the event carries.
541
- //
542
- // `tokens` above is the other convention, kept as it shipped: a flat view
543
- // model like pi's own `SessionStats`, carrying the DISPLAY total, which
544
- // excludes cacheRead (#38). The two answer different questions and neither
545
- // derives from the other.
546
- const usage = toReportedUsage(u);
547
426
  return {
548
427
  id: record.id,
549
428
  type: record.type,
550
429
  description: record.description,
551
- result: record.transcriptPath ? undefined : record.result,
430
+ result: record.result,
552
431
  error: record.error,
553
- transcriptPath: record.transcriptPath,
554
432
  status: record.status,
555
433
  toolUses: record.toolUses,
556
434
  durationMs,
557
435
  tokens,
558
- usage,
559
436
  };
560
437
  }
561
438
 
562
439
  // Background completion: route through group join or send individual nudge
563
- type AgentMenuSelection = { id?: string; index: number };
564
- const historyAgentSelection: AgentMenuSelection = { index: 0 };
565
- const runningAgentSelection: AgentMenuSelection = { index: 0 };
566
440
  const manager = new AgentManager((record) => {
567
- // Owned children — nested, or a workflow's — report only through their
568
- // owner: the parent's scoped tools, or the workflow's card, notification
569
- // and dialog. Keep them out of top-level lifecycle, transcript,
570
- // notification, and UI channels.
571
- if (!isTopLevelAgent(record)) return;
572
-
573
441
  // Emit lifecycle event based on terminal status
574
442
  const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
575
443
  const eventData = buildEventData(record);
@@ -583,10 +451,16 @@ export default function (pi: ExtensionAPI) {
583
451
  pi.appendEntry("subagents:record", {
584
452
  id: record.id, type: record.type, description: record.description,
585
453
  status: record.status,
454
+ // Durable transcripts are the source of truth for full output. Avoid
455
+ // copying a potentially large result into the parent session branch;
456
+ // get_subagent_result reloads it on demand after cleanup/restart.
586
457
  result: record.transcriptPath ? undefined : record.result,
587
458
  error: record.error,
588
- transcriptPath: record.transcriptPath,
589
459
  startedAt: record.startedAt, completedAt: record.completedAt,
460
+ toolUses: record.toolUses,
461
+ lifetimeUsage: record.lifetimeUsage,
462
+ invocation: record.invocation,
463
+ transcriptPath: record.transcriptPath,
590
464
  });
591
465
 
592
466
  // Skip notification if result was already consumed via get_subagent_result
@@ -612,21 +486,15 @@ export default function (pi: ExtensionAPI) {
612
486
  // 'delivered' → group callback already fired
613
487
  widget.update();
614
488
  }, undefined, (record) => {
615
- if (!isTopLevelAgent(record)) return;
616
- // Agent-tool spawns refresh these surfaces in their tool handler, but RPC
617
- // and scheduler spawns enter through the manager directly.
618
- if (currentCtx?.hasUI && (currentCtx.mode === undefined || currentCtx.mode === "tui")) {
619
- widget.ensureTimer();
620
- widget.update();
621
- }
622
489
  // Emit started event when agent transitions to running (including from queue)
623
490
  pi.events.emit("subagents:started", {
624
491
  id: record.id,
625
492
  type: record.type,
626
493
  description: record.description,
627
494
  });
495
+ widget.ensureTimer();
496
+ widget.update();
628
497
  }, (record, info) => {
629
- if (!isTopLevelAgent(record)) return;
630
498
  // Emit compacted event when agent's session compacts (preserves count on record).
631
499
  pi.events.emit("subagents:compacted", {
632
500
  id: record.id,
@@ -636,17 +504,10 @@ export default function (pi: ExtensionAPI) {
636
504
  tokensBefore: info.tokensBefore,
637
505
  compactionCount: record.compactionCount,
638
506
  });
639
- }, (_record, usage) => {
640
- // Every assistant message from every agent — nested included, exactly once.
641
- // Parked here until a tool result can carry it back to the parent session;
642
- // see `PendingUsagePool`. Skipped entirely when the feature is off, so no
643
- // pool grows in a session that will never drain it.
644
- if (reportUsage) pendingUsage.add(usage);
645
507
  });
646
508
 
647
509
  // Expose manager via Symbol.for() global registry for cross-package access.
648
510
  // Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
649
- // Documented for callers in docs/rpc.md ("The manager registry").
650
511
  //
651
512
  // Claim the slot only if it's free: subagent sessions re-activate this
652
513
  // extension in the same process (session.bindExtensions in agent-runner.ts),
@@ -655,92 +516,12 @@ export default function (pi: ExtensionAPI) {
655
516
  // session's entry. The first activation (the root session) wins; child
656
517
  // activations leave it alone.
657
518
  const MANAGER_KEY = Symbol.for("pi-subagents:manager");
658
- // Process-external callers may supply arbitrary options. Nested ownership and
659
- // config-root metadata are internal capabilities issued only by scoped tools.
660
- /**
661
- * Resolve the agent type and spawn. Trusts its options — every caller must
662
- * either be in-process or have gone through `spawnTopLevel` first.
663
- */
664
- const spawnResolved = (piRef: any, ctxRef: any, type: string, prompt: string, options: any) => {
665
- // Cross-extension callers get the same dispatch contract as the LLM (#183).
666
- // The RPC layer already throws for an unresolvable model rather than falling
667
- // back silently; a bad agent type should not be quieter. Throws become error
668
- // envelopes at the RPC boundary. Reload first so an agent file added mid
669
- // session is spawnable here too, not only through the Agent tool.
670
- reloadCustomAgents();
671
- const dispatch = resolveSpawnType(type);
672
- if (!dispatch.ok) throw new Error(dispatch.message);
673
- // Every programmatic spawn lands here — cross-extension RPC, both `@handle`
674
- // mention paths, and the `Symbol.for("pi-subagents:manager")` registry — and
675
- // none came through the Agent tool, which is where the UI activity tracker is
676
- // otherwise created. Without one the widget has no tool name
677
- // and no turn count, so the row reads `thinking…` for the agent's whole life
678
- // while the header's tool-use count climbs beside it (#181). Double-tracking
679
- // is not possible: the Agent tool calls `manager.spawn` directly. The tracker
680
- // callbacks are the funnel's own — a caller's are not honoured, since a
681
- // half-wired tracker renders worse than none.
682
- //
683
- // The turn limit is resolved rather than read off `options`, which a mention
684
- // spawn deliberately omits so the agent's own config can decide: a tracker
685
- // built with `undefined` renders `↻3` where the Agent tool renders `↻3≤20`.
686
- // Like the tool's own, it is a prediction — editing the agent file mid-run
687
- // leaves the displayed ceiling stale.
688
- const { state, callbacks } = createActivityTracker(resolveEffectiveMaxTurns(dispatch.type, options?.maxTurns));
689
- // Repaints are left to the manager's `onStart` callback, which already starts
690
- // the widget timer for agents that enter this way.
691
- const id = manager.spawn(piRef, ctxRef, dispatch.type, prompt, { ...options, ...callbacks });
692
- agentActivity.set(id, state);
693
- return id;
694
- };
695
-
696
- const spawnTopLevel = (piRef: any, ctxRef: any, type: string, prompt: string, options: any) => {
697
- const safeOptions = { ...(options ?? {}) };
698
- delete safeOptions.parentAgentId;
699
- // Internal too: a forged value would hide an RPC-spawned agent inside
700
- // someone else's workflow, and take it out of the concurrency pool with it.
701
- delete safeOptions.workflowId;
702
- delete safeOptions.depth;
703
- delete safeOptions.maxSubagentDepth;
704
- delete safeOptions.configCwd;
705
- // Also internal: it names a transcript directory, so a forged value would
706
- // be a path-traversal primitive.
707
- delete safeOptions.rootSessionId;
708
- // Worse than rootSessionId: this one names a file to OPEN and replay as a
709
- // conversation. Only the mention dispatcher may set it, and only from a
710
- // path this extension itself recorded — never from anything a caller sent.
711
- delete safeOptions.resumeSessionFile;
712
- // Bypasses handle allocation, so a forged value would duplicate a live
713
- // agent's name and make `@handle` ambiguous. Same rule: dispatcher only.
714
- delete safeOptions.reclaim;
715
- // Every spawn through here is DETACHED — the caller gets an id back and
716
- // awaits nothing. A forged `blocking` would charge it to the foreground
717
- // pool and could defer it behind a queue whose gate nobody is holding.
718
- delete safeOptions.blocking;
719
- return spawnResolved(piRef, ctxRef, type, prompt, safeOptions);
720
- };
721
-
722
- /**
723
- * Resolve a tool's `agent_id` as an id OR a handle, so the model addresses
724
- * agents by the same names the user types. Ids are tried first, keeping the
725
- * existing behaviour exact — a handle is only consulted when the string is
726
- * not an id at all. Only live records: a tombstone has nothing to steer and
727
- * no result to read. Callers still enforce the nested-ownership rejection.
728
- */
729
- const resolveAgentRef = (ref: string): AgentRecord | undefined => {
730
- const byId = manager.getRecord(ref);
731
- if (byId) return byId;
732
- const resolved = manager.resolveMention(ref);
733
- return resolved?.kind === "live" ? resolved.record : undefined;
734
- };
735
-
736
519
  const registryEntry = {
737
520
  waitForAll: () => manager.waitForAll(),
738
521
  hasRunning: () => manager.hasRunning(),
739
- spawn: spawnTopLevel,
740
- getRecord: (id: string) => {
741
- const record = manager.getRecord(id);
742
- return record !== undefined && isTopLevelAgent(record) ? record : undefined;
743
- },
522
+ spawn: (piRef: any, ctx: any, type: string, prompt: string, options: any) =>
523
+ manager.spawn(piRef, ctx, type, prompt, options),
524
+ getRecord: (id: string) => manager.getRecord(id),
744
525
  };
745
526
  const ownsManagerRegistry = (globalThis as any)[MANAGER_KEY] === undefined;
746
527
  if (ownsManagerRegistry) {
@@ -757,8 +538,6 @@ export default function (pi: ExtensionAPI) {
757
538
  // (currentCtx would stay undefined → spawn always "No active session"). Gating
758
539
  // here makes a filtered session behave like an absent one (#142).
759
540
  let rpcHandle: RpcHandle | undefined;
760
- /** Whether the `@handle` autocomplete wrapper has been stacked on pi's provider. */
761
- let mentionProviderRegistered = false;
762
541
 
763
542
  // ---- Subagent scheduler ----
764
543
  // Session-scoped: store is constructed inside session_start once sessionId
@@ -781,26 +560,35 @@ export default function (pi: ExtensionAPI) {
781
560
  }
782
561
  }
783
562
 
563
+ type AgentMenuSelection = { id?: string; index: number };
564
+ let runningAgentSelection: AgentMenuSelection = { index: 0 };
565
+ let historyAgentSelection: AgentMenuSelection = { index: 0 };
566
+
567
+ function resetAgentMenuSelections() {
568
+ runningAgentSelection = { index: 0 };
569
+ historyAgentSelection = { index: 0 };
570
+ }
571
+
784
572
  // Capture ctx from session_start for RPC spawn handler + start the scheduler.
785
573
  // This also wires the RPC handlers and broadcasts readiness — on the first
786
574
  // bound session_start, so a filtered-out activation never advertises (#142).
787
575
  pi.on("session_start", async (_event, ctx) => {
576
+ resetAgentMenuSelections();
788
577
  currentCtx = ctx;
578
+ manager.clearCompleted(true);
579
+ const branch = ctx.sessionManager?.getBranch?.() ?? [];
580
+ manager.restoreCompleted(branch
581
+ .filter((entry: any) => entry?.type === "custom" && entry?.customType === "subagents:record")
582
+ .map((entry: any) => entry.data));
583
+ // Checkpoint files cover agents whose parent session never got a terminal
584
+ // branch entry (shutdown, session switch, or a process restart).
789
585
  manager.restoreRecovered(ctx.cwd);
790
- const branchEntries = ctx.sessionManager?.getBranch?.() ?? [];
791
- const restoredRecords = branchEntries
792
- .filter((entry: any) => entry?.customType === "subagents:record" && entry?.data && typeof entry.data.id === "string")
793
- .map((entry: any) => entry.data as Partial<AgentRecord>);
794
- manager.restoreCompleted(restoredRecords);
795
- historyAgentSelection.id = undefined;
796
- historyAgentSelection.index = 0;
797
- runningAgentSelection.id = undefined;
798
- runningAgentSelection.index = 0;
799
- if (ctx.hasUI && (ctx.mode === undefined || ctx.mode === "tui")) {
800
- widget.setUICtx(ctx.ui);
586
+ // Attach the panel during TUI startup, after restored records are present,
587
+ // so terminal agents from the session branch are immediately visible.
588
+ if (ctx.mode === "tui") {
589
+ widget.setUICtx(ctx.ui as UICtx);
801
590
  widget.update();
802
591
  }
803
- manager.clearCompleted(true);
804
592
  // Guard mirrors the `!scheduler.isActive()` pattern below: session_start
805
593
  // fires once per activation, but a double-bind must not leak listeners.
806
594
  if (!rpcHandle) {
@@ -808,26 +596,7 @@ export default function (pi: ExtensionAPI) {
808
596
  events: pi.events,
809
597
  pi,
810
598
  getCtx: () => currentCtx,
811
- manager: {
812
- spawn: spawnTopLevel,
813
- awaitStartup: (id) => manager.awaitStartup(id),
814
- getRecord: (id) => manager.getRecord(id),
815
- // Unguarded on purpose: the stop handler now runs the top-level check
816
- // itself off `getRecord`, and reports the refusal instead of the
817
- // "Agent not found" a false from here used to be read as.
818
- abort: (id) => manager.abort(id),
819
- consumeResult: (id) => {
820
- const record = resolveAgentRef(id);
821
- // Same guard as get_subagent_result: a running agent has no result
822
- // to consume, and its notification is still the caller's only
823
- // signal that it finished.
824
- if (!record || record.parentAgentId) return false;
825
- if (record.status === "running" || record.status === "queued") return false;
826
- record.resultConsumed = true;
827
- cancelNudge(record.id);
828
- return true;
829
- },
830
- },
599
+ manager,
831
600
  });
832
601
  // Broadcast readiness so extensions loaded alongside us can discover us.
833
602
  // Emitting after all factories have run (rather than at factory time)
@@ -835,267 +604,13 @@ export default function (pi: ExtensionAPI) {
835
604
  pi.events.emit("subagents:ready", {});
836
605
  }
837
606
  if (isSchedulingEnabled() && !scheduler.isActive()) startScheduler(ctx);
838
- // Stack `@handle` suggestions on pi's built-in autocomplete. Registered at
839
- // most once per activation: pi appends wrappers to a list it never prunes,
840
- // so a second call would layer a duplicate provider on the first. TUI only
841
- // — print mode has no such method, and RPC mode's is a no-op.
842
- if (ctx.mode === "tui" && !mentionProviderRegistered && typeof ctx.ui.addAutocompleteProvider === "function") {
843
- mentionProviderRegistered = true;
844
- ctx.ui.addAutocompleteProvider(current =>
845
- createMentionProvider(
846
- current,
847
- // Plain text, not renderAgentName: the same label the widget shows,
848
- // but the autocomplete description cannot carry ANSI.
849
- () => mentionRoster(manager, mentionTypes(), type => getConfig(type).displayName),
850
- isAgentMentionsEnabled,
851
- ),
852
- );
853
- }
854
- // Last, and only here: CLI flag values are applied by the host AFTER every
855
- // extension factory has run, so this is the earliest point the real value
856
- // exists. Detached inside — a workflow must not hold up session startup.
857
- resolveWorkflowCollisions(ctx);
858
- runWorkflowFlag(ctx);
859
- });
860
-
861
- /** Agent types `@` can start, in the shape the roster wants. */
862
- const mentionTypes = (): TypeInfo[] =>
863
- getAvailableTypes().map(name => ({ name, description: getAgentConfig(name)?.description ?? name }));
864
-
865
- /**
866
- * `@handle message` typed at the prompt addresses that agent instead of the
867
- * main model — Claude Code's prompt mention, same grammar (see mention.ts).
868
- *
869
- * The handle names the *agent*, not one process, so one syntax covers its
870
- * whole lifecycle: message it while it runs, resume it once it has finished,
871
- * start it if it never ran. Everything that isn't an agent mention falls
872
- * through untouched, which is what keeps `@src/foo.ts summarize this`, a bare
873
- * `@handle`, and ordinary prose working. A delivered mention costs no
874
- * main-model turn; the answer arrives through the ordinary completion
875
- * notification either way.
876
- */
877
- pi.on("input", async (event, ctx) => {
878
- // Never hijack text the extension layer itself submitted (pi.sendMessage,
879
- // scheduled prompts) — only something a person typed can be a mention.
880
- if (event.source === "extension" || !isAgentMentionsEnabled()) return { action: "continue" };
881
- // Claiming the turn is TUI only, matching the `@` completion that teaches
882
- // the syntax. Pi defaults `session.prompt()` to source "interactive", so a
883
- // headless `pi -p "@explore …"` reaches here too — and claiming it would
884
- // answer with silence, which the background hold cannot fix: `handled`
885
- // returns from prompt() before any turn starts, so the loop that patch wraps
886
- // never runs (it holds subagents spawned by the Agent tool MID-turn, a
887
- // different path). The agent would detach, `ctx.ui.notify` is a no-op
888
- // outside the TUI, and print mode would exit having printed nothing.
889
- //
890
- // `model` mode has none of that problem: it queues a reminder and lets the
891
- // turn run, so the answer is the model's own, printed as usual. It is the
892
- // only branch allowed to act headlessly; everything else falls through to
893
- // the main model exactly as it did before mentions existed.
894
- const canDispatchDirectly = ctx.mode === "tui";
895
- if (!canDispatchDirectly && getAgentMentionMode() !== "model") return { action: "continue" };
896
-
897
- const mention = parseMention(event.text);
898
- if (!mention) return { action: "continue" };
899
-
900
- // `@main` addresses the main conversation, never a subagent — the one name
901
- // `assignHandle` refuses to allocate. An explicit escape hatch for text
902
- // that would otherwise read as a mention, so the prefix is dropped and the
903
- // rest goes to the model with its attachments intact.
904
- if (isReservedHandle(mention.handle)) {
905
- return { action: "transform", text: mention.message, ...(event.images && { images: event.images }) };
906
- }
907
-
908
- // As typed first, so an agent actually called `agent-foo` wins over Claude
909
- // Code's `@agent-` + `foo` spelling rather than being shadowed by it.
910
- const alias = stripAgentPrefix(mention.handle);
911
- const resolved = manager.resolveMention(mention.handle)
912
- ?? (alias ? manager.resolveMention(alias) : undefined);
913
-
914
- // Steering and resuming are direct in every mode, so headless they are not
915
- // available at all. Falling through here rather than dropping to the start
916
- // path below matters: the handle names an agent that already exists, and
917
- // asking the model to start another one is not what was typed.
918
- if (resolved && !canDispatchDirectly) return { action: "continue" };
919
-
920
- if (resolved?.kind === "live") {
921
- const record = resolved.record;
922
- const target = `@${record.alias ?? record.handle ?? mention.handle}`;
923
-
924
- if (record.status === "running" || record.status === "queued") {
925
- // Steering interrupts after the current tool call, exactly like the
926
- // steer_subagent tool. Un-consume the result so the agent's reply to
927
- // this message is still relayed even if the LLM read its last answer.
928
- record.resultConsumed = false;
929
- manager.steer(record.id, mention.message);
930
- pi.events.emit("subagents:steered", { id: record.id, message: mention.message });
931
- ctx.ui.notify(`Sent to ${target}`, "info");
932
- return { action: "handled" };
933
- }
934
-
935
- if (record.session) {
936
- // Both derived from the record's OWN type: a mention names an existing
937
- // agent, so its frontmatter is what governs — `output_transcript: false`
938
- // must keep holding, since record.outputFile is the sole gate every
939
- // downstream consumer keys off and a resume must not re-open it.
940
- const config = getAgentConfig(record.type);
941
- const resumedRecord = await startBackgroundResume(ctx, record, mention.message, {
942
- outputTranscript: config?.outputTranscript ?? getOutputTranscriptDefault(),
943
- maxTurns: normalizeMaxTurns(config?.maxTurns ?? getDefaultMaxTurns()),
944
- });
945
- ctx.ui.notify(
946
- resumedRecord ? `Resuming ${target}` : `Could not resume ${target} — it is still running.`,
947
- resumedRecord ? "info" : "warning",
948
- );
949
- return { action: "handled" };
950
- }
951
- // A live record with no session never got far enough to continue, so it
952
- // falls through to the start-fresh path below, like Claude's
953
- // `no_transcript`.
954
- }
955
-
956
- // Evicted, but its conversation is still on disk: reopen it. This is an
957
- // ordinary spawn carrying a session file, so the new record picks up the
958
- // widget row, transcript and completion notification unchanged —
959
- // and `reclaim` hands it back the names the tombstone was holding.
960
- if (resolved?.kind === "tombstone") {
961
- const entry = resolved.entry;
962
- const target = `@${entry.alias ?? entry.handle}`;
963
-
964
- // Checked here rather than left to SessionManager.open: that runs inside
965
- // runAgent, whose rejection lands on the record as an agent error, not in
966
- // the catch below. A `/new` in another pi window or a manual delete makes
967
- // the conversation unrecoverable (Claude Code's `not_reachable`), so drop
968
- // the entry — a row that can only ever fail is worse than none — and say
969
- // so rather than quietly sending this message to an unrelated agent.
970
- if (!existsSync(entry.sessionFile)) {
971
- manager.dropTombstone(entry.handle);
972
- ctx.ui.notify(`Could not resume ${target} — its session is gone.`, "warning");
973
- return { action: "handled" };
974
- }
975
-
976
- // The Agent tool deliberately falls back to general-purpose for a type it
977
- // cannot resolve (#183), which covers a deleted file AND a merely
978
- // disabled one. A resume must not inherit that: reopening this
979
- // conversation under a different agent's prompt and tools is not
980
- // continuing it, and the new record would re-tombstone under the
981
- // substitute, so the handle would never find its way back.
982
- reloadCustomAgents();
983
- const dispatch = resolveSpawnType(entry.type);
984
- if (!dispatch.ok || dispatch.fellBackFrom !== undefined) {
985
- // The tombstone stays: re-enabling the agent makes the handle work
986
- // again, which a drop would foreclose.
987
- ctx.ui.notify(`Could not resume ${target} — the ${entry.type} agent is no longer available.`, "warning");
988
- return { action: "handled" };
989
- }
990
-
991
- try {
992
- // spawnResolved, not spawnTopLevel: the latter strips
993
- // `resumeSessionFile` and `reclaim` as untrusted. This path is the
994
- // exception — both come from a tombstone this extension wrote.
995
- const id = spawnResolved(pi, ctx, dispatch.type, mention.message, {
996
- description: entry.description,
997
- reclaim: { handle: entry.handle, alias: entry.alias },
998
- resumeSessionFile: entry.sessionFile,
999
- isBackground: true,
1000
- });
1001
- // The agent may still be starting — wait, so a startup failure lands in
1002
- // the catch below instead of being announced as a resume.
1003
- await manager.awaitStartup(id);
1004
- // The tombstone deliberately stays. `resolveMention` prefers the live
1005
- // record holding these same names, so it cannot shadow the resume — and
1006
- // if this run dies before establishing its own session, the original
1007
- // transcript is still the right thing for the next mention to reopen.
1008
- // Once the resumed record is evicted it overwrites this entry in place,
1009
- // keyed by the same handle, so nothing accumulates.
1010
- ctx.ui.notify(`Resuming ${target}`, "info");
1011
- } catch (err) {
1012
- // The type is already settled above, so what is left is a spawn-time
1013
- // failure: a strict worktree-isolation error, an unusable cwd.
1014
- ctx.ui.notify(
1015
- `Could not resume ${target}: ${err instanceof Error ? err.message : String(err)}`,
1016
- "warning",
1017
- );
1018
- }
1019
- return { action: "handled" };
1020
- }
1021
-
1022
- // No agent under that handle — but the name may still be an agent type, in
1023
- // which case the mention starts one.
1024
- const typeHandle = mention.handle;
1025
- const type = resolveHandleToType(typeHandle, getAvailableTypes())
1026
- ?? (alias ? resolveHandleToType(alias, getAvailableTypes()) : undefined);
1027
- if (!type) return { action: "continue" };
1028
-
1029
- // Claude Code never starts the agent itself: `@agent-<type>` becomes an
1030
- // attachment asking the main model to do it, and the model writes the
1031
- // agent's prompt from the conversation rather than forwarding the typed
1032
- // text. That buys a real `Agent` tool call — transcript, per-tool widget
1033
- // detail, tool-use-id correlation, join grouping — and a prompt with the
1034
- // context a cold spawn lacks.
1035
- //
1036
- // It also costs a visible turn, spent narrating a decision the user already
1037
- // made by typing the handle. So the turn is taken by a clone of this
1038
- // conversation instead (mention-clone.ts): same messages, same system
1039
- // prompt, off-screen, holding only the `Agent` tool. Nothing reaches the
1040
- // chat, and what it starts is an ordinary top-level agent.
1041
- if (getAgentMentionMode() === "model") {
1042
- const label = `@${handleBase(type)}`;
1043
- // "Prompting", not "Starting": in this mode nothing starts until the
1044
- // off-screen clone has taken a whole model turn writing the agent's
1045
- // prompt, and that wait is the one thing the chat cannot show. `direct`
1046
- // says "Started" because by then it has. The distinction tells the user
1047
- // which of the two they are waiting on.
1048
- ctx.ui.notify(`Prompting ${label}…`, "info");
1049
- // Not awaited: the clone runs a full model turn, and prompt() is blocked
1050
- // until this hook returns. The user gets their prompt back immediately
1051
- // and the agent appears in the widget when it starts.
1052
- void runMentionClone({ ctx, type, message: mention.message, agentTool: registeredAgentTool })
1053
- .then(async (result) => {
1054
- if (result.spawned) return;
1055
- // A clone that could not run must not swallow the mention: start the
1056
- // agent the direct way rather than leaving the user with a toast and
1057
- // nothing running.
1058
- try {
1059
- const id = spawnTopLevel(pi, ctx, type, mention.message, {
1060
- description: describeMention(mention.message),
1061
- isBackground: true,
1062
- });
1063
- // Same reason as the direct path below: the agent may still be
1064
- // starting, and a failure there must reach this catch.
1065
- await manager.awaitStartup(id);
1066
- ctx.ui.notify(`Started ${label} directly — ${result.error}`, "warning");
1067
- } catch (err) {
1068
- ctx.ui.notify(
1069
- `Could not start ${label}: ${err instanceof Error ? err.message : String(err)}`,
1070
- "error",
1071
- );
1072
- }
1073
- });
1074
- return { action: "handled" };
1075
- }
1076
-
1077
- try {
1078
- // Nothing else to pass: runAgent resolves model, thinking and max turns
1079
- // from the agent's own config when the spawn omits them, and the
1080
- // manager's onStart/onComplete callbacks own the widget and completion
1081
- // notification — the same contract the scheduler and
1082
- // cross-extension RPC spawns run under.
1083
- const id = spawnTopLevel(pi, ctx, type, mention.message, {
1084
- description: describeMention(mention.message),
1085
- isBackground: true,
1086
- });
1087
- // The agent may still be starting (a worktree copy is an awaited git
1088
- // call) — report a failure that lands there as a failed start, not as a
1089
- // "Started" toast for an agent that never ran.
1090
- await manager.awaitStartup(id);
1091
- ctx.ui.notify(`Started @${handleBase(type)}`, "info");
1092
- } catch (err) {
1093
- ctx.ui.notify(`Could not start @${handleBase(type)}: ${err instanceof Error ? err.message : String(err)}`, "error");
1094
- }
1095
- return { action: "handled" };
1096
607
  });
1097
608
 
1098
609
  pi.on("session_before_switch", () => {
610
+ resetAgentMenuSelections();
611
+ // A switch is catchable. Stop and checkpoint live/queued agents before the
612
+ // old session context is discarded, then retain their unread history.
613
+ manager.abortAll();
1099
614
  manager.clearCompleted(true);
1100
615
  scheduler.stop();
1101
616
  });
@@ -1103,10 +618,10 @@ export default function (pi: ExtensionAPI) {
1103
618
  // On shutdown, abort all agents immediately and clean up.
1104
619
  // If the session is going down, there's nothing left to consume agent results.
1105
620
  pi.on("session_shutdown", async () => {
621
+ resetAgentMenuSelections();
1106
622
  rpcHandle?.unsubSpawn();
1107
623
  rpcHandle?.unsubStop();
1108
624
  rpcHandle?.unsubPing();
1109
- rpcHandle?.unsubConsume();
1110
625
  rpcHandle = undefined;
1111
626
  currentCtx = undefined;
1112
627
  // Only release the global slot if this activation claimed it — a child
@@ -1115,66 +630,45 @@ export default function (pi: ExtensionAPI) {
1115
630
  delete (globalThis as any)[MANAGER_KEY];
1116
631
  }
1117
632
  scheduler.stop();
1118
- // Before abortAll, and not folded into it: a workflow owns a worker thread
1119
- // as well as its children, and only its own signal terminates that.
1120
- for (const task of workflowTasks.values()) task.abortController.abort();
1121
- workflowTasks.clear();
1122
633
  manager.abortAll();
1123
634
  for (const timer of pendingNudges.values()) clearTimeout(timer);
1124
635
  pendingNudges.clear();
1125
636
  widget.dispose();
1126
- // Awaited: it emits `session_shutdown` into every retained child session so
1127
- // extensions bound there can release what they armed in `session_start` (#242).
1128
- // pi awaits this handler, and the process exits right after — unawaited, those
1129
- // handlers would never run. Internally bounded, so a hung one can't strand quit.
1130
- await manager.dispose(pi);
637
+ manager.dispose();
1131
638
  });
1132
639
 
1133
- // Live widget: show running agents above editor.
1134
- // widgetMode (default "background") selects what the widget shows: "all" =
1135
- // every agent; "background" = hide foreground (they already render inline as
1136
- // the Agent tool result, so showing them here too is a duplicate, #118), keep
1137
- // everything else; "off" = hide the widget entirely. Read live at render time.
1138
- let widgetMode: WidgetMode = "background";
640
+ // Live widget: show all agents above the editor. Read live at render time.
641
+ let widgetMode: WidgetMode = "all";
1139
642
  function getWidgetMode(): WidgetMode { return widgetMode; }
1140
- const widget = new AgentWidget(manager, agentActivity, getWidgetMode, {
1141
- canOpenHistory: (record) => canOpenAgentHistory(record, currentCtx?.cwd),
1142
- onOpen: (record) => {
1143
- if (currentCtx) void viewAgentConversation(currentCtx as unknown as ExtensionCommandContext, record);
643
+ const widget = new AgentWidget(
644
+ manager,
645
+ agentActivity,
646
+ getWidgetMode,
647
+ {
648
+ canOpenHistory: (record) => canOpenAgentHistory(record, currentCtx?.cwd),
649
+ onOpen: (record, mode) => {
650
+ const ctx = currentCtx;
651
+ if (ctx) void viewAgentConversation(ctx as ExtensionCommandContext, record, mode);
652
+ },
1144
653
  },
1145
- showCost: isShowCostEnabled,
1146
- }, isShowModelEnabled);
1147
- function setWidgetMode(m: WidgetMode): void { widgetMode = m; widget.update(); }
1148
-
1149
- // Claude Code-style `@handle message` prompt mentions. Read live by both the
1150
- // `input` hook and the stacked autocomplete provider, so the toggle applies
1151
- // immediately — the provider itself can never be unregistered (pi's wrapper
1152
- // list is append-only), it just delegates everything when this is off.
1153
- let agentMentionMode: AgentMentionMode = "model";
1154
- function getAgentMentionMode(): AgentMentionMode { return agentMentionMode; }
1155
- function setAgentMentionMode(mode: AgentMentionMode): void { agentMentionMode = mode; }
1156
- // `model` and `direct` differ only in who starts a not-yet-running agent, so
1157
- // everything that just asks "are mentions live at all" — the suggestion list,
1158
- // the steer and resume branches — reads this instead of the mode.
1159
- function isAgentMentionsEnabled(): boolean { return agentMentionMode !== "off"; }
1160
-
1161
- // Project/global default for writing the subagent .output transcript lives in
1162
- // output-file.ts (both spawn paths read it). A custom agent's
1163
- // `output_transcript` frontmatter overrides it per spawn; when the frontmatter
1164
- // is silent, this default applies. Read live at spawn time.
654
+ );
655
+ function setWidgetMode(m: WidgetMode): void {
656
+ widgetMode = m;
657
+ widget.update();
658
+ }
659
+
660
+ // Project/global default for writing the subagent .output transcript. A custom
661
+ // agent's `output_transcript` frontmatter overrides this per spawn; when the
662
+ // frontmatter is silent, this default applies. Read live at spawn time.
663
+ let outputTranscriptDefault = true;
664
+ function getOutputTranscriptDefault(): boolean { return outputTranscriptDefault; }
665
+ function setOutputTranscript(b: boolean): void { outputTranscriptDefault = b; }
1165
666
 
1166
667
  // ---- Join mode configuration ----
1167
668
  let defaultJoinMode: JoinMode = 'smart';
1168
669
  function getDefaultJoinMode(): JoinMode { return defaultJoinMode; }
1169
670
  function setDefaultJoinMode(mode: JoinMode) { defaultJoinMode = mode; }
1170
671
 
1171
- // What an unqualified top-level spawn means. Defaults to background,
1172
- // following Claude Code; `backgroundByDefault: false` restores the previous
1173
- // foreground default. Nested spawns ignore this — see nested-tools.ts.
1174
- let backgroundByDefault = true;
1175
- function getBackgroundByDefault(): boolean { return backgroundByDefault; }
1176
- function setBackgroundByDefault(b: boolean) { backgroundByDefault = b; }
1177
-
1178
672
  // Master switch for the schedule subagent feature. Defaults to enabled.
1179
673
  // Read once at extension init (before tool registration) so the Agent tool's
1180
674
  // param schema reflects the persisted setting. Runtime toggles via /agents
@@ -1185,25 +679,16 @@ export default function (pi: ExtensionAPI) {
1185
679
  function isSchedulingEnabled(): boolean { return schedulingEnabled; }
1186
680
  function setSchedulingEnabled(b: boolean) { schedulingEnabled = b; }
1187
681
 
1188
- // Master switch for scripted workflows. Defaults to ON. Off means the
1189
- // `SubagentWorkflow` tool is never registered: the model is not told the
1190
- // feature exists (zero context cost) and has nothing to call. The
1191
- // `/agents → Workflows` view and `--subagents-workflow-file` are refused too, so
1192
- // there is no second door into the same machinery.
1193
- //
1194
- // `workflowsPinned` records that the answer came from the user — a boolean in
1195
- // subagents.json, or the settings toggle — rather than from this default. It
1196
- // is what `resolveWorkflowCollisions` checks before yielding to another
1197
- // extension's workflow tool: a default may be overridden by what else is
1198
- // loaded, an explicit choice may not.
1199
- let workflowsEnabled = true;
1200
- let workflowsPinned = false;
1201
- function isWorkflowsEnabled(): boolean { return workflowsEnabled; }
1202
- function isWorkflowsPinned(): boolean { return workflowsPinned; }
1203
- function setWorkflowsEnabled(b: boolean) {
1204
- workflowsEnabled = b;
1205
- workflowsPinned = true;
1206
- }
682
+ // ---- Scope models configuration ----
683
+ // When enabled, subagent model choices are validated against `enabledModels`
684
+ // from pi's settings — both global `<agentDir>/settings.json` and
685
+ // project-local `<cwd>/.pi/settings.json` (project overrides global).
686
+ // Off by default; opt-in via `/agents → Settings`. See docstring on
687
+ // SubagentsSettings.scopeModels for the hard-error vs warn-and-proceed
688
+ // policy and its rationale.
689
+ let scopeModelsEnabled = false;
690
+ function isScopeModelsEnabled(): boolean { return scopeModelsEnabled; }
691
+ function setScopeModelsEnabled(enabled: boolean): void { scopeModelsEnabled = enabled; }
1207
692
 
1208
693
  // ---- Disable default agents configuration ----
1209
694
  // When enabled, the three hardcoded default agents (general-purpose, Explore,
@@ -1268,104 +753,22 @@ export default function (pi: ExtensionAPI) {
1268
753
  }
1269
754
  }
1270
755
 
1271
- /**
1272
- * Launch a detached resume of an existing agent and wire everything a
1273
- * re-running agent needs: transcript anchoring, activity tracking, join-mode
1274
- * batching, the widget refresh, and the `subagents:created` event.
1275
- *
1276
- * Shared by the Agent tool's `resume` + `run_in_background` branch and the
1277
- * `@handle message` prompt mention — they differ only in how they report the
1278
- * outcome. Returns the record, or undefined when the manager refused because
1279
- * the agent is still running (see AgentManager.resume).
1280
- *
1281
- * Callers must have already established that the record has a session.
1282
- */
1283
- async function startBackgroundResume(
1284
- ctx: ExtensionContext,
1285
- existing: AgentRecord,
1286
- prompt: string,
1287
- opts: { outputTranscript: boolean; maxTurns?: number; toolCallId?: string },
1288
- ): Promise<AgentRecord | undefined> {
1289
- const id = existing.id;
1290
- const joinMode = resolveJoinMode(defaultJoinMode, true);
1291
- // Assigned unconditionally: the completion notification carries this as
1292
- // `<tool-use-id>`, so a mention-resume (which passes none) has to CLEAR the
1293
- // id left by the spawn that created the record. Keeping it would point the
1294
- // orchestrator's new result at a tool call that was answered runs ago.
1295
- existing.toolCallId = opts.toolCallId;
1296
- if (joinMode) existing.joinMode = joinMode;
1297
- // Reuse the agent's transcript rather than starting a fresh one: the
1298
- // path is deterministic per agent+session, so writing an initial entry
1299
- // would truncate the previous run's turns (see ensureOutputFile).
1300
- if (opts.outputTranscript) {
1301
- existing.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
1302
- ensureOutputFile(existing.outputFile);
1303
- }
1304
- // Anchor streaming past the turns already on disk, captured BEFORE the
1305
- // run starts. The resumed prompt lands as an ordinary user message at
1306
- // this index, so it is written exactly once.
1307
- const transcriptAnchor = existing.session?.messages.length ?? 0;
1308
-
1309
- const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(opts.maxTurns);
1310
- // resumeAgent has no onSessionCreated — the session predates this run —
1311
- // so seed it directly, or the widget shows no context % for the agent.
1312
- bgState.session = existing.session;
1313
-
1314
- // No `signal`: a background spawn deliberately omits it, and a detached
1315
- // resume must behave the same. Passing it would abort this agent when
1316
- // the parent turn is interrupted (user Esc), while agents started with
1317
- // run_in_background in that same turn keep going.
1318
- const record = await manager.resume(id, prompt, undefined, {
1319
- isBackground: true,
1320
- onToolActivity: bgCallbacks.onToolActivity,
1321
- onAssistantUsage: bgCallbacks.onAssistantUsage,
1322
- // Fires when the run actually starts — immediately, or on queue
1323
- // drain. Wiring it here (rather than after resume() returns) means a
1324
- // resume stopped while still queued never started streaming, so
1325
- // there is no subscription left behind for a later run to trip over.
1326
- onStarted: () => {
1327
- const rec = manager.getRecord(id);
1328
- if (rec?.session && rec.outputFile) {
1329
- rec.outputCleanup = streamToOutputFile(rec.session, rec.outputFile, id, ctx.cwd, transcriptAnchor);
1330
- }
1331
- },
1332
- });
1333
- if (!record) return undefined;
1334
-
1335
- if (joinMode != null && joinMode !== 'async') {
1336
- currentBatchAgents.push({ id, joinMode });
1337
- if (batchFinalizeTimer) clearTimeout(batchFinalizeTimer);
1338
- batchFinalizeTimer = setTimeout(finalizeBatch, 100);
1339
- }
1340
-
1341
- agentActivity.set(id, bgState);
1342
- // This agent already finished once, so the widget holds a finished-age
1343
- // for it that is past the linger limit — without clearing it, the
1344
- // resumed run's ✓/✗ line never renders and the agent just vanishes.
1345
- widget.markRunning(id);
1346
- widget.ensureTimer();
1347
- widget.update();
1348
-
1349
- // Resume ignores subagent_type (the record keeps the type it was
1350
- // spawned with), so report the record's own identity — a "created"
1351
- // event carrying the caller's type would re-register the agent under
1352
- // the wrong one in cross-extension mirrors keyed by id.
1353
- pi.events.emit("subagents:created", {
1354
- id,
1355
- type: existing.type,
1356
- description: existing.description,
1357
- isBackground: true,
1358
- });
1359
-
1360
- return record;
1361
- }
1362
-
1363
756
  // Grab UI context from first tool execution + clear lingering widget on new turn
1364
757
  pi.on("tool_execution_start", async (_event, ctx) => {
1365
- if (ctx.hasUI && (ctx.mode === undefined || ctx.mode === "tui")) widget.setUICtx(ctx.ui as UICtx);
758
+ widget.setUICtx(ctx.ui as UICtx);
1366
759
  widget.onTurnStart();
1367
760
  });
1368
761
 
762
+ /** Format an agent's tool scope: "*" when it has all built-ins, else a comma-separated list. */
763
+ const formatToolsSuffix = (cfg: AgentConfig | undefined): string => {
764
+ const tools = cfg?.builtinToolNames;
765
+ if (!tools || tools.length === 0) return "*";
766
+ const isFullSet =
767
+ tools.length === BUILTIN_TOOL_NAMES.length
768
+ && BUILTIN_TOOL_NAMES.every((t) => tools.includes(t));
769
+ return isFullSet ? "*" : tools.join(", ");
770
+ };
771
+
1369
772
  /** Build the full type list text dynamically from available agents only. */
1370
773
  const buildTypeListText = () => {
1371
774
  const available = getAvailableTypes();
@@ -1405,28 +808,15 @@ export default function (pi: ExtensionAPI) {
1405
808
  applyAndEmitLoaded(
1406
809
  {
1407
810
  setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
1408
- setMaxConcurrentForeground: (n) => manager.setMaxConcurrentForeground(n),
1409
811
  setDefaultMaxTurns,
1410
812
  setGraceTurns,
1411
813
  setDefaultJoinMode,
1412
- setBackgroundByDefault,
1413
814
  setSchedulingEnabled,
1414
815
  setScopeModels: setScopeModelsEnabled,
1415
- setStrictAgentFiles: (b) => { strictAgentFiles = b; },
1416
816
  setDisableDefaultAgents: setDisableDefaultAgents,
1417
817
  setToolDescriptionMode: setToolDescriptionMode,
1418
- setAgentMentions: setAgentMentionMode,
1419
- setRememberAgents,
1420
818
  setWidgetMode: setWidgetMode,
1421
- setOutputTranscript: setOutputTranscriptDefault,
1422
- setWorktreeIsolation: setWorktreeIsolationEnabled,
1423
- setWorkflowsEnabled: setWorkflowsEnabled,
1424
- setMaxSubagentDepth: setMaxSubagentDepth,
1425
- setFallbackSubagent: setFallbackSubagent,
1426
- setReportUsage,
1427
- setShowCost,
1428
- setShowModel,
1429
- setViewerMarkdown,
819
+ setOutputTranscript: setOutputTranscript,
1430
820
  },
1431
821
  (event, payload) => pi.events.emit(event, payload),
1432
822
  );
@@ -1455,21 +845,6 @@ export default function (pi: ExtensionAPI) {
1455
845
  ? `\n- Use \`schedule\` only when the user explicitly asked for scheduled / recurring / delayed execution (e.g. "every Monday", "in an hour"). Don't auto-schedule from vague intent like "monitor X" — run once now or ask.`
1456
846
  : "";
1457
847
 
1458
- // Same trade as scheduleParam/scheduleGuideline above: `isolationParam` drops
1459
- // the field from the schema when the project set `worktreeIsolation: false`,
1460
- // so the prose has to go with it. Left in, it would teach the model to pass a
1461
- // parameter that isn't declared — accepted (TypeBox sets no
1462
- // `additionalProperties: false`) and then silently dropped by the resolver.
1463
- // With no per-result note by design, the model would have every reason to go
1464
- // on reporting a `pi-agent-*` branch that was never created.
1465
- const isolationGuideline = isWorktreeIsolationEnabled()
1466
- ? `\n- Use isolation: "worktree" to give the agent its own git worktree (safe parallel file modifications); leave it unset, or pass "off", for none. The worktree is removed when the agent finishes; if it made changes, they are committed to a branch and the branch is named in the result.`
1467
- : "";
1468
-
1469
- const isolationCompactGuideline = isWorktreeIsolationEnabled()
1470
- ? `\n- isolation: "worktree" gives the agent its own git worktree (removed on completion); changes land on a branch named in the result.`
1471
- : "";
1472
-
1473
848
  // Compact Agent tool description (#91, `toolDescriptionMode: "compact"`) —
1474
849
  // the same load-bearing facts as the full version at ~75% fewer tokens, for
1475
850
  // small/local models. Per-option details live in the param descriptions.
@@ -1480,10 +855,10 @@ Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.
1480
855
 
1481
856
  Notes:
1482
857
  - description: 3-5 words (shown in UI). Prompts must be self-contained — the agent has not seen this conversation.
1483
- - Parallel work: one message, multiple Agent calls — they run concurrently.
1484
- - Subagents run in the background by default; you'll be notified when one completes. Pass run_in_background: false only when your very next action depends on the result and nothing else could usefully happen while it runs. Never fabricate or predict a pending agent's results — if the user asks before the notification arrives, say it's still running.
858
+ - Parallel work: one message, multiple Agent calls, run_in_background: true on each. You are notified when background agents finish — never poll or sleep.
1485
859
  - The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
1486
- - resume continues a previous agent by ID; steer_subagent messages a running one.${isolationCompactGuideline}`;
860
+ - resume continues a previous agent by ID; steer_subagent messages a running one.
861
+ - isolation: "worktree" runs the agent in an isolated git worktree; changes land on a branch.`;
1487
862
 
1488
863
  const fullAgentToolDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
1489
864
 
@@ -1501,23 +876,23 @@ If the target is already known, use a direct tool — \`read\` for a known path,
1501
876
  ## Usage notes
1502
877
 
1503
878
  - Always include a short (3-5 word) description summarizing what the agent will do (shown in UI).
1504
- - When you launch multiple agents for independent work, send them in a single message with multiple tool uses so they run concurrently. If the user specifies that they want you to run agents "in parallel", you MUST send a single message with multiple Agent tool use content blocks.
879
+ - When you launch multiple agents for independent work, send them in a single message with multiple tool uses, with run_in_background: true on each, so they run concurrently. If the user specifies that they want agents run "in parallel", you MUST send a single message with multiple tool calls. Foreground calls run sequentially — only one executes at a time.
1505
880
  - When the agent is done, it returns a single message back to you. The result is not visible to the user — to show the user, send a text message with a concise summary.
1506
- - Trust but verify: an agent's summary describes what it intended to do, not necessarily what it did. When an agent writes or edits code, check the actual changes before reporting the work as done.
1507
- - Agents run in the background by default. When an agent runs in the background, you will be automatically notified when it completes — do NOT sleep, poll, or proactively check on its progress. Continue with other work or respond to the user instead.
1508
- - **Foreground vs background**: Pass \`run_in_background: false\` only when your very next action depends on the agent's result and nothing else could usefully happen while it runs — e.g., a research agent whose finding gates the edit you're about to make. Otherwise let it run in the background (the default) — this includes fire-and-forget work, independent investigations, and anything where the user might hand you something else in the meantime. Wanting the result "next" is not enough on its own.
1509
- - **Don't race**: after launching a background agent, you know nothing about its results. Never fabricate or predict them in any format — not as prose, summary, or structured output. The completion notification arrives in a later turn; it is never something you write yourself. If the user asks before it lands, say the agent is still running — give status, not a guess.
881
+ - Trust but verify: an agent's summary describes what it intended to do, not necessarily what it did. When an agent writes or edits code, check the actual changes before reporting work as done.
882
+ - Use run_in_background for work you don't need immediately. You will be notified when it completes — do NOT poll or sleep waiting for it. Continue with other work or respond to the user instead.
883
+ - Foreground vs background: use foreground (default) when you need the agent's results before you can proceed. Use background when you have genuinely independent work to do in parallel.
1510
884
  - Use resume with an agent ID to continue a previous agent's work. A new (non-resume) Agent call starts a fresh agent with no memory of prior runs, so the prompt must be self-contained.
1511
885
  - Use steer_subagent to send mid-run messages to a running background agent.
1512
886
  - Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, etc.), since it is not aware of the user's intent.
1513
887
  - If an agent's description says it should be used proactively, try to use it without the user having to ask for it first.
1514
888
  - Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
1515
889
  - Use thinking to control extended thinking level.
1516
- - Use inherit_context if the agent needs the parent conversation history.${isolationGuideline}${scheduleGuideline}
890
+ - Use inherit_context if the agent needs the parent conversation history.
891
+ - Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications). The worktree is automatically cleaned up if the agent makes no changes; otherwise the path and branch are returned in the result.${scheduleGuideline}
1517
892
 
1518
893
  ## Writing the prompt
1519
894
 
1520
- Brief the agent like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters.
895
+ Provide clear, detailed prompts so the agent can work autonomously. Brief it like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters.
1521
896
  - Explain what you're trying to accomplish and why.
1522
897
  - Describe what you've already learned or ruled out.
1523
898
  - Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction.
@@ -1537,7 +912,6 @@ Terse command-style prompts produce shallow, generic work.
1537
912
  typeList: buildTypeListText,
1538
913
  compactTypeList: buildCompactTypeListText,
1539
914
  agentDir: getAgentDir,
1540
- isolationGuideline: () => isolationGuideline,
1541
915
  scheduleGuideline: () => scheduleGuideline,
1542
916
  };
1543
917
  // Replacement callback (not a string) — agent descriptions may contain `$&` etc.
@@ -1576,10 +950,7 @@ Terse command-style prompts produce shallow, generic work.
1576
950
  return fullAgentToolDescription;
1577
951
  })();
1578
952
 
1579
- // Held rather than registered inline: the mention clone reuses this exact
1580
- // definition, so the agent it starts is an ordinary top-level spawn instead
1581
- // of a second implementation that has to be kept in step with this one.
1582
- const agentTool = defineTool({
953
+ pi.registerTool(defineTool({
1583
954
  name: SUBAGENT_TOOL_NAMES.AGENT,
1584
955
  label: "Agent",
1585
956
  description: agentToolDescription,
@@ -1597,12 +968,6 @@ Terse command-style prompts produce shallow, generic work.
1597
968
  description: Type.String({
1598
969
  description: "A short (3-5 word) description of the task (shown in UI).",
1599
970
  }),
1600
- name: Type.Optional(
1601
- Type.String({
1602
- description:
1603
- 'Optional memorable name for this agent, e.g. "auth-audit", so it can be addressed as `@name` at the prompt and by steer_subagent / get_subagent_result. Letters, digits, `_` and `-`. Worth setting when several agents of the same type run at once; omit for one-off work. The agent stays reachable by its type either way.',
1604
- }),
1605
- ),
1606
971
  subagent_type: Type.String({
1607
972
  description: `The type of specialized agent to use. Available types: ${getAvailableTypes().join(", ")}. Custom agents from .pi/agents/*.md (project) or ${getAgentDir()}/agents/*.md (global) are also available.`,
1608
973
  }),
@@ -1625,12 +990,12 @@ Terse command-style prompts produce shallow, generic work.
1625
990
  ),
1626
991
  run_in_background: Type.Optional(
1627
992
  Type.Boolean({
1628
- description: "Defaults to true — the agent runs detached, returning its ID immediately, and you are notified on completion. Set false only when your very next action depends on the result; the call then blocks and returns the agent's full output inline.",
993
+ description: "Set to true to run in background. Returns agent ID immediately. You will be notified on completion.",
1629
994
  }),
1630
995
  ),
1631
996
  resume: Type.Optional(
1632
997
  Type.String({
1633
- description: "Optional agent ID to resume from. Continues from previous context. Resumes detached like any other spawn; pass run_in_background: false to block and get the result inline. An agent can only be resumed once its current run has finished — use steer_subagent to reach one mid-run.",
998
+ description: "Optional agent ID to resume from. Continues from previous context.",
1634
999
  }),
1635
1000
  ),
1636
1001
  isolated: Type.Optional(
@@ -1643,40 +1008,26 @@ Terse command-style prompts produce shallow, generic work.
1643
1008
  description: "If true, fork parent conversation into the agent. Default: false (fresh context).",
1644
1009
  }),
1645
1010
  ),
1646
- ...isolationParam(isWorktreeIsolationEnabled()),
1011
+ isolation: Type.Optional(
1012
+ Type.Literal("worktree", {
1013
+ description: 'Set to "worktree" to run the agent in a temporary git worktree (isolated copy of the repo). Changes are saved to a branch on completion.',
1014
+ }),
1015
+ ),
1647
1016
  ...scheduleParam,
1648
1017
  }),
1649
1018
 
1650
1019
  // ---- Custom rendering: Claude Code style ----
1651
1020
 
1652
- renderCall(args, theme, context) {
1653
- // A badge closes its own background, which would clear the tool block's row tint
1654
- // for the rest of the line, so the badge restores it. The tint is opened here too:
1655
- // the TUI's Box paints it, but HTML export takes it from CSS, and restoring a
1656
- // background the line never opened is what banded the export before. The line is
1657
- // deliberately left open — Box.applyBackgroundToLine pads to width and *then*
1658
- // wraps, so closing here would leave that padding untinted, and HTML export closes
1659
- // any open span per line anyway. No badge means no tint, so an uncolored agent
1660
- // renders exactly the line it always did.
1661
- const rowBackground = hasAgentBadge(args.subagent_type)
1662
- ? theme.getBgAnsi(context.isPartial ? "toolPendingBg" : context.isError ? "toolErrorBg" : "toolSuccessBg")
1663
- : "";
1021
+ renderCall(args, theme) {
1022
+ const displayName = args.subagent_type ? getDisplayName(args.subagent_type) : "Agent";
1664
1023
  const desc = args.description ?? "";
1665
- const name = renderAgentName(args.subagent_type, theme, {
1666
- fallbackColor: "toolTitle",
1667
- restoreBackground: rowBackground,
1668
- bold: true,
1669
- });
1670
- return new Text(rowBackground + "▸ " + name + (desc ? " " + theme.fg("muted", desc) : ""), 0, 0);
1024
+ return new Text("▸ " + theme.fg("toolTitle", theme.bold(displayName)) + (desc ? " " + theme.fg("muted", desc) : ""), 0, 0);
1671
1025
  },
1672
1026
 
1673
- renderResult(result, { expanded, isPartial }, theme, renderContext) {
1027
+ renderResult(result, { expanded, isPartial }, theme) {
1674
1028
  const details = result.details as AgentDetails | undefined;
1675
- const text = result.content[0]?.type === "text" ? result.content[0].text : "";
1676
- // Pi reports pre-execution failures (extension block, abort, argument
1677
- // validation) as `{ content: [reason], details: {} }` with isError set —
1678
- // no status to render, so show the reason instead of inventing one (#199).
1679
- if (renderContext.isError || !details?.status) {
1029
+ if (!details) {
1030
+ const text = result.content[0]?.type === "text" ? result.content[0].text : "";
1680
1031
  return new Text(text, 0, 0);
1681
1032
  }
1682
1033
 
@@ -1690,10 +1041,6 @@ Terse command-style prompts produce shallow, generic work.
1690
1041
  }
1691
1042
  if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
1692
1043
  if (d.tokens) parts.push(d.tokens);
1693
- if (showCost) {
1694
- const costText = formatCost(d.cost ?? 0);
1695
- if (costText) parts.push(costText);
1696
- }
1697
1044
  return parts.map(p => fgPreservingNestedStyles(theme, "dim", p)).join(" " + theme.fg("dim", "·") + " ");
1698
1045
  };
1699
1046
 
@@ -1744,12 +1091,6 @@ Terse command-style prompts produce shallow, generic work.
1744
1091
  return new Text(line, 0, 0);
1745
1092
  }
1746
1093
 
1747
- // Anything left ("queued", or a status added later) has no rendering of
1748
- // its own — the turn-limit wording below must not be the catch-all.
1749
- if (details.status !== "error" && details.status !== "aborted") {
1750
- return new Text(text, 0, 0);
1751
- }
1752
-
1753
1094
  // ---- Error / Aborted (hard max_turns) ----
1754
1095
  const s = stats(details);
1755
1096
  let line = theme.fg("error", "✗") + (s ? " " + s : "");
@@ -1773,40 +1114,16 @@ Terse command-style prompts produce shallow, generic work.
1773
1114
  reloadCustomAgents();
1774
1115
 
1775
1116
  const rawType = params.subagent_type as SubagentType;
1776
- // Single decision point for dispatch (#183): unknown, disabled and
1777
- // case-ambiguous types are refused here, BEFORE anything spawns, so a
1778
- // background or scheduled call can't start running the wrong agent while
1779
- // the caller is still unaware. `fallbackSubagent` decides whether an
1780
- // unresolvable type falls back or fails closed.
1781
- const dispatch = resolveSpawnType(rawType);
1782
- // `resume` replays a stored session and ignores `subagent_type` entirely,
1783
- // but the parameter is required by the schema — so gating it here would
1784
- // make a live agent unresumable the moment its type is deleted, disabled,
1785
- // or gains a case-clashing sibling. Only a real spawn is gated.
1786
- if (!dispatch.ok && !params.resume) return textResult(dispatch.message);
1787
- const subagentType = dispatch.ok ? dispatch.type : rawType;
1788
- // What the caller actually asked for, named once: `fellBackFrom` is "" for
1789
- // a blank request, so reading it inline invites the `??`-vs-`||` slip that
1790
- // once persisted an empty type into a scheduled job.
1791
- const requestedType = (dispatch.ok && dispatch.fellBackFrom) || subagentType;
1792
- // Computed at resolution rather than after the run, so the background and
1793
- // schedule branches carry it too — previously it existed only on the
1794
- // foreground path. Resume deliberately doesn't: it replays the stored
1795
- // session and ignores `subagent_type` entirely, so a note about type
1796
- // substitution would be describing something that didn't happen.
1797
- const fallbackNote = dispatch.ok && dispatch.fellBackFrom !== undefined
1798
- ? `Note: Unknown agent type "${dispatch.fellBackFrom}" — using ${resolveType(subagentType) ? subagentType : "the fallback agent config"}.\n\n`
1799
- : "";
1117
+ const resolved = resolveType(rawType);
1118
+ const subagentType = resolved ?? "general-purpose";
1119
+ const fellBack = resolved === undefined;
1800
1120
 
1801
1121
  const displayName = getDisplayName(subagentType);
1802
1122
 
1803
1123
  // Get agent config (if any)
1804
1124
  const customConfig = getAgentConfig(subagentType);
1805
1125
 
1806
- const resolvedConfig = resolveAgentInvocationConfig(customConfig, params, {
1807
- worktreeAllowed: isWorktreeIsolationEnabled(),
1808
- defaultRunInBackground: getBackgroundByDefault(),
1809
- });
1126
+ const resolvedConfig = resolveAgentInvocationConfig(customConfig, params);
1810
1127
 
1811
1128
  // Resolve model from agent config first; tool-call params only fill gaps.
1812
1129
  let model = ctx.model;
@@ -1821,18 +1138,33 @@ Terse command-style prompts produce shallow, generic work.
1821
1138
  }
1822
1139
 
1823
1140
  // Scope validation: the effective resolved model is checked against the
1824
- // user's enabledModels list. Policy (hard error vs warn-and-proceed) lives
1825
- // in model-scope.ts so the nested delegation tools apply the same rule.
1826
- const scopeVerdict = checkModelScope({
1827
- model,
1828
- cwd: ctx.cwd,
1829
- modelRegistry: ctx.modelRegistry,
1830
- callerSupplied: resolvedConfig.modelFromParams,
1831
- agentLabel: customConfig?.displayName ?? subagentType,
1832
- modelInput: resolvedConfig.modelInput,
1833
- });
1834
- if (scopeVerdict.kind === "error") return textResult(scopeVerdict.message);
1835
- if (scopeVerdict.kind === "warn") ctx.ui.notify(scopeVerdict.message, "warning");
1141
+ // user's enabledModels list (read in `enabled-models.ts`).
1142
+ //
1143
+ // Design: scopeModels guards against *runtime* LLM choices, not user-level config.
1144
+ // - Caller-supplied out-of-scope → hard error (the orchestrator made an explicit
1145
+ // out-of-scope choice; surface it so it picks differently).
1146
+ // - Frontmatter-pinned or parent-inherited out-of-scope → warn but proceed (the
1147
+ // user authored/installed this agent or chose the parent's model; trust it).
1148
+ // See SubagentsSettings.scopeModels docstring for the full policy.
1149
+ if (isScopeModelsEnabled() && model) {
1150
+ const allowed = resolveEnabledModels(readEnabledModels(ctx.cwd), ctx.modelRegistry, ctx.cwd);
1151
+ if (allowed && !isModelInScope(model, allowed)) {
1152
+ if (resolvedConfig.modelFromParams) {
1153
+ const list = [...allowed].sort().map(m => ` ${m}`).join("\n");
1154
+ return textResult(
1155
+ `Model not in scope: "${resolvedConfig.modelInput}".\n\n` +
1156
+ `Allowed models (from enabledModels):\n${list}`,
1157
+ );
1158
+ }
1159
+ // Frontmatter-pinned or parent-inherited: warn + proceed.
1160
+ const agentLabel = customConfig?.displayName ?? subagentType;
1161
+ const modelLabel = resolvedConfig.modelInput ?? `${model.provider}/${model.id}`;
1162
+ ctx.ui.notify(
1163
+ `Agent "${agentLabel}" using out-of-scope model "${modelLabel}"`,
1164
+ "warning",
1165
+ );
1166
+ }
1167
+ }
1836
1168
 
1837
1169
  const thinking = resolvedConfig.thinking;
1838
1170
  const inheritContext = resolvedConfig.inheritContext;
@@ -1849,34 +1181,33 @@ Terse command-style prompts produce shallow, generic work.
1849
1181
  if (!rec || !outputTranscript) return;
1850
1182
  rec.outputFile = createOutputFilePath(ctx.cwd, agentId, ctx.sessionManager.getSessionId());
1851
1183
  writeInitialEntry(rec.outputFile, agentId, params.prompt, ctx.cwd);
1184
+
1185
+ try {
1186
+ rec.historyFile = createAgentHistoryPath(ctx.cwd, agentId);
1187
+ rec.transcriptPath = agentHistoryLocator(ctx.cwd, rec.historyFile);
1188
+ writeInitialEntry(rec.historyFile, agentId, params.prompt, ctx.cwd);
1189
+ manager.setTranscript(agentId, rec.historyFile, rec.transcriptPath, ctx.cwd);
1190
+ } catch (err) {
1191
+ rec.historyFile = undefined;
1192
+ rec.transcriptPath = undefined;
1193
+ ctx.ui.notify(
1194
+ `Could not create durable transcript for agent ${agentId}: ${err instanceof Error ? err.message : String(err)}`,
1195
+ "warning",
1196
+ );
1197
+ }
1852
1198
  };
1853
1199
 
1854
- // Unconditional, not "only when it differs from the parent": a thinking
1855
- // level reads as a property of a model, and an agent that inherited the
1856
- // parent's model used to show the level with nothing to attach it to.
1857
- // This is the pre-session snapshot — agent-manager overwrites it with the
1858
- // effective values the moment a session reports them.
1859
- const { modelName, modelId } = model ? describeModel(model) : { modelName: undefined, modelId: undefined };
1860
- // What the caller SPELLED, kept only if it names a different model than the
1861
- // one that won. Model input is fuzzy — `"haiku"` and
1862
- // `"anthropic/claude-haiku-4-5"` are the same model — so comparing the two
1863
- // strings would disclose an override that never happened. A spelling that
1864
- // resolves to nothing is still worth disclosing: it cannot have taken effect.
1865
- const askedModel = ((asked: string | undefined) => {
1866
- if (!asked) return undefined;
1867
- const resolvedAsked = resolveModel(asked, ctx.modelRegistry);
1868
- if (typeof resolvedAsked === "string") return asked;
1869
- return resolvedAsked.provider === model?.provider && resolvedAsked.id === model?.id ? undefined : asked;
1870
- })(resolvedConfig.overridden?.model);
1200
+ const parentModelId = ctx.model?.id;
1201
+ const effectiveModelId = model?.id;
1202
+ const modelName = effectiveModelId && effectiveModelId !== parentModelId
1203
+ ? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase()
1204
+ : undefined;
1871
1205
  const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
1872
1206
  const agentInvocation: AgentInvocation = {
1873
1207
  modelName,
1874
- modelId,
1208
+ effectiveModelName: model?.name ?? model?.id ?? ctx.model?.name ?? ctx.model?.id,
1875
1209
  thinking,
1876
- // Only set where the agent file outranked the caller, so the surfaces can
1877
- // disclose a parameter that was accepted but could not take effect (#182).
1878
- requestedThinking: resolvedConfig.overridden?.thinking,
1879
- requestedModel: askedModel,
1210
+ effectiveThinking: thinking,
1880
1211
  // Explicit value only — the default fallback would just add noise.
1881
1212
  // Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
1882
1213
  maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
@@ -1897,34 +1228,6 @@ Terse command-style prompts produce shallow, generic work.
1897
1228
  tags: agentTags.length > 0 ? agentTags : undefined,
1898
1229
  };
1899
1230
 
1900
- /**
1901
- * `detailBase` for a record that exists, which outranks it: the base is a
1902
- * snapshot of what this call REQUESTED, and pi may have resolved a
1903
- * different model or clamped the thinking level (agent-manager writes the
1904
- * effective values back when the session reports them). Resume goes
1905
- * further and ignores the model/thinking parameters outright — it runs on
1906
- * the session it is reopening — so rendering the base there advertises
1907
- * settings the run never used.
1908
- *
1909
- * The mode label is rebuilt rather than carried over: it hangs off the
1910
- * agent TYPE, not the invocation, so tags taken straight from
1911
- * buildInvocationTags would silently drop `twin`.
1912
- */
1913
- const detailBaseFor = (rec: AgentRecord | undefined): typeof detailBase => {
1914
- if (!rec?.invocation) return detailBase;
1915
- const type = rec.type;
1916
- const { modelName: recModelName, tags } = buildInvocationTags(rec.invocation);
1917
- const recModeLabel = getPromptModeLabel(type);
1918
- const recTags = recModeLabel ? [recModeLabel, ...tags] : tags;
1919
- return {
1920
- displayName: getDisplayName(type),
1921
- description: rec.description,
1922
- subagentType: type,
1923
- modelName: recModelName,
1924
- tags: recTags.length > 0 ? recTags : undefined,
1925
- };
1926
- };
1927
-
1928
1231
  // ---- Schedule: register a job, don't spawn now ----
1929
1232
  if (params.schedule) {
1930
1233
  if (!isSchedulingEnabled()) {
@@ -1947,9 +1250,7 @@ Terse command-style prompts produce shallow, generic work.
1947
1250
  name: params.description as string,
1948
1251
  description: params.description as string,
1949
1252
  schedule: params.schedule as string,
1950
- // The caller's own name, not the substitute — the scheduler re-resolves
1951
- // at fire time, and the original is what a user edits.
1952
- subagent_type: requestedType,
1253
+ subagent_type: subagentType,
1953
1254
  prompt: params.prompt as string,
1954
1255
  model: params.model as string | undefined,
1955
1256
  thinking: thinking,
@@ -1959,7 +1260,7 @@ Terse command-style prompts produce shallow, generic work.
1959
1260
  });
1960
1261
  const next = scheduler.getNextRun(job.id);
1961
1262
  return textResult(
1962
- `${fallbackNote}Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
1263
+ `Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
1963
1264
  `Next run: ${next ?? "(unknown)"}. ` +
1964
1265
  `Manage via /agents → Scheduled jobs.`,
1965
1266
  );
@@ -1971,53 +1272,12 @@ Terse command-style prompts produce shallow, generic work.
1971
1272
  // Resume existing agent
1972
1273
  if (params.resume) {
1973
1274
  const existing = manager.getRecord(params.resume);
1974
- if (!existing || !isTopLevelAgent(existing)) {
1275
+ if (!existing) {
1975
1276
  return textResult(`Agent not found: "${params.resume}". It may have been cleaned up.`);
1976
1277
  }
1977
1278
  if (!existing.session) {
1978
1279
  return textResult(`Agent "${params.resume}" has no active session to resume.`);
1979
1280
  }
1980
-
1981
- // Background resume: detached run that notifies on completion, mirroring
1982
- // a background spawn. Previously run_in_background was silently ignored
1983
- // on resume (this branch returned before the background branch below),
1984
- // so a resumed agent always blocked the main loop until it finished.
1985
- if (runInBackground) {
1986
- const id = existing.id;
1987
- // A detached resume hands control back while the record stays
1988
- // "running", so nothing stops the model from resuming the same agent
1989
- // again mid-run. manager.resume() refuses that (it would orphan the
1990
- // live run's abort controller); say why here, where the model can act
1991
- // on it, instead of letting it read as a generic failure.
1992
- if (existing.status === "running" || existing.status === "queued") {
1993
- return textResult(
1994
- `Agent "${params.resume}" is still ${existing.status} — it can only be resumed once its current run finishes.\n` +
1995
- `Use steer_subagent to send it a message mid-run, or get_subagent_result to wait for it.`,
1996
- );
1997
- }
1998
-
1999
- const record = await startBackgroundResume(ctx, existing, params.prompt, {
2000
- outputTranscript,
2001
- maxTurns: effectiveMaxTurns,
2002
- toolCallId,
2003
- });
2004
- if (!record) {
2005
- return textResult(`Failed to resume agent "${params.resume}".`);
2006
- }
2007
-
2008
- const isQueued = record.status === "queued";
2009
- return textResult(
2010
- `Agent ${isQueued ? "queued" : "resumed"} in background.\n` +
2011
- `Agent ID: ${id}\n` +
2012
- `Type: ${existing.type}\n` +
2013
- (record.outputFile ? `Output file: ${record.outputFile}\n` : "") +
2014
- (isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
2015
- `\nYou will be notified when this agent completes.\n` +
2016
- `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.`,
2017
- { ...detailBaseFor(record), toolUses: record.toolUses, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
2018
- );
2019
- }
2020
-
2021
1281
  const record = await manager.resume(params.resume, params.prompt, signal);
2022
1282
  if (!record) {
2023
1283
  return textResult(`Failed to resume agent "${params.resume}".`);
@@ -2025,11 +1285,11 @@ Terse command-style prompts produce shallow, generic work.
2025
1285
  // A failed resume surfaces the error, plus any partial output THIS
2026
1286
  // resume produced (never the previous turn's answer, #144).
2027
1287
  if (record.status === "error") {
2028
- return textResult(`Agent failed: ${record.error}${partialOutputSuffix(record)}`, buildDetails(detailBaseFor(record), record));
1288
+ return textResult(`Agent failed: ${record.error}${partialOutputSuffix(record)}`, buildDetails(detailBase, record));
2029
1289
  }
2030
1290
  return textResult(
2031
1291
  record.result?.trim() || "No output.",
2032
- buildDetails(detailBaseFor(record), record),
1292
+ buildDetails(detailBase, record),
2033
1293
  );
2034
1294
  }
2035
1295
 
@@ -2038,53 +1298,47 @@ Terse command-style prompts produce shallow, generic work.
2038
1298
  const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns);
2039
1299
 
2040
1300
  // Wrap onSessionCreated to wire output file streaming.
2041
- // The callback lazily reads record.outputFile (set right after spawn)
2042
- // rather than closing over a value that doesn't exist yet.
1301
+ // The callback reads the transcript paths installed synchronously by
1302
+ // onSpawned before the agent can queue or start.
2043
1303
  let id: string;
1304
+ const joinMode = resolveJoinMode(defaultJoinMode, true);
2044
1305
  const origBgOnSession = bgCallbacks.onSessionCreated;
2045
1306
  bgCallbacks.onSessionCreated = (session: any) => {
2046
1307
  origBgOnSession(session);
2047
1308
  const rec = manager.getRecord(id);
2048
1309
  if (rec?.outputFile) {
2049
- rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd, undefined);
1310
+ rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd, rec.historyFile);
2050
1311
  }
2051
1312
  };
2052
1313
 
2053
- // A throw here means the agent never started. Let it out: pi marks a
2054
- // tool call failed only when execute throws, and a returned message
2055
- // reads to the model as a subagent that ran and reported this (#179).
2056
- id = manager.spawn(pi, ctx, subagentType, params.prompt, {
2057
- description: params.description,
2058
- name: params.name as string | undefined,
2059
- model,
2060
- maxTurns: effectiveMaxTurns,
2061
- isolated,
2062
- inheritContext,
2063
- thinkingLevel: thinking,
2064
- isBackground: true,
2065
- isolation,
2066
- invocation: agentInvocation,
2067
- outputTranscript,
2068
- rootSessionId: ctx.sessionManager.getSessionId(),
2069
- ...bgCallbacks,
2070
- });
1314
+ try {
1315
+ id = manager.spawn(pi, ctx, subagentType, params.prompt, {
1316
+ description: params.description,
1317
+ model,
1318
+ maxTurns: effectiveMaxTurns,
1319
+ isolated,
1320
+ inheritContext,
1321
+ thinkingLevel: thinking,
1322
+ isBackground: true,
1323
+ isolation,
1324
+ invocation: agentInvocation,
1325
+ onSpawned: (spawnedId) => {
1326
+ attachTranscript(manager.getRecord(spawnedId), spawnedId);
1327
+ },
1328
+ ...bgCallbacks,
1329
+ });
1330
+ } catch (err) {
1331
+ return textResult(err instanceof Error ? err.message : String(err));
1332
+ }
2071
1333
 
2072
- // Set output file + join mode synchronously after spawn, before the
2073
- // event loop yields — onSessionCreated is async so this is safe.
2074
- const joinMode = resolveJoinMode(defaultJoinMode, true);
1334
+ // Set join metadata after spawn. Transcript metadata was installed by
1335
+ // the manager's synchronous onSpawned callback before this point.
2075
1336
  const record = manager.getRecord(id);
2076
1337
  if (record && joinMode) {
2077
1338
  record.joinMode = joinMode;
2078
1339
  record.toolCallId = toolCallId;
2079
- attachTranscript(record, id);
2080
1340
  }
2081
1341
 
2082
- // With isolation: "worktree" the agent isn't running yet — the repo
2083
- // copy is an awaited git call. Wait for it here, after the synchronous
2084
- // wiring above, so a strict-isolation failure still fails THIS tool
2085
- // call instead of being reported as a subagent that ran (#179).
2086
- await manager.awaitStartup(id);
2087
-
2088
1342
  if (joinMode == null || joinMode === 'async') {
2089
1343
  // Foreground/no join mode or explicit async — not part of any batch
2090
1344
  } else {
@@ -2099,6 +1353,7 @@ Terse command-style prompts produce shallow, generic work.
2099
1353
  agentActivity.set(id, bgState);
2100
1354
  widget.ensureTimer();
2101
1355
  widget.update();
1356
+
2102
1357
  // Emit created event
2103
1358
  pi.events.emit("subagents:created", {
2104
1359
  id,
@@ -2109,7 +1364,7 @@ Terse command-style prompts produce shallow, generic work.
2109
1364
 
2110
1365
  const isQueued = record?.status === "queued";
2111
1366
  return textResult(
2112
- `${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
1367
+ `Agent ${isQueued ? "queued" : "started"} in background.\n` +
2113
1368
  `Agent ID: ${id}\n` +
2114
1369
  `Type: ${displayName}\n` +
2115
1370
  `Description: ${params.description}\n` +
@@ -2118,7 +1373,7 @@ Terse command-style prompts produce shallow, generic work.
2118
1373
  `\nYou will be notified when this agent completes.\n` +
2119
1374
  `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
2120
1375
  `Do not duplicate this agent's work.`,
2121
- { ...detailBaseFor(record), toolUses: 0, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
1376
+ { ...detailBase, toolUses: 0, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
2122
1377
  );
2123
1378
  }
2124
1379
 
@@ -2126,33 +1381,17 @@ Terse command-style prompts produce shallow, generic work.
2126
1381
  let spinnerFrame = 0;
2127
1382
  const startedAt = Date.now();
2128
1383
  let fgId: string | undefined;
2129
- // Set only while the spawn is parked on a foreground concurrency slot
2130
- // (maxConcurrentForeground); undefined the rest of the time, including
2131
- // always when the limit is unset.
2132
- let queuedAhead: number | undefined;
2133
1384
 
2134
1385
  const streamUpdate = () => {
2135
- // Spend from the record, everything else from the live tracker. `fgId`
2136
- // is set in onSessionCreated below, which fires before the first
2137
- // assistant message — so nothing is spent while this reads zero.
2138
- const fgRecord = fgId ? manager.getRecord(fgId) : undefined;
2139
1386
  const details: AgentDetails = {
2140
- ...detailBaseFor(fgRecord),
1387
+ ...detailBase,
2141
1388
  toolUses: fgState.toolUses,
2142
- tokens: fgRecord ? formatLifetimeTokens(fgRecord) : "",
2143
- cost: fgRecord ? getLifetimeCost(fgRecord.lifetimeUsage) : 0,
1389
+ tokens: formatLifetimeTokens(fgState),
2144
1390
  turnCount: fgState.turnCount,
2145
1391
  maxTurns: fgState.maxTurns,
2146
1392
  durationMs: Date.now() - startedAt,
2147
- // Deliberately still "running" while queued: the renderer routes any
2148
- // status it doesn't know to raw text (see the catch-all below), which
2149
- // would drop the spinner and read as hung. Only the activity line
2150
- // changes — "thinking…" would be a lie for an agent that has not
2151
- // started and may not for minutes.
2152
1393
  status: "running",
2153
- activity: queuedAhead === undefined
2154
- ? describeActivity(fgState.activeTools, fgState.responseText)
2155
- : `queued — waiting for a foreground slot${queuedAhead > 0 ? ` (${queuedAhead} ahead)` : ""}`,
1394
+ activity: describeActivity(fgState.activeTools, fgState.responseText),
2156
1395
  spinnerFrame: spinnerFrame % SPINNER.length,
2157
1396
  };
2158
1397
  onUpdate?.({
@@ -2169,18 +1408,12 @@ Terse command-style prompts produce shallow, generic work.
2169
1408
  const origOnSession = fgCallbacks.onSessionCreated;
2170
1409
  fgCallbacks.onSessionCreated = (session: any) => {
2171
1410
  origOnSession(session);
2172
- // It really started — stop reporting it as queued, and repaint now
2173
- // rather than leaving the stale line up for the next spinner tick.
2174
- // Guarded, so a spawn that never queued emits no extra update.
2175
- if (queuedAhead !== undefined) {
2176
- queuedAhead = undefined;
2177
- streamUpdate();
2178
- }
2179
1411
  for (const a of manager.listAgents()) {
2180
1412
  if (a.session === session) {
2181
1413
  fgId = a.id;
2182
1414
  agentActivity.set(a.id, fgState);
2183
1415
  widget.ensureTimer();
1416
+ widget.update();
2184
1417
  break;
2185
1418
  }
2186
1419
  }
@@ -2188,7 +1421,7 @@ Terse command-style prompts produce shallow, generic work.
2188
1421
  if (fgId) {
2189
1422
  const rec = manager.getRecord(fgId);
2190
1423
  if (rec?.outputFile) {
2191
- rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd, undefined);
1424
+ rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd, rec.historyFile);
2192
1425
  }
2193
1426
  }
2194
1427
  };
@@ -2205,7 +1438,6 @@ Terse command-style prompts produce shallow, generic work.
2205
1438
  try {
2206
1439
  const fgResult = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
2207
1440
  description: params.description,
2208
- name: params.name as string | undefined,
2209
1441
  model,
2210
1442
  maxTurns: effectiveMaxTurns,
2211
1443
  isolated,
@@ -2213,13 +1445,7 @@ Terse command-style prompts produce shallow, generic work.
2213
1445
  thinkingLevel: thinking,
2214
1446
  isolation,
2215
1447
  invocation: agentInvocation,
2216
- outputTranscript,
2217
1448
  signal,
2218
- rootSessionId: ctx.sessionManager.getSessionId(),
2219
- // Deliberately does NOT set fgId: that drives agentActivity, the
2220
- // widget and the `finally` cleanup below, none of which should see an
2221
- // agent that has no session and may never get one.
2222
- onQueued: (_id, ahead) => { queuedAhead = ahead; streamUpdate(); },
2223
1449
  ...fgCallbacks,
2224
1450
  }, (fgAgentId) => {
2225
1451
  // onSpawned: called synchronously after spawn, before onSessionCreated fires.
@@ -2228,22 +1454,29 @@ Terse command-style prompts produce shallow, generic work.
2228
1454
  attachTranscript(fgRec, fgAgentId);
2229
1455
  });
2230
1456
  record = fgResult.record;
2231
- } finally {
2232
- // Runs on both paths, so a startup throw — which now propagates, see
2233
- // the background spawn above (#179) — no longer leaves the spinner
2234
- // ticking or a finished agent on the widget.
1457
+ } catch (err) {
2235
1458
  clearInterval(spinnerInterval);
2236
- if (fgId) {
2237
- agentActivity.delete(fgId);
2238
- widget.markFinished(fgId);
2239
- }
1459
+ return textResult(err instanceof Error ? err.message : String(err));
2240
1460
  }
2241
1461
 
2242
- // Get final token count — from the record, like the cost below it, so the
2243
- // two describe the same work when the agent delegated to nested children.
2244
- const tokenText = formatLifetimeTokens(record);
1462
+ clearInterval(spinnerInterval);
1463
+
1464
+ // Clean up foreground agent from widget
1465
+ if (fgId) {
1466
+ agentActivity.delete(fgId);
1467
+ widget.markFinished(fgId);
1468
+ }
2245
1469
 
2246
- const details = buildDetails(detailBaseFor(record), record, fgState, { tokens: tokenText });
1470
+ // Get final token count
1471
+ const tokenText = formatLifetimeTokens(fgState);
1472
+
1473
+ const details = buildDetails(detailBase, record, fgState, { tokens: tokenText });
1474
+
1475
+ // "general-purpose" may itself be unregistered (defaults disabled, no
1476
+ // user override) — getConfig then uses the hardcoded fallback config.
1477
+ const fallbackNote = fellBack
1478
+ ? `Note: Unknown agent type "${rawType}" — using ${resolveType("general-purpose") ? "general-purpose" : "the fallback agent config"}.\n\n`
1479
+ : "";
2247
1480
 
2248
1481
  if (record.status === "error") {
2249
1482
  // Error headline + any partial output the run produced before failing.
@@ -2253,459 +1486,25 @@ Terse command-style prompts produce shallow, generic work.
2253
1486
  const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
2254
1487
  const statsParts = [`${record.toolUses} tool uses`];
2255
1488
  if (tokenText) statsParts.push(tokenText);
2256
- if (showCost) {
2257
- const costText = formatCost(getLifetimeCost(record.lifetimeUsage));
2258
- if (costText) statsParts.push(costText);
2259
- }
2260
1489
  return textResult(
2261
- `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getForegroundOutcomeNote(record.status)}.\n\n` +
1490
+ `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getStatusNote(record.status)}.\n\n` +
2262
1491
  (record.result?.trim() || "No output."),
2263
1492
  details,
2264
1493
  );
2265
1494
  },
2266
- });
2267
- /**
2268
- * Wrap a tool so its results carry back whatever subagent spend the parent
2269
- * session has not been told about yet (see `PendingUsagePool`).
2270
- *
2271
- * Pi copies `AgentToolResult.usage` onto the persisted tool-result message and
2272
- * folds it into `getSessionStats()`, which is what the footer, the statusline
2273
- * and `/cost` read — so this is the whole of "report usage to the parent".
2274
- *
2275
- * Nothing is attached to a call with no tool-call id. That is the `@handle`
2276
- * mention path (`mention-clone.ts`), which invokes this tool from a fork of the
2277
- * conversation that is discarded moments later: the result never becomes a
2278
- * message in the real session, so usage hung on it would be spend the user paid
2279
- * for and nobody counted. Skipping leaves it pending for the next real result.
2280
- */
2281
- function withUsageReporting<T extends { execute: (...args: any[]) => any }>(tool: T): T {
2282
- return {
2283
- ...tool,
2284
- execute: async (toolCallId: string | undefined, ...rest: any[]) => {
2285
- const result = await tool.execute(toolCallId, ...rest);
2286
- if (!reportUsage || !toolCallId) return result;
2287
- const usage = pendingUsage.drain();
2288
- return usage ? { ...result, usage } : result;
2289
- },
2290
- };
2291
- }
2292
- function registerToolReportingUsage(tool: any): void {
2293
- pi.registerTool(withUsageReporting(tool));
2294
- }
2295
-
2296
- // The mention path is handed THIS object, not the bare `agentTool` — see the
2297
- // mention-clone header on why the clone must call the registered tool.
2298
- const registeredAgentTool = withUsageReporting(agentTool);
2299
- pi.registerTool(registeredAgentTool);
2300
-
2301
- // ---- Workflow tool ----
2302
-
2303
- /**
2304
- * Live runs, by task id. The tool returns before the run finishes, so its
2305
- * result card looks the task up here on every render rather than freezing a
2306
- * snapshot into `details` — that is what makes the inline card follow a
2307
- * background run.
2308
- */
2309
- const workflowTasks = new Map<string, WorkflowTask>();
2310
-
2311
- /**
2312
- * Run a task to completion against the real manager, settling the record
2313
- * either way. Never rejects: a run that cannot start (bad `meta`, oversized
2314
- * source, non-JSON `args`) is a failed workflow, and both callers here are
2315
- * detached — a rejection would surface as an unhandled one.
2316
- */
2317
- async function runWorkflowTask(ctx: ExtensionContext, task: WorkflowTask): Promise<void> {
2318
- try {
2319
- const result = await runWorkflow({
2320
- script: task.script,
2321
- args: task.args,
2322
- signal: task.abortController.signal,
2323
- host: createWorkflowHost({
2324
- pi,
2325
- ctx,
2326
- manager,
2327
- signal: task.abortController.signal,
2328
- rootSessionId: ctx.sessionManager.getSessionId(),
2329
- workflowId: task.id,
2330
- }),
2331
- onProgress: entries => updateWorkflowProgressBatch(task, entries),
2332
- // The dialog's pause / skip / retry keys run through this; it is dropped
2333
- // again when the task settles.
2334
- onControl: control => { task.control = control; },
2335
- journal: {
2336
- ...(task.replay !== undefined ? { entries: task.replay } : {}),
2337
- ...(task.journalPath !== undefined
2338
- ? { append: (entry: WorkflowJournalEntry) => appendJournal(task.journalPath!, entry) }
2339
- : {}),
2340
- },
2341
- });
2342
- completeWorkflowTask(task, result);
2343
- } catch (err) {
2344
- failWorkflowTask(task, err instanceof Error ? err.message : String(err));
2345
- }
2346
- }
2347
-
2348
- /**
2349
- * Hand a finished run back to the model through the SAME channel a background
2350
- * agent uses — held briefly by `scheduleNudge`, delivered as a follow-up that
2351
- * triggers a turn, rendered by the existing `subagent-notification` renderer.
2352
- */
2353
- function notifyWorkflowFinished(task: WorkflowTask) {
2354
- widget.update();
2355
- const result = workflowResultText(task);
2356
- scheduleNudge(task.id, () => {
2357
- pi.sendMessage<NotificationDetails>({
2358
- customType: "subagent-notification",
2359
- content: formatWorkflowNotification(task),
2360
- display: true,
2361
- details: {
2362
- id: task.id,
2363
- description: `Workflow ${task.workflowName ?? task.id}`,
2364
- status: task.status === "completed" ? "completed" : task.status === "killed" ? "stopped" : "error",
2365
- toolUses: task.totalToolCalls,
2366
- // A workflow has agents, not turns; rendering "↻0" would be noise.
2367
- turnCount: 0,
2368
- totalTokens: task.totalTokens,
2369
- durationMs: elapsedMs(task, Date.now()),
2370
- error: task.error,
2371
- resultPreview: result.length > 500 ? `${result.slice(0, 500)}…` : result,
2372
- },
2373
- }, { deliverAs: "followUp", triggerTurn: true });
2374
- });
2375
- }
2376
-
2377
- // Defined unconditionally, registered only when the feature is on — the same
2378
- // shape the Agent tool uses. Keeping the definition out of the `if` means the
2379
- // switch changes exactly one thing: whether pi is ever told about the tool.
2380
- const workflowTool = defineTool({
2381
- name: SUBAGENT_TOOL_NAMES.WORKFLOW,
2382
- label: "SubagentWorkflow",
2383
- description: renderToolDescriptionTemplate(fullWorkflowToolDescription),
2384
- promptSnippet: "Run a deterministic script that orchestrates many subagents",
2385
- promptGuidelines: [
2386
- "Use SubagentWorkflow when the number of agents depends on something discovered at runtime, when work flows through stages, or when findings should be independently verified. Use Agent for one delegated task or a handful you can name up front.",
2387
- "Prefer `pipeline` over `parallel` — a barrier costs wall-clock whenever the stages are unevenly sized.",
2388
- "A workflow runs in the background and notifies you when it finishes — do not poll or sleep waiting for it.",
2389
- ],
2390
- parameters: Type.Object({
2391
- script: Type.Optional(
2392
- Type.String({
2393
- maxLength: 524288,
2394
- description: "Inline workflow source. Must begin with `export const meta = { name, description }`.",
2395
- }),
2396
- ),
2397
- scriptPath: Type.Optional(
2398
- Type.String({
2399
- description:
2400
- "Path to a workflow script file, absolute or relative to the project. Takes precedence over `script` — this is how you re-run an edited workflow.",
2401
- }),
2402
- ),
2403
- name: Type.Optional(
2404
- Type.String({
2405
- description:
2406
- "Name of a saved workflow — `<name>.js` in .pi/workflows/, .agents/workflows/ or the user's agent dir. Lowest precedence: `scriptPath` and `script` both win over it.",
2407
- }),
2408
- ),
2409
- args: Type.Optional(
2410
- Type.Any({
2411
- description: "Exposed to the script as the global `args`, verbatim. Must be JSON-shaped.",
2412
- }),
2413
- ),
2414
- resumeFromRunId: Type.Optional(
2415
- Type.String({
2416
- pattern: "^wf_[a-z0-9-]{6,}$",
2417
- description:
2418
- "Run id of an earlier workflow in this session. Its unchanged leading agent() calls return their recorded results instantly; the first changed or failed call, and everything after it, runs live. Same script and args means nothing re-runs.",
2419
- }),
2420
- ),
2421
- // Accepted and ignored, as in Claude Code. Models reach for them because
2422
- // every other tool has them, and a hard schema rejection would cost a
2423
- // whole turn to re-emit a script that was already correct. The `meta`
2424
- // block is the one place a workflow is named.
2425
- title: Type.Optional(
2426
- Type.String({ description: "Ignored — set the workflow title in the script's `meta` block." }),
2427
- ),
2428
- description: Type.Optional(
2429
- Type.String({ description: "Ignored — set the workflow description in the script's `meta` block." }),
2430
- ),
2431
- }),
2432
-
2433
- renderCall(args, theme) {
2434
- return new Text(
2435
- `${theme.fg("toolTitle", "▸ ")}${theme.bold(theme.fg("toolTitle", "SubagentWorkflow"))} ${theme.fg("muted", workflowCallName(args))}`,
2436
- 0,
2437
- 0,
2438
- );
2439
- },
2440
-
2441
- renderResult(result, _options, theme, renderContext) {
2442
- const text = result.content[0]?.type === "text" ? result.content[0].text : "";
2443
- const taskId = (result.details as { taskId?: string } | undefined)?.taskId;
2444
- const task = taskId !== undefined ? workflowTasks.get(taskId) : undefined;
2445
- // No task means the run predates this session (a reloaded transcript) or
2446
- // the call never started one — show what `execute` said instead.
2447
- if (renderContext.isError || !task) return new Text(text, 0, 0);
2448
- return renderWorkflowCard(
2449
- {
2450
- progress: task.workflowProgress,
2451
- task: {
2452
- status: task.status,
2453
- workflowName: task.workflowName,
2454
- startTime: task.startTime,
2455
- endTime: task.endTime,
2456
- totalPausedMs: task.totalPausedMs,
2457
- },
2458
- meta: task.meta,
2459
- agentCount: task.agentCount,
2460
- totalTokens: task.totalTokens,
2461
- },
2462
- theme,
2463
- );
2464
- },
2465
-
2466
- execute: async (toolCallId, params, _signal, _onUpdate, ctx) => {
2467
- const resumeFrom = resolveResumeTarget(params.resumeFromRunId, workflowTasks);
2468
- if (resumeFrom !== undefined && !resumeFrom.ok) return textResult(resumeFrom.message);
2469
-
2470
- // A resume with no source of its own re-runs what that run ran. The
2471
- // common case is an edited script, but "run that again, cheaply" should
2472
- // not require repeating a path the run already knows.
2473
- const resolved = resolveWorkflowScript(
2474
- params.script === undefined && params.scriptPath === undefined && params.name === undefined
2475
- && resumeFrom !== undefined
2476
- ? { scriptPath: resumeFrom.scriptPath }
2477
- : params,
2478
- ctx.cwd,
2479
- );
2480
- if (!resolved.ok) return textResult(resolved.message);
2481
-
2482
- // Parsed before anything is scheduled: a bad `meta` is an authoring error
2483
- // the model can fix immediately, and reporting it as a background run
2484
- // that failed a second later would just cost a turn.
2485
- let meta: WorkflowMeta;
2486
- try {
2487
- meta = extractMeta(resolved.script).meta;
2488
- } catch (err) {
2489
- return textResult(err instanceof Error ? err.message : String(err));
2490
- }
2491
-
2492
- const runId = workflowRunId();
2493
- // Every invocation lands on disk next to the agent transcripts, so
2494
- // iterating is edit-the-file-then-rerun-with-scriptPath rather than
2495
- // re-emitting the whole source. The journal sits beside it under the same
2496
- // id, which is what makes a run id enough to resume from.
2497
- let savedPath: string | undefined;
2498
- let journalPath: string | undefined;
2499
- try {
2500
- const dir = sessionTaskDir(ctx.cwd, ctx.sessionManager.getSessionId());
2501
- savedPath = join(dir, `${runId}.workflow.js`);
2502
- writeFileSync(savedPath, resolved.script, "utf-8");
2503
- journalPath = join(dir, `${runId}.workflow.jsonl`);
2504
- } catch (err) {
2505
- savedPath = undefined;
2506
- journalPath = undefined;
2507
- console.warn(`[pi-subagents] could not persist workflow script: ${err instanceof Error ? err.message : String(err)}`);
2508
- }
2509
-
2510
- const replay = resumeFrom !== undefined ? readJournal(resumeFrom.journalPath) : undefined;
2511
-
2512
- const task = createWorkflowTask({
2513
- id: runId,
2514
- script: resolved.script,
2515
- scriptPath: resolved.scriptPath ?? savedPath,
2516
- args: params.args,
2517
- meta,
2518
- toolCallId,
2519
- ...(journalPath !== undefined ? { journalPath } : {}),
2520
- ...(replay !== undefined && replay.length > 0 ? { replay, resumedFrom: resumeFrom!.runId } : {}),
2521
- });
2522
- workflowTasks.set(runId, task);
2523
- // The run's own row has to appear now, not when it settles. Its agents
2524
- // are owned by it, so their lifecycle callbacks no longer refresh these
2525
- // surfaces — nothing else would register the widget for a run whose
2526
- // first agent has not started yet.
2527
- widget.update();
2528
-
2529
- // Background, like Claude Code: the id comes back now and the run keeps
2530
- // going without the tool call.
2531
- void runWorkflowTask(ctx, task).then(() => notifyWorkflowFinished(task));
2532
-
2533
- return {
2534
- content: [{
2535
- type: "text" as const,
2536
- text:
2537
- `Workflow "${meta.name}" started in the background.\n` +
2538
- `Task ID: ${runId}\n` +
2539
- (task.scriptPath ? `Script: ${task.scriptPath}\n` : "") +
2540
- (task.resumedFrom !== undefined
2541
- ? `Resuming ${task.resumedFrom}: ${task.replay?.length ?? 0} recorded call(s) available to replay.\n`
2542
- : params.resumeFromRunId !== undefined
2543
- ? `Nothing to replay from ${params.resumeFromRunId} — every agent runs live.\n`
2544
- : "") +
2545
- `\nYou will be notified when it finishes — do NOT poll or sleep waiting for it.\n` +
2546
- `To iterate, edit the script file and call SubagentWorkflow again with scriptPath.`,
2547
- }],
2548
- details: { taskId: runId },
2549
- };
2550
- },
2551
- });
2552
-
2553
- if (isWorkflowsEnabled()) pi.registerTool(workflowTool);
2554
-
2555
- /**
2556
- * Act on {@link decideWorkflowCollision} — the half that needs the host.
2557
- *
2558
- * The policy (what counts as a conflict, what a pin changes, whether there is
2559
- * anything left to withdraw) lives in `workflow/collisions.ts`; this is the
2560
- * host-facing shell around it: read the registry, warn, and take our tool out
2561
- * of the active set.
2562
- *
2563
- * ## Why this can only happen at session_start
2564
- *
2565
- * `getAllTools` throws during extension loading ("Action methods cannot be
2566
- * called during extension loading"), and load order means a check at
2567
- * registration time could not see an extension that has not loaded yet. So
2568
- * the decision cannot gate `registerTool`; it has to undo it. `setActiveTools`
2569
- * is what makes that real rather than cosmetic — pi rebuilds the system
2570
- * prompt from the new set, and `session_start` runs before any turn, so the
2571
- * model never sees a spec we withdrew. A later `_refreshToolRegistry` keeps
2572
- * the active set it had and only adds names new to the registry, so ours does
2573
- * not creep back.
2574
- *
2575
- * Best-effort and swallowed. A diagnostic that took the session down would be
2576
- * worse than the collision it reports.
2577
- */
2578
- let collisionsChecked = false;
2579
- function resolveWorkflowCollisions(ctx: ExtensionContext): void {
2580
- if (collisionsChecked) return;
2581
- collisionsChecked = true;
2582
-
2583
- const warn = (message: string) => {
2584
- if (ctx.hasUI) ctx.ui.notify(message, "warning");
2585
- else console.warn(`[pi-subagents] ${message}`);
2586
- };
2587
-
2588
- try {
2589
- if (!isWorkflowsEnabled()) return;
2590
-
2591
- const verdict = decideWorkflowCollision({
2592
- tools: pi.getAllTools(),
2593
- // Identifies our own registration: this extension does not know its
2594
- // install path, and the description is the one field certainly ours.
2595
- ownDescription: workflowTool.description,
2596
- pinned: isWorkflowsPinned(),
2597
- });
2598
- if (verdict.kind === "none") return;
2599
- if (verdict.kind === "report") {
2600
- warn(verdict.message);
2601
- return;
2602
- }
2603
-
2604
- workflowsEnabled = false; // not setWorkflowsEnabled: this is not the user pinning it
2605
- widget.update();
2606
- warn(verdict.message);
2607
-
2608
- if (!verdict.withdraw) return;
2609
- const active = pi.getActiveTools();
2610
- if (active.includes(SUBAGENT_TOOL_NAMES.WORKFLOW)) {
2611
- pi.setActiveTools(active.filter(name => name !== SUBAGENT_TOOL_NAMES.WORKFLOW));
2612
- }
2613
- } catch {
2614
- // getAllTools/setActiveTools are unavailable in some hosts (print mode,
2615
- // RPC). Not being able to check is not a reason to fail the session.
2616
- }
2617
- }
2618
-
2619
- /**
2620
- * `--subagents-workflow-file=<path>` — run a script at startup, with no LLM
2621
- * round-trip deciding whether to call the tool.
2622
- *
2623
- * Read here rather than at activation because that is the only place the real
2624
- * value exists: the host activates extensions first and applies collected CLI
2625
- * flags second, so `getFlag` during activation returns the registered default
2626
- * and nothing else. `examples/extensions/ssh.ts` reads its flag from
2627
- * session_start for exactly this reason.
2628
- */
2629
- let workflowFlagHandled = false;
2630
- function runWorkflowFlag(ctx: ExtensionContext): void {
2631
- if (workflowFlagHandled) return;
2632
- const flag = typeof pi.getFlag === "function" ? pi.getFlag(WORKFLOW_FILE_FLAG) : undefined;
2633
- if (flag === undefined || flag === false) return;
2634
- workflowFlagHandled = true;
2635
-
2636
- const report = (message: string, level: "info" | "warning") => {
2637
- if (ctx.hasUI) ctx.ui.notify(message, level);
2638
- else console.warn(`[pi-subagents] ${message}`);
2639
- };
2640
-
2641
- // The flag is the same machinery by another door, so the master switch has
2642
- // to close it too — silently ignoring a flag the user typed would be worse
2643
- // than saying why nothing ran.
2644
- if (!isWorkflowsEnabled()) {
2645
- report(
2646
- `--${WORKFLOW_FILE_FLAG} ignored: workflows are off. Turn them on in /agents → Settings → Workflows, ` +
2647
- 'or set `"workflowsEnabled": true` in .pi/subagents.json.',
2648
- "warning",
2649
- );
2650
- return;
2651
- }
2652
-
2653
- // A bare `--subagents-workflow-file` parses to boolean `true`. Say what was
2654
- // missing rather than reading a file called "true".
2655
- if (typeof flag !== "string" || flag.trim() === "") {
2656
- report(`--${WORKFLOW_FILE_FLAG} needs a path: --${WORKFLOW_FILE_FLAG}=<path>`, "warning");
2657
- return;
2658
- }
2659
-
2660
- const path = isAbsolute(flag.trim()) ? flag.trim() : join(ctx.cwd, flag.trim());
2661
- let script: string;
2662
- try {
2663
- script = readFileSync(path, "utf-8");
2664
- } catch (err) {
2665
- report(`Could not read ${path}: ${err instanceof Error ? err.message : String(err)}`, "warning");
2666
- return;
2667
- }
2668
-
2669
- let meta: WorkflowMeta | undefined;
2670
- try {
2671
- meta = extractMeta(script).meta;
2672
- } catch (err) {
2673
- report(err instanceof Error ? err.message : String(err), "warning");
2674
- return;
2675
- }
2676
-
2677
- const task = createWorkflowTask({ id: workflowRunId(), script, scriptPath: path, meta });
2678
- workflowTasks.set(task.id, task);
2679
- widget.update();
2680
- report(`Running workflow ${meta.name}…`, "info");
2681
-
2682
- // Detached: session_start is awaited by the host, and a workflow can run for
2683
- // minutes — blocking here would hold the whole session's startup.
2684
- void runWorkflowTask(ctx, task).then(() => {
2685
- // No tool call to attach a result card to, so the card becomes a session
2686
- // entry (same layout), and the outcome is handed to the model as context
2687
- // for its next turn rather than forcing one.
2688
- pi.appendEntry<WorkflowEntryData>(WORKFLOW_ENTRY_TYPE, workflowEntryData(task));
2689
- pi.sendMessage({
2690
- customType: "workflow-result",
2691
- content: formatWorkflowNotification(task),
2692
- display: false,
2693
- }, { deliverAs: "nextTurn" });
2694
- widget.update();
2695
- });
2696
- }
1495
+ }));
2697
1496
 
2698
1497
  // ---- get_subagent_result tool ----
2699
1498
 
2700
- registerToolReportingUsage(defineTool({
1499
+ pi.registerTool(defineTool({
2701
1500
  name: SUBAGENT_TOOL_NAMES.GET_RESULT,
2702
1501
  label: "Get Agent Result",
2703
1502
  description:
2704
- "Check status and retrieve a background agent's full result — its completion notification carries only a preview. Use the agent ID returned by Agent.",
1503
+ "Check status and retrieve results from a background agent. Use the agent ID returned by Agent with run_in_background.",
2705
1504
  promptSnippet: "Check status and retrieve results from a background agent",
2706
1505
  parameters: Type.Object({
2707
1506
  agent_id: Type.String({
2708
- description: "The agent ID to check. The agent's handle also works — its `name` if you gave it one, otherwise its type (`explore`, `explore-2`).",
1507
+ description: "The agent ID to check.",
2709
1508
  }),
2710
1509
  wait: Type.Optional(
2711
1510
  Type.Boolean({
@@ -2719,8 +1518,8 @@ Terse command-style prompts produce shallow, generic work.
2719
1518
  ),
2720
1519
  }),
2721
1520
  execute: async (_toolCallId, params, signal, _onUpdate, _ctx) => {
2722
- const record = resolveAgentRef(params.agent_id);
2723
- if (!record || !isTopLevelAgent(record)) {
1521
+ const record = manager.getRecord(params.agent_id);
1522
+ if (!record) {
2724
1523
  return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
2725
1524
  }
2726
1525
 
@@ -2739,16 +1538,15 @@ Terse command-style prompts produce shallow, generic work.
2739
1538
  if (record.promise) await abortable(record.promise, signal);
2740
1539
  }
2741
1540
 
1541
+ const durableResult = !record.result?.trim() && record.transcriptPath && currentCtx?.cwd
1542
+ ? readAgentHistoryResult(currentCtx.cwd, record.transcriptPath)
1543
+ : undefined;
2742
1544
  const displayName = getDisplayName(record.type);
2743
1545
  const duration = formatDuration(record.startedAt, record.completedAt);
2744
1546
  const tokens = formatLifetimeTokens(record);
2745
1547
  const contextPercent = getSessionContextPercent(record.session);
2746
1548
  const statsParts = [`Tool uses: ${record.toolUses}`];
2747
1549
  if (tokens) statsParts.push(tokens);
2748
- if (showCost) {
2749
- const costText = formatCost(getLifetimeCost(record.lifetimeUsage));
2750
- if (costText) statsParts.push(`Cost: ${costText}`);
2751
- }
2752
1550
  if (contextPercent !== null) statsParts.push(`Context: ${Math.round(contextPercent)}%`);
2753
1551
  if (record.compactionCount) statsParts.push(`Compactions: ${record.compactionCount}`);
2754
1552
  statsParts.push(`Duration: ${duration}`);
@@ -2761,9 +1559,9 @@ Terse command-style prompts produce shallow, generic work.
2761
1559
  if (record.status === "running") {
2762
1560
  output += "Agent is still running. Use wait: true or check back later.";
2763
1561
  } else if (record.status === "error") {
2764
- output += `Error: ${record.error}${partialOutputSuffix(record)}`;
1562
+ output += `Error: ${record.error}${partialOutputSuffix(record, durableResult)}`;
2765
1563
  } else {
2766
- output += record.result?.trim() || "No output.";
1564
+ output += durableResult || record.result?.trim() || "No output.";
2767
1565
  }
2768
1566
 
2769
1567
  // Mark result as consumed — suppresses the completion notification
@@ -2786,7 +1584,7 @@ Terse command-style prompts produce shallow, generic work.
2786
1584
 
2787
1585
  // ---- steer_subagent tool ----
2788
1586
 
2789
- registerToolReportingUsage(defineTool({
1587
+ pi.registerTool(defineTool({
2790
1588
  name: SUBAGENT_TOOL_NAMES.STEER,
2791
1589
  label: "Steer Agent",
2792
1590
  description:
@@ -2795,15 +1593,15 @@ Terse command-style prompts produce shallow, generic work.
2795
1593
  promptSnippet: "Send a steering message to redirect a running background agent",
2796
1594
  parameters: Type.Object({
2797
1595
  agent_id: Type.String({
2798
- description: "The agent ID to steer (must be currently running). The agent's handle also works — its `name` if you gave it one, otherwise its type (`explore`, `explore-2`).",
1596
+ description: "The agent ID to steer (must be currently running).",
2799
1597
  }),
2800
1598
  message: Type.String({
2801
1599
  description: "The steering message to send. This will appear as a user message in the agent's conversation.",
2802
1600
  }),
2803
1601
  }),
2804
1602
  execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
2805
- const record = resolveAgentRef(params.agent_id);
2806
- if (!record || !isTopLevelAgent(record)) {
1603
+ const record = manager.getRecord(params.agent_id);
1604
+ if (!record) {
2807
1605
  return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
2808
1606
  }
2809
1607
  if (record.status !== "running") {
@@ -2824,10 +1622,6 @@ Terse command-style prompts produce shallow, generic work.
2824
1622
  const contextPercent = getSessionContextPercent(record.session);
2825
1623
  const stateParts: string[] = [];
2826
1624
  if (tokens) stateParts.push(tokens);
2827
- if (showCost) {
2828
- const costText = formatCost(getLifetimeCost(record.lifetimeUsage));
2829
- if (costText) stateParts.push(costText);
2830
- }
2831
1625
  stateParts.push(`${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`);
2832
1626
  if (contextPercent !== null) stateParts.push(`context ${Math.round(contextPercent)}% full`);
2833
1627
  if (record.compactionCount) stateParts.push(`${record.compactionCount} compaction${record.compactionCount === 1 ? "" : "s"}`);
@@ -2843,9 +1637,20 @@ Terse command-style prompts produce shallow, generic work.
2843
1637
 
2844
1638
  // ---- /agents interactive menu ----
2845
1639
 
2846
- // Directory resolution and the frontmatter edits live in agent-file-toggle.ts
2847
- // so they are reachable from tests — this command handler is only registered
2848
- // through `registerCommand`, which every test mocks.
1640
+ const projectAgentsDir = () => join(process.cwd(), ".pi", "agents");
1641
+ const workspaceAgentsDir = () => join(process.cwd(), ".agents", "agents");
1642
+ const personalAgentsDir = () => join(getAgentDir(), "agents");
1643
+
1644
+ /** Find the file path of a custom agent by name, in discovery-precedence order (project, workspace, then global). */
1645
+ function findAgentFile(name: string): { path: string; location: "project" | "workspace" | "personal" } | undefined {
1646
+ const projectPath = join(projectAgentsDir(), `${name}.md`);
1647
+ if (existsSync(projectPath)) return { path: projectPath, location: "project" };
1648
+ const workspacePath = join(workspaceAgentsDir(), `${name}.md`);
1649
+ if (existsSync(workspacePath)) return { path: workspacePath, location: "workspace" };
1650
+ const personalPath = join(personalAgentsDir(), `${name}.md`);
1651
+ if (existsSync(personalPath)) return { path: personalPath, location: "personal" };
1652
+ return undefined;
1653
+ }
2849
1654
 
2850
1655
  function getModelLabel(type: string, registry?: ModelRegistry): string {
2851
1656
  const cfg = getAgentConfig(type);
@@ -2872,15 +1677,10 @@ Terse command-style prompts produce shallow, generic work.
2872
1677
  // Build select options
2873
1678
  const options: string[] = [];
2874
1679
 
2875
- // Keep active sessions and durable terminal history as separate menu rows.
2876
- const agents = manager.listAgents().filter(isTopLevelAgent);
2877
- const { active, history } = splitAgentRecords(agents, ctx.cwd);
2878
- if (active.length > 0) {
2879
- const running = active.filter(a => a.status === "running").length;
2880
- const queued = active.filter(a => a.status === "queued").length;
2881
- options.push(`Running agents (${active.length}) — ${running} running, ${queued} queued`);
2882
- }
2883
- if (history.length > 0) options.push(`Agent history (${history.length})`);
1680
+ // Keep active agents and terminal history in separate menu entries.
1681
+ const records = manager.listAgents();
1682
+ const { active, history } = splitAgentRecords(records, ctx.cwd);
1683
+ options.push(...buildAgentStatusMenuEntries(records, ctx.cwd));
2884
1684
 
2885
1685
  // Agent types list
2886
1686
  if (allNames.length > 0) {
@@ -2893,17 +1693,11 @@ Terse command-style prompts produce shallow, generic work.
2893
1693
  options.push(`Scheduled jobs (${jobCount})`);
2894
1694
  }
2895
1695
 
2896
- // Workflow runs, on the same terms as scheduled jobs: shown only when the
2897
- // feature is on, so the menu never advertises something switched off.
2898
- if (isWorkflowsEnabled()) {
2899
- options.push(`Workflows (${workflowTasks.size})`);
2900
- }
2901
-
2902
1696
  // Actions
2903
1697
  options.push("Create new agent");
2904
1698
  options.push("Settings");
2905
1699
 
2906
- const noAgentsMsg = allNames.length === 0 && agents.length === 0
1700
+ const noAgentsMsg = allNames.length === 0 && active.length === 0 && history.length === 0
2907
1701
  ? "No agents found. Create specialized subagents that can be delegated to.\n\n" +
2908
1702
  "Each subagent has its own context window, custom system prompt, and specific tools.\n\n" +
2909
1703
  "Try creating: Code Reviewer, Security Auditor, Test Writer, or Documentation Writer.\n\n"
@@ -2928,9 +1722,6 @@ Terse command-style prompts produce shallow, generic work.
2928
1722
  } else if (choice.startsWith("Scheduled jobs (")) {
2929
1723
  await showSchedulesMenu(ctx, scheduler);
2930
1724
  await showAgentsMenu(ctx);
2931
- } else if (choice.startsWith("Workflows (")) {
2932
- await showWorkflowsMenu(ctx, workflowMenuDeps);
2933
- await showAgentsMenu(ctx);
2934
1725
  } else if (choice === "Create new agent") {
2935
1726
  await showCreateWizard(ctx);
2936
1727
  } else if (choice === "Settings") {
@@ -3007,22 +1798,24 @@ Terse command-style prompts produce shallow, generic work.
3007
1798
  }
3008
1799
  }
3009
1800
 
3010
- function makeUniqueAgentOptionLabels(pairs: Array<{ record: AgentRecord; label: string }>): void {
1801
+ function makeUniqueAgentOptionLabels(pairs: Array<{ record: AgentRecord; label: string }>): string[] {
3011
1802
  const counts = new Map<string, number>();
3012
1803
  for (const pair of pairs) counts.set(pair.label, (counts.get(pair.label) ?? 0) + 1);
3013
1804
  const used = new Set<string>();
3014
- for (const pair of pairs) {
3015
- if ((counts.get(pair.label) ?? 0) === 1) {
3016
- used.add(pair.label);
3017
- continue;
1805
+ return pairs.map((pair) => {
1806
+ const { record, label } = pair;
1807
+ if ((counts.get(label) ?? 0) === 1) {
1808
+ used.add(label);
1809
+ return label;
3018
1810
  }
3019
- const suffix = ` · #${pair.record.id.slice(-8)}`;
3020
- let candidate = `${pair.label}${suffix}`;
1811
+ const suffix = ` · #${record.id.slice(-8)}`;
1812
+ let candidate = `${label}${suffix}`;
3021
1813
  let n = 2;
3022
- while (used.has(candidate)) candidate = `${pair.label}${suffix}-${n++}`;
3023
- pair.label = candidate;
1814
+ while (used.has(candidate)) candidate = `${label}${suffix}-${n++}`;
3024
1815
  used.add(candidate);
3025
- }
1816
+ pair.label = candidate;
1817
+ return candidate;
1818
+ });
3026
1819
  }
3027
1820
 
3028
1821
  async function selectAgentFromReadOnlyList(
@@ -3032,7 +1825,9 @@ Terse command-style prompts produce shallow, generic work.
3032
1825
  selection: AgentMenuSelection,
3033
1826
  ): Promise<AgentRecord | undefined> {
3034
1827
  const options = pairs.map(({ record, label }) => ({ value: record.id, label }));
3035
- const rememberedIndex = selection.id ? pairs.findIndex(({ record }) => record.id === selection.id) : -1;
1828
+ const rememberedIndex = selection.id
1829
+ ? pairs.findIndex(({ record }) => record.id === selection.id)
1830
+ : -1;
3036
1831
  const initialIndex = rememberedIndex >= 0
3037
1832
  ? rememberedIndex
3038
1833
  : Math.max(0, Math.min(selection.index, pairs.length - 1));
@@ -3046,7 +1841,11 @@ Terse command-style prompts produce shallow, generic work.
3046
1841
  };
3047
1842
 
3048
1843
  const choice = await ctx.ui.custom<string | undefined>((_tui, _theme, _kb, done) => {
3049
- const list = new SelectList(options, Math.min(options.length, 10), getSelectListTheme());
1844
+ const list = new SelectList(
1845
+ options,
1846
+ Math.min(options.length, 10),
1847
+ getSelectListTheme(),
1848
+ );
3050
1849
  list.setSelectedIndex(initialIndex);
3051
1850
  const initialItem = options[initialIndex];
3052
1851
  if (initialItem) remember(initialItem.value);
@@ -3062,11 +1861,9 @@ Terse command-style prompts produce shallow, generic work.
3062
1861
  container.addChild(new Spacer(1));
3063
1862
  container.addChild(list);
3064
1863
  return {
3065
- render: (width: number) => container.render(width),
1864
+ render: (w: number) => container.render(w),
3066
1865
  invalidate: () => container.invalidate(),
3067
- handleInput: (data: string) => {
3068
- if (!isKeyRelease(data)) list.handleInput(data);
3069
- },
1866
+ handleInput: (data: string) => list.handleInput(data),
3070
1867
  };
3071
1868
  });
3072
1869
 
@@ -3075,64 +1872,90 @@ Terse command-style prompts produce shallow, generic work.
3075
1872
  }
3076
1873
 
3077
1874
  async function showRunningAgents(ctx: ExtensionCommandContext) {
3078
- const agents = manager.listAgents().filter(record =>
3079
- isTopLevelAgent(record) && (record.status === "running" || record.status === "queued"),
3080
- );
1875
+ const { active: agents } = splitAgentRecords(manager.listAgents(), ctx.cwd);
3081
1876
  if (agents.length === 0) {
3082
1877
  ctx.ui.notify("No agents.", "info");
3083
1878
  return;
3084
1879
  }
3085
- const pairs = agents.map(record => ({
3086
- record,
3087
- label: `${getDisplayName(record.type)} (${record.description}) · ${record.toolUses} tools · ${record.status} · ${formatDuration(record.startedAt, record.completedAt)}`,
3088
- }));
1880
+
1881
+ const pairs = agents.map((record) => {
1882
+ const dn = getDisplayName(record.type);
1883
+ const dur = formatDuration(record.startedAt, record.completedAt);
1884
+ return { record, label: `${dn} (${record.description}) · ${record.toolUses} tools · ${record.status} · ${dur}` };
1885
+ });
3089
1886
  makeUniqueAgentOptionLabels(pairs);
1887
+
3090
1888
  const record = await selectAgentFromReadOnlyList(ctx, "Running agents", pairs, runningAgentSelection);
3091
1889
  if (!record) return;
3092
- await viewAgentConversation(ctx, record);
1890
+
1891
+ await viewAgentConversation(ctx, record, "live");
1892
+ // Back-navigation: re-show the list at the previously selected agent.
3093
1893
  await showRunningAgents(ctx);
3094
1894
  }
3095
1895
 
3096
- async function showAgentHistory(ctx: ExtensionCommandContext): Promise<void> {
3097
- const { history } = splitAgentRecords(manager.listAgents().filter(isTopLevelAgent), ctx.cwd);
1896
+ async function showAgentHistory(ctx: ExtensionCommandContext) {
1897
+ const { history } = splitAgentRecords(manager.listAgents(), ctx.cwd);
3098
1898
  if (history.length === 0) {
3099
1899
  ctx.ui.notify("No agent history.", "info");
3100
1900
  return;
3101
1901
  }
3102
- const pairs = history.map(record => ({ record, label: formatAgentHistoryOption(record, Date.now()) }));
1902
+
1903
+ const pairs = history.map((record) => ({ record, label: formatAgentHistoryOption(record, Date.now()) }));
3103
1904
  makeUniqueAgentOptionLabels(pairs);
3104
1905
  const record = await selectAgentFromReadOnlyList(ctx, "Agent history", pairs, historyAgentSelection);
3105
1906
  if (!record) return;
3106
- await viewAgentConversation(ctx, record);
1907
+
1908
+ await viewAgentConversation(ctx, record, "history");
1909
+ // Back-navigation: re-show the list at the previously selected agent.
3107
1910
  await showAgentHistory(ctx);
3108
1911
  }
3109
1912
 
3110
- async function viewAgentConversation(ctx: ExtensionCommandContext, record: AgentRecord) {
1913
+ async function viewAgentConversation(
1914
+ ctx: ExtensionCommandContext,
1915
+ record: AgentRecord,
1916
+ mode: "live" | "history",
1917
+ ) {
1918
+ if (mode === "live" && !canOpenActiveAgent(record)) {
1919
+ ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no history available.`, "info");
1920
+ return;
1921
+ }
1922
+ if (mode === "history" && !canOpenAgentHistory(record, ctx.cwd)) {
1923
+ ctx.ui.notify("No agent history.", "info");
1924
+ return;
1925
+ }
1926
+
3111
1927
  const { ConversationViewer, VIEWPORT_HEIGHT_PCT, createStaticConversationSource } = await import("./ui/conversation-viewer.js");
3112
- const messages = record.transcriptPath ? readAgentHistory(ctx.cwd, record.transcriptPath) : undefined;
3113
- const session = record.session ?? (messages ? createStaticConversationSource(messages) : undefined);
1928
+ const session = mode === "live"
1929
+ ? record.session
1930
+ : (() => {
1931
+ const messages = record.transcriptPath
1932
+ ? readAgentHistory(ctx.cwd, record.transcriptPath)
1933
+ : undefined;
1934
+ return messages
1935
+ ? createStaticConversationSource(messages)
1936
+ : record.session
1937
+ ? createStaticConversationSource(record.session.messages)
1938
+ : undefined;
1939
+ })();
3114
1940
  if (!session) {
3115
- ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`, "info");
1941
+ ctx.ui.notify("No agent history.", "info");
3116
1942
  return;
3117
1943
  }
3118
- const isHistory = record.session === undefined;
3119
- const activity = agentActivity.get(record.id);
3120
1944
 
1945
+ const activity = agentActivity.get(record.id);
1946
+ const isLive = mode === "live";
3121
1947
  await ctx.ui.custom<undefined>(
3122
- (tui, theme, keybindings, done) => new ConversationViewer(
3123
- tui,
3124
- session,
3125
- record,
3126
- activity,
3127
- theme,
3128
- done,
3129
- isHistory ? undefined : () => {
3130
- if (manager.abort(record.id)) ctx.ui.notify(`Stopped "${record.description}".`, "info");
3131
- },
3132
- keybindings,
3133
- isHistory ? undefined : (message: string) => manager.steer(record.id, message),
3134
- { pi, ctx, readOnly: isHistory },
3135
- ),
1948
+ (tui, theme, keybindings, done) => {
1949
+ return new ConversationViewer(tui, session, record, activity, theme, done,
1950
+ isLive ? () => {
1951
+ if (manager.abort(record.id)) {
1952
+ ctx.ui.notify(`Stopped "${record.description}".`, "info");
1953
+ }
1954
+ } : undefined,
1955
+ keybindings,
1956
+ isLive ? (message: string) => manager.steer(record.id, message) : undefined,
1957
+ mode === "history" ? { pi, ctx, readOnly: true } : { pi, ctx });
1958
+ },
3136
1959
  {
3137
1960
  overlay: true,
3138
1961
  overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
@@ -3147,7 +1970,7 @@ Terse command-style prompts produce shallow, generic work.
3147
1970
  return;
3148
1971
  }
3149
1972
 
3150
- const file = locateAgentFile(name, cfg.sourcePath);
1973
+ const file = findAgentFile(name);
3151
1974
  const isDefault = cfg.isDefault === true;
3152
1975
  const disabled = cfg.enabled === false;
3153
1976
 
@@ -3222,7 +2045,29 @@ Terse command-style prompts produce shallow, generic work.
3222
2045
  if (!overwrite) return;
3223
2046
  }
3224
2047
 
3225
- const content = serializeAgentFile(cfg);
2048
+ // Build the .md file content
2049
+ const fmFields: string[] = [];
2050
+ fmFields.push(`description: ${JSON.stringify(cfg.description)}`);
2051
+ if (cfg.displayName) fmFields.push(`display_name: ${cfg.displayName}`);
2052
+ fmFields.push(`tools: ${cfg.builtinToolNames?.join(", ") || "all"}`);
2053
+ if (cfg.model) fmFields.push(`model: ${cfg.model}`);
2054
+ if (cfg.thinking) fmFields.push(`thinking: ${cfg.thinking}`);
2055
+ if (cfg.maxTurns) fmFields.push(`max_turns: ${cfg.maxTurns}`);
2056
+ fmFields.push(`prompt_mode: ${cfg.promptMode}`);
2057
+ if (cfg.extensions === false) fmFields.push("extensions: false");
2058
+ else if (Array.isArray(cfg.extensions)) fmFields.push(`extensions: ${cfg.extensions.join(", ")}`);
2059
+ if (cfg.excludeExtensions?.length) fmFields.push(`exclude_extensions: ${cfg.excludeExtensions.join(", ")}`);
2060
+ if (cfg.skills === false) fmFields.push("skills: false");
2061
+ else if (Array.isArray(cfg.skills)) fmFields.push(`skills: ${cfg.skills.join(", ")}`);
2062
+ if (cfg.disallowedTools?.length) fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`);
2063
+ if (cfg.inheritContext) fmFields.push("inherit_context: true");
2064
+ if (cfg.runInBackground) fmFields.push("run_in_background: true");
2065
+ if (cfg.outputTranscript === false) fmFields.push("output_transcript: false");
2066
+ if (cfg.isolated) fmFields.push("isolated: true");
2067
+ if (cfg.memory) fmFields.push(`memory: ${cfg.memory}`);
2068
+ if (cfg.isolation) fmFields.push(`isolation: ${cfg.isolation}`);
2069
+
2070
+ const content = `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`;
3226
2071
 
3227
2072
  const { writeFileSync } = await import("node:fs");
3228
2073
  writeFileSync(targetPath, content, "utf-8");
@@ -3232,21 +2077,15 @@ Terse command-style prompts produce shallow, generic work.
3232
2077
 
3233
2078
  /** Disable an agent: set enabled: false in its .md file, or create a stub for built-in defaults. */
3234
2079
  async function disableAgent(ctx: ExtensionCommandContext, name: string) {
3235
- const file = locateAgentFile(name, getAgentConfig(name)?.sourcePath);
2080
+ const file = findAgentFile(name);
3236
2081
  if (file) {
3237
2082
  // Existing file — set enabled: false in frontmatter (idempotent)
3238
2083
  const content = readFileSync(file.path, "utf-8");
3239
- const { content: updated, outcome } = disableInContent(content);
3240
- if (outcome === "already-disabled") {
2084
+ if (content.includes("\nenabled: false\n")) {
3241
2085
  ctx.ui.notify(`${name} is already disabled.`, "info");
3242
2086
  return;
3243
2087
  }
3244
- if (outcome === "no-frontmatter") {
3245
- // Nothing to edit — say so rather than rewriting the file unchanged and
3246
- // reporting success for a change that never happened.
3247
- ctx.ui.notify(`Cannot disable ${name}: ${file.path} has no frontmatter block.`, "error");
3248
- return;
3249
- }
2088
+ const updated = content.replace(/^---\n/, "---\nenabled: false\n");
3250
2089
  const { writeFileSync } = await import("node:fs");
3251
2090
  writeFileSync(file.path, updated, "utf-8");
3252
2091
  reloadCustomAgents();
@@ -3273,21 +2112,15 @@ Terse command-style prompts produce shallow, generic work.
3273
2112
 
3274
2113
  /** Enable a disabled agent by removing enabled: false from its frontmatter. */
3275
2114
  async function enableAgent(ctx: ExtensionCommandContext, name: string) {
3276
- const file = locateAgentFile(name, getAgentConfig(name)?.sourcePath);
2115
+ const file = findAgentFile(name);
3277
2116
  if (!file) return;
3278
2117
 
3279
2118
  const content = readFileSync(file.path, "utf-8");
3280
- const { content: updated, changed } = enableInContent(content);
3281
- if (!changed && !isEmptyStub(updated)) {
3282
- // The file carries no `enabled: false` to remove, so it was never disabled
3283
- // by us — reporting success here would hide a no-op.
3284
- ctx.ui.notify(`${name} is not disabled in ${file.path}.`, "info");
3285
- return;
3286
- }
2119
+ const updated = content.replace(/^(---\n)enabled: false\n/, "$1");
3287
2120
  const { writeFileSync } = await import("node:fs");
3288
2121
 
3289
2122
  // If the file was just a stub ("---\n---\n"), delete it to restore the built-in default
3290
- if (isEmptyStub(updated)) {
2123
+ if (updated.trim() === "---\n---" || updated.trim() === "---\n---\n") {
3291
2124
  unlinkSync(file.path);
3292
2125
  reloadCustomAgents();
3293
2126
  ctx.ui.notify(`Enabled ${name} (removed ${file.path})`, "info");
@@ -3346,7 +2179,6 @@ The file format is a markdown file with YAML frontmatter and a system prompt bod
3346
2179
  \`\`\`markdown
3347
2180
  ---
3348
2181
  description: <one-line description shown in UI>
3349
- color: <optional agent name badge color: red, blue, green, yellow, purple, orange, pink, cyan, an Agency Agents alias, or quoted "#RRGGBB">
3350
2182
  tools: <comma-separated built-in tools: read, bash, edit, write, grep, find, ls. Use "none" for no tools. Omit for all tools>
3351
2183
  model: <optional model as "provider/modelId", e.g. "anthropic/claude-haiku-4-5". Omit to inherit parent model>
3352
2184
  thinking: <optional thinking level: ${THINKING_LEVELS.join(", ")}. Omit to inherit>
@@ -3356,18 +2188,11 @@ extensions: <true (inherit all MCP/extension tools), false (none), or comma-sepa
3356
2188
  skills: <true (inherit all), false (none), or comma-separated skill names to preload into prompt. Default: true>
3357
2189
  disallowed_tools: <comma-separated tool names to block, even if otherwise available. Omit for none>
3358
2190
  inherit_context: <true to fork parent conversation into agent so it sees chat history. Default: false>
3359
- run_in_background: <pin this agent to background (true) or foreground (false). Omit to follow the backgroundByDefault setting, which is background>
2191
+ run_in_background: <true to run in background by default. Default: false>
3360
2192
  output_transcript: <false to write no transcript file or path for this agent. Independent of persist_session. Default: true>
3361
2193
  isolated: <true for no extension/MCP tools, only built-in tools. Default: false>
3362
- memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>${
3363
- // Offering the field on a project that turned worktrees off would bake a
3364
- // request that is refused at spawn time into a file that outlives the
3365
- // session — the #231 pathology (models fill the fields they are shown)
3366
- // one layer up. Built per invocation, so this read is live.
3367
- isWorktreeIsolationEnabled()
3368
- ? `\nisolation: <"worktree" to run in isolated git worktree; "off" to refuse one even when the caller asks. Omit for normal>`
3369
- : ""
3370
- }
2194
+ memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>
2195
+ isolation: <"worktree" to run in isolated git worktree. Omit for normal>
3371
2196
  ---
3372
2197
 
3373
2198
  <system prompt body — instructions for the agent>
@@ -3388,12 +2213,6 @@ Write the file using the write tool. Only write the file, nothing else.`;
3388
2213
  const { record } = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, {
3389
2214
  description: `Generate ${name} agent`,
3390
2215
  maxTurns: 5,
3391
- // Exempt from maxConcurrentForeground. This runs from a modal wizard, not
3392
- // a tool call: it passes no signal, and Esc in `ctx.ui` never reaches the
3393
- // manager — so a user waiting behind a full pool would have no way to
3394
- // cancel at all. It is also one human action that cannot fan out, which
3395
- // is what the limit exists to bound. It still counts once started.
3396
- bypassQueue: true,
3397
2216
  });
3398
2217
 
3399
2218
  if (record.status === "error") {
@@ -3446,12 +2265,13 @@ Write the file using the write tool. Only write the file, nothing else.`;
3446
2265
  ]);
3447
2266
  if (!modelChoice) return;
3448
2267
 
3449
- let model: string | undefined;
3450
- if (modelChoice === "haiku") model = "anthropic/claude-haiku-4-5";
3451
- else if (modelChoice === "sonnet") model = "anthropic/claude-sonnet-4-6";
3452
- else if (modelChoice === "opus") model = "anthropic/claude-opus-4-6";
2268
+ let modelLine = "";
2269
+ if (modelChoice === "haiku") modelLine = "\nmodel: anthropic/claude-haiku-4-5";
2270
+ else if (modelChoice === "sonnet") modelLine = "\nmodel: anthropic/claude-sonnet-4-6";
2271
+ else if (modelChoice === "opus") modelLine = "\nmodel: anthropic/claude-opus-4-6";
3453
2272
  else if (modelChoice === "custom...") {
3454
- model = (await ctx.ui.input("Model (provider/modelId)")) || undefined;
2273
+ const customModel = await ctx.ui.input("Model (provider/modelId)");
2274
+ if (customModel) modelLine = `\nmodel: ${customModel}`;
3455
2275
  }
3456
2276
 
3457
2277
  // 5. Thinking
@@ -3459,17 +2279,22 @@ Write the file using the write tool. Only write the file, nothing else.`;
3459
2279
  const thinkingChoice = await ctx.ui.select("Thinking level", ["inherit", ...THINKING_LEVELS]);
3460
2280
  if (!thinkingChoice) return;
3461
2281
 
2282
+ let thinkingLine = "";
2283
+ if (thinkingChoice !== "inherit") thinkingLine = `\nthinking: ${thinkingChoice}`;
2284
+
3462
2285
  // 6. System prompt
3463
2286
  const systemPrompt = await ctx.ui.editor("System prompt", "");
3464
2287
  if (systemPrompt === undefined) return;
3465
2288
 
3466
- const content = buildNewAgentFile({
3467
- description,
3468
- tools,
3469
- model,
3470
- thinking: thinkingChoice === "inherit" ? undefined : thinkingChoice,
3471
- systemPrompt,
3472
- });
2289
+ // Build the file
2290
+ const content = `---
2291
+ description: ${description}
2292
+ tools: ${tools}${modelLine}${thinkingLine}
2293
+ prompt_mode: replace
2294
+ ---
2295
+
2296
+ ${systemPrompt}
2297
+ `;
3473
2298
 
3474
2299
  mkdirSync(targetDir, { recursive: true });
3475
2300
  const targetPath = join(targetDir, `${name}.md`);
@@ -3485,86 +2310,30 @@ Write the file using the write tool. Only write the file, nothing else.`;
3485
2310
  ctx.ui.notify(`Created ${targetPath}`, "info");
3486
2311
  }
3487
2312
 
3488
- /**
3489
- * Every settings mutation writes this WHOLE object back to disk, so a field
3490
- * missing here is erased from the user's subagents.json the next time they
3491
- * toggle something unrelated. `SubagentsSettings` has every field optional,
3492
- * so a `: SubagentsSettings` return annotation would let a newly-added setting
3493
- * be forgotten here and still type-check. `satisfies` instead: it still checks
3494
- * each value's type and rejects a mistyped key, but leaves the return type
3495
- * inferred so `_NoMissingSettingsKeys` below can check completeness.
3496
- */
3497
- function snapshotSettings() {
2313
+ function snapshotSettings(): SubagentsSettings {
3498
2314
  return {
3499
2315
  maxConcurrent: manager.getMaxConcurrent(),
3500
- // 0 = unlimited, and the default — see SubagentsSettings.
3501
- maxConcurrentForeground: manager.getMaxConcurrentForeground(),
3502
2316
  // 0 = unlimited — per SubagentsSettings.defaultMaxTurns docstring and
3503
2317
  // normalizeMaxTurns() in agent-runner.ts (which maps 0 → undefined).
3504
2318
  defaultMaxTurns: getDefaultMaxTurns() ?? 0,
3505
2319
  graceTurns: getGraceTurns(),
3506
2320
  defaultJoinMode: getDefaultJoinMode(),
3507
- backgroundByDefault: getBackgroundByDefault(),
3508
2321
  schedulingEnabled: isSchedulingEnabled(),
3509
2322
  scopeModels: isScopeModelsEnabled(),
3510
- strictAgentFiles,
3511
2323
  disableDefaultAgents: isDefaultsDisabled(),
3512
2324
  toolDescriptionMode: getToolDescriptionMode(),
3513
- agentMentions: getAgentMentionMode(),
3514
- rememberAgents: getRememberAgents(),
3515
2325
  widgetMode: getWidgetMode(),
3516
2326
  outputTranscript: getOutputTranscriptDefault(),
3517
- worktreeIsolation: isWorktreeIsolationEnabled(),
3518
- // The user's answer, not the effective one. A stand-down for another
3519
- // extension's workflow tool is scoped to the session it was detected in;
3520
- // writing it here would let an unrelated settings change three menus away
3521
- // freeze it into the file as an explicit `false`, which then survives
3522
- // uninstalling the extension it was deferring to. undefined is dropped by
3523
- // JSON.stringify, so unset stays unset — same reasoning as
3524
- // `fallbackSubagent` below.
3525
- workflowsEnabled: isWorkflowsPinned() ? isWorkflowsEnabled() : undefined,
3526
- maxSubagentDepth: getMaxSubagentDepth(),
3527
- // Deliberately NOT `?? "general-purpose"`: every settings change writes the
3528
- // whole snapshot, and materializing the implicit default would turn it into
3529
- // explicit configuration — which then fails loudly if general-purpose later
3530
- // goes away. undefined is dropped by JSON.stringify.
3531
- fallbackSubagent: getFallbackSubagent(),
3532
- reportUsage: isReportUsageEnabled(),
3533
- showCost: isShowCostEnabled(),
3534
- showModel: isShowModelEnabled(),
3535
- viewerMarkdown: getViewerMarkdown(),
3536
- } satisfies SubagentsSettings;
2327
+ };
3537
2328
  }
3538
2329
 
3539
- // Compile-time completeness guard for snapshotSettings(). If a field is added
3540
- // to SubagentsSettings and not mirrored above, this Exclude is non-empty and
3541
- // fails to satisfy `never` — turning a silent settings-erasure bug into a
3542
- // typecheck error. `npm run typecheck` runs in CI.
3543
- type _NoMissingSettingsKeys =
3544
- Exclude<keyof SubagentsSettings, keyof ReturnType<typeof snapshotSettings>> extends never
3545
- ? true
3546
- : ["snapshotSettings() is missing a SubagentsSettings key"];
3547
- const _settingsSnapshotIsComplete: _NoMissingSettingsKeys = true;
3548
- void _settingsSnapshotIsComplete;
3549
-
3550
- const NUMERIC_IDS = new Set([
3551
- "maxConcurrent", "maxConcurrentForeground", "defaultMaxTurns", "graceTurns", "maxSubagentDepth",
3552
- ]);
2330
+ const NUMERIC_IDS = new Set(["maxConcurrent", "defaultMaxTurns", "graceTurns"]);
3553
2331
 
3554
2332
  async function showSettings(ctx: ExtensionCommandContext) {
3555
2333
  function buildItems(): SettingItem[] {
3556
2334
  const mc = manager.getMaxConcurrent();
3557
- const mcf = manager.getMaxConcurrentForeground();
3558
2335
  const dmt = getDefaultMaxTurns() ?? 0;
3559
2336
  const gt = getGraceTurns();
3560
- const msd = getMaxSubagentDepth();
3561
- // Label what unset actually does — it targets general-purpose even when
3562
- // that is unregistered (the permissive hardcoded tier), so showing "none"
3563
- // there would advertise strict dispatch for the most permissive state.
3564
- // `values` still offers only resolvable targets, so the user cannot
3565
- // persist a fallback that would hard-error on every dispatch.
3566
- const fallbackValue = getFallbackSubagent() ?? "general-purpose";
3567
- const fallbackValues = [...new Set([...getAvailableTypes(), NO_FALLBACK])];
3568
2337
 
3569
2338
  return [
3570
2339
  {
@@ -3574,13 +2343,6 @@ Write the file using the write tool. Only write the file, nothing else.`;
3574
2343
  currentValue: String(mc),
3575
2344
  values: [String(mc)],
3576
2345
  },
3577
- {
3578
- id: "maxConcurrentForeground",
3579
- label: "Max foreground concurrency",
3580
- description: "Max concurrent foreground (blocking) agents (0 = unlimited, Enter to type)",
3581
- currentValue: String(mcf),
3582
- values: [String(mcf)],
3583
- },
3584
2346
  {
3585
2347
  id: "defaultMaxTurns",
3586
2348
  label: "Default max turns",
@@ -3595,13 +2357,6 @@ Write the file using the write tool. Only write the file, nothing else.`;
3595
2357
  currentValue: String(gt),
3596
2358
  values: [String(gt)],
3597
2359
  },
3598
- {
3599
- id: "maxSubagentDepth",
3600
- label: "Nested depth",
3601
- description: "Hard cap on nested delegation — main is 0, its subagents 1 (0/1 = nesting off, Enter to type)",
3602
- currentValue: String(msd),
3603
- values: [String(msd)],
3604
- },
3605
2360
  {
3606
2361
  id: "joinMode",
3607
2362
  label: "Join mode",
@@ -3609,13 +2364,6 @@ Write the file using the write tool. Only write the file, nothing else.`;
3609
2364
  currentValue: getDefaultJoinMode(),
3610
2365
  values: ["smart", "async", "group"],
3611
2366
  },
3612
- {
3613
- id: "backgroundByDefault",
3614
- label: "Background by default",
3615
- description: "An Agent call that doesn't say runs detached (off = blocks the turn and returns inline)",
3616
- currentValue: getBackgroundByDefault() ? "on" : "off",
3617
- values: ["on", "off"],
3618
- },
3619
2367
  {
3620
2368
  id: "schedulingEnabled",
3621
2369
  label: "Scheduling",
@@ -3623,15 +2371,6 @@ Write the file using the write tool. Only write the file, nothing else.`;
3623
2371
  currentValue: isSchedulingEnabled() ? "on" : "off",
3624
2372
  values: ["on", "off"],
3625
2373
  },
3626
- {
3627
- id: "workflowsEnabled",
3628
- label: "Workflows",
3629
- description:
3630
- "Scripted workflows, on unless another extension provides a workflow tool "
3631
- + "(off keeps the SubagentWorkflow tool out of the tool spec; applies on next pi session)",
3632
- currentValue: isWorkflowsEnabled() ? "on" : "off",
3633
- values: ["on", "off"],
3634
- },
3635
2374
  {
3636
2375
  id: "scopeModels",
3637
2376
  label: "Scope models",
@@ -3639,13 +2378,6 @@ Write the file using the write tool. Only write the file, nothing else.`;
3639
2378
  currentValue: isScopeModelsEnabled() ? "on" : "off",
3640
2379
  values: ["on", "off"],
3641
2380
  },
3642
- {
3643
- id: "strictAgentFiles",
3644
- label: "Strict agent files",
3645
- description: "Fail startup on an unreadable/unparseable agent .md instead of skipping it with a warning",
3646
- currentValue: strictAgentFiles ? "on" : "off",
3647
- values: ["on", "off"],
3648
- },
3649
2381
  {
3650
2382
  id: "disableDefaultAgents",
3651
2383
  label: "Disable defaults",
@@ -3653,13 +2385,6 @@ Write the file using the write tool. Only write the file, nothing else.`;
3653
2385
  currentValue: isDefaultsDisabled() ? "on" : "off",
3654
2386
  values: ["on", "off"],
3655
2387
  },
3656
- {
3657
- id: "fallbackSubagent",
3658
- label: "Fallback agent",
3659
- description: `Agent used when subagent_type is unknown, disabled, or ambiguous; "${NO_FALLBACK}" rejects the call instead (strict dispatch)`,
3660
- currentValue: fallbackValue,
3661
- values: fallbackValues,
3662
- },
3663
2388
  {
3664
2389
  id: "outputTranscript",
3665
2390
  label: "Output transcript",
@@ -3667,60 +2392,6 @@ Write the file using the write tool. Only write the file, nothing else.`;
3667
2392
  currentValue: getOutputTranscriptDefault() ? "on" : "off",
3668
2393
  values: ["on", "off"],
3669
2394
  },
3670
- {
3671
- id: "worktreeIsolation",
3672
- label: "Worktree isolation",
3673
- description:
3674
- "Allow isolation: worktree to copy the repo. Off refuses worktrees on every path immediately — for repos where a copy costs too much time or disk — and drops the `isolation` param from the Agent tool spec on next pi session.",
3675
- currentValue: isWorktreeIsolationEnabled() ? "on" : "off",
3676
- values: ["on", "off"],
3677
- },
3678
- {
3679
- id: "reportUsage",
3680
- label: "Report usage to session",
3681
- description:
3682
- "Add subagent tokens and cost to this session's own totals, so pi's footer and /cost stop reading a delegating session as nearly free. Reported on the next tool result (agents that finish in the background are counted on the one after). Context-window % is unaffected.",
3683
- currentValue: isReportUsageEnabled() ? "on" : "off",
3684
- values: ["on", "off"],
3685
- },
3686
- {
3687
- id: "showCost",
3688
- label: "Show cost",
3689
- description:
3690
- "Show an estimated `~$0.0042` beside subagent token counts in the widget, results and notifications. Priced by pi from the model's rates — omitted entirely for a model it has no rates for.",
3691
- currentValue: isShowCostEnabled() ? "on" : "off",
3692
- values: ["on", "off"],
3693
- },
3694
- {
3695
- id: "showModel",
3696
- label: "Show model",
3697
- description:
3698
- "Name the model driving each agent, and the thinking level it is running at, on the widget's running rows. The Agent tool result and the conversation viewer show the pair either way — this adds it to the widget, where the row is already dense.",
3699
- currentValue: isShowModelEnabled() ? "on" : "off",
3700
- values: ["on", "off"],
3701
- },
3702
- {
3703
- id: "viewerMarkdown",
3704
- label: "Viewer markdown",
3705
- description:
3706
- "How much of the conversation viewer renders as Markdown. assistant = assistant text only (default); all = tool results too, for tools that emit Markdown — accepting that a Markdown pass over a diff or a log eats `#` comments, swallows a `---` line and re-fences indented output; off = everything verbatim. `m` in the viewer cycles the same setting (footer: raw / md / md+).",
3707
- currentValue: getViewerMarkdown(),
3708
- values: ["off", "assistant", "all"],
3709
- },
3710
- {
3711
- id: "agentMentions",
3712
- label: "Agent mentions",
3713
- description: "Route `@handle message` at the prompt to that agent. model = an off-screen clone of this conversation calls the Agent tool, so the agent gets a context-written prompt, a transcript and per-tool detail, and the chat stays clean; direct = started here from your text, no model call. Messaging and resuming are direct either way.",
3714
- currentValue: getAgentMentionMode(),
3715
- values: ["model", "direct", "off"],
3716
- },
3717
- {
3718
- id: "rememberAgents",
3719
- label: "Remember agents",
3720
- description: "Persist subagent sessions so `@handle` can resume one long after it finished (they also appear in /resume)",
3721
- currentValue: getRememberAgents() ? "on" : "off",
3722
- values: ["on", "off"],
3723
- },
3724
2395
  {
3725
2396
  id: "widgetMode",
3726
2397
  label: "Widget",
@@ -3745,15 +2416,6 @@ Write the file using the write tool. Only write the file, nothing else.`;
3745
2416
  manager.setMaxConcurrent(n);
3746
2417
  notifyApplied(ctx, `Max concurrency set to ${n}`);
3747
2418
  }
3748
- } else if (id === "maxConcurrentForeground") {
3749
- // 0 is meaningful here, unlike maxConcurrent above: it means unlimited.
3750
- const n = parseInt(value, 10);
3751
- if (n >= 0) {
3752
- manager.setMaxConcurrentForeground(n);
3753
- notifyApplied(ctx, n === 0
3754
- ? "Max foreground concurrency set to unlimited"
3755
- : `Max foreground concurrency set to ${n}`);
3756
- }
3757
2419
  } else if (id === "defaultMaxTurns") {
3758
2420
  const n = parseInt(value, 10);
3759
2421
  if (n === 0) {
@@ -3769,29 +2431,9 @@ Write the file using the write tool. Only write the file, nothing else.`;
3769
2431
  setGraceTurns(n);
3770
2432
  notifyApplied(ctx, `Grace turns set to ${n}`);
3771
2433
  }
3772
- } else if (id === "maxSubagentDepth") {
3773
- const n = parseInt(value, 10);
3774
- if (n >= 0) {
3775
- setMaxSubagentDepth(n);
3776
- notifyApplied(
3777
- ctx,
3778
- n <= 1
3779
- ? "Nested delegation disabled"
3780
- : `Nested depth set to ${n}. Applies to agents started from now on.`,
3781
- );
3782
- }
3783
2434
  } else if (id === "joinMode") {
3784
2435
  setDefaultJoinMode(value as JoinMode);
3785
2436
  notifyApplied(ctx, `Default join mode set to ${value}`);
3786
- } else if (id === "backgroundByDefault") {
3787
- const enabled = value === "on";
3788
- setBackgroundByDefault(enabled);
3789
- notifyApplied(
3790
- ctx,
3791
- enabled
3792
- ? "Agent calls run in the background unless they pass run_in_background: false"
3793
- : "Agent calls block and return inline unless they pass run_in_background: true",
3794
- );
3795
2437
  } else if (id === "schedulingEnabled") {
3796
2438
  const enabled = value === "on";
3797
2439
  if (enabled === isSchedulingEnabled()) {
@@ -3804,91 +2446,21 @@ Write the file using the write tool. Only write the file, nothing else.`;
3804
2446
  `Scheduling ${enabled ? "enabled" : "disabled"}. Tool spec change takes effect on next pi session.`,
3805
2447
  );
3806
2448
  }
3807
- } else if (id === "workflowsEnabled") {
3808
- const enabled = value === "on";
3809
- if (enabled === isWorkflowsEnabled()) {
3810
- ctx.ui.notify(`Workflows already ${enabled ? "enabled" : "disabled"}.`, "info");
3811
- } else {
3812
- setWorkflowsEnabled(enabled);
3813
- // Runs already in flight keep going: the switch governs whether the
3814
- // tool is offered, and killing live agents on a settings toggle would
3815
- // lose work the user never asked to discard.
3816
- notifyApplied(
3817
- ctx,
3818
- `Workflows ${enabled ? "enabled" : "disabled"}. Tool spec change takes effect on next pi session.`,
3819
- );
3820
- }
3821
2449
  } else if (id === "scopeModels") {
3822
2450
  const enabled = value === "on";
3823
2451
  setScopeModelsEnabled(enabled);
3824
2452
  notifyApplied(ctx, `Scope models ${enabled ? "enabled" : "disabled"}`);
3825
- } else if (id === "strictAgentFiles") {
3826
- const enabled = value === "on";
3827
- strictAgentFiles = enabled;
3828
- notifyApplied(ctx, `Strict agent files ${enabled ? "enabled" : "disabled"}. Takes effect on next pi session.`);
3829
2453
  } else if (id === "disableDefaultAgents") {
3830
2454
  const enabled = value === "on";
3831
2455
  setDisableDefaultAgents(enabled);
3832
2456
  notifyApplied(ctx, `Default agents ${enabled ? "disabled" : "enabled"}. Tool spec change takes effect on next pi session.`);
3833
- } else if (id === "fallbackSubagent") {
3834
- setFallbackSubagent(value);
3835
- notifyApplied(
3836
- ctx,
3837
- value === NO_FALLBACK
3838
- ? "Unknown or disabled agent types will now be rejected"
3839
- : `Unknown agent types will fall back to ${value}`,
3840
- );
3841
2457
  } else if (id === "outputTranscript") {
3842
2458
  const enabled = value === "on";
3843
- setOutputTranscriptDefault(enabled);
2459
+ setOutputTranscript(enabled);
3844
2460
  notifyApplied(ctx, `Output transcript ${enabled ? "enabled" : "disabled"} by default`);
3845
- } else if (id === "worktreeIsolation") {
3846
- const enabled = value === "on";
3847
- setWorktreeIsolationEnabled(enabled);
3848
- // The refusal is live, but the tool schema is built at registration, so
3849
- // the isolation parameter only appears/disappears next session.
3850
- notifyApplied(
3851
- ctx,
3852
- `Worktree isolation ${enabled ? "enabled" : "disabled"}. Tool parameter updates on next pi session.`,
3853
- );
3854
2461
  } else if (id === "toolDescriptionMode") {
3855
2462
  setToolDescriptionMode(value as ToolDescriptionMode);
3856
2463
  notifyApplied(ctx, `Tool description set to ${value}. Takes effect on next pi session.`);
3857
- } else if (id === "reportUsage") {
3858
- const enabled = value === "on";
3859
- setReportUsage(enabled);
3860
- notifyApplied(
3861
- ctx,
3862
- enabled
3863
- ? "Subagent usage now counted in this session's totals"
3864
- : "Subagent usage no longer counted in this session's totals",
3865
- );
3866
- } else if (id === "showCost") {
3867
- const enabled = value === "on";
3868
- setShowCost(enabled);
3869
- notifyApplied(ctx, `Cost display ${enabled ? "enabled" : "disabled"}`);
3870
- } else if (id === "showModel") {
3871
- const enabled = value === "on";
3872
- setShowModel(enabled);
3873
- notifyApplied(ctx, `Model display ${enabled ? "enabled" : "disabled"}`);
3874
- } else if (id === "viewerMarkdown") {
3875
- setViewerMarkdown(value as ViewerMarkdownMode);
3876
- notifyApplied(ctx, `Viewer markdown set to ${value}`);
3877
- } else if (id === "agentMentions") {
3878
- const mode = value as AgentMentionMode;
3879
- setAgentMentionMode(mode);
3880
- notifyApplied(
3881
- ctx,
3882
- mode === "off"
3883
- ? "Agent mentions disabled"
3884
- : mode === "model"
3885
- ? "Agent mentions on — a conversation clone starts a mentioned agent off-screen"
3886
- : "Agent mentions on — a mentioned agent starts here, with no model call",
3887
- );
3888
- } else if (id === "rememberAgents") {
3889
- const enabled = value === "on";
3890
- setRememberAgents(enabled);
3891
- notifyApplied(ctx, `Remember agents ${enabled ? "enabled" : "disabled"}`);
3892
2464
  } else if (id === "widgetMode") {
3893
2465
  setWidgetMode(value as WidgetMode);
3894
2466
  notifyApplied(ctx, `Widget set to ${value}`);
@@ -3943,23 +2515,15 @@ Write the file using the write tool. Only write the file, nothing else.`;
3943
2515
  if (result && NUMERIC_IDS.has(result)) {
3944
2516
  const current = result === "maxConcurrent"
3945
2517
  ? String(manager.getMaxConcurrent())
3946
- : result === "maxConcurrentForeground"
3947
- ? String(manager.getMaxConcurrentForeground())
3948
- : result === "defaultMaxTurns"
3949
- ? String(getDefaultMaxTurns() ?? 0)
3950
- : result === "maxSubagentDepth"
3951
- ? String(getMaxSubagentDepth())
3952
- : String(getGraceTurns());
2518
+ : result === "defaultMaxTurns"
2519
+ ? String(getDefaultMaxTurns() ?? 0)
2520
+ : String(getGraceTurns());
3953
2521
 
3954
2522
  const label = result === "maxConcurrent"
3955
2523
  ? "Max concurrency (1+)"
3956
- : result === "maxConcurrentForeground"
3957
- ? "Max foreground concurrency (0 = unlimited)"
3958
- : result === "defaultMaxTurns"
3959
- ? "Default max turns (0 = unlimited)"
3960
- : result === "maxSubagentDepth"
3961
- ? "Nested depth (0/1 = nesting off)"
3962
- : "Grace turns (1+)";
2524
+ : result === "defaultMaxTurns"
2525
+ ? "Default max turns (0 = unlimited)"
2526
+ : "Grace turns (1+)";
3963
2527
 
3964
2528
  // Loop until user enters a valid integer or cancels (Esc / null).
3965
2529
  // Silently trims whitespace; rejects non-numeric input by re-prompting.
@@ -3978,6 +2542,10 @@ Write the file using the write tool. Only write the file, nothing else.`;
3978
2542
  }
3979
2543
  }
3980
2544
 
2545
+ // Persist the current snapshot, emit `subagents:settings_changed`, and surface
2546
+ // the right toast. Successful saves show info; persistence failures downgrade
2547
+ // to warning so users aren't silently reverted on restart. Event fires regardless
2548
+ // of outcome so listeners see the in-memory change.
3981
2549
  function notifyApplied(ctx: ExtensionCommandContext, successMsg: string) {
3982
2550
  const { message, level } = saveAndEmitChanged(
3983
2551
  snapshotSettings(),
@@ -3991,12 +2559,4 @@ Write the file using the write tool. Only write the file, nothing else.`;
3991
2559
  description: "Manage agents",
3992
2560
  handler: async (_args, ctx) => { await showAgentsMenu(ctx); },
3993
2561
  });
3994
-
3995
- /** Dependencies shared by `/agents → Workflows` and its inspector. */
3996
- const workflowMenuDeps: WorkflowMenuDeps = {
3997
- tasks: workflowTasks,
3998
- getRecord: id => manager.getRecord(id),
3999
- viewAgentConversation,
4000
- };
4001
-
4002
2562
  }