@esso0428/pi-subagents 0.17.16 → 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 +6 -0
  2. package/ROADMAP.md +87 -0
  3. package/docs/post-0.17.6-agents-roster-viewer-spec.md +42 -0
  4. package/docs/post-0.17.6-durable-history-spec.md +41 -0
  5. package/docs/post-0.17.6-feature-specs.md +50 -0
  6. package/docs/post-0.17.6-nested-agents-spec.md +38 -0
  7. package/docs/post-0.17.6-recovery-shutdown-spec.md +46 -0
  8. package/docs/post-0.17.6-ui-latency-baseline-spec.md +46 -0
  9. package/docs/post-0.17.6-workflow-rpc-lifecycle-spec.md +54 -0
  10. package/package.json +1 -1
  11. package/src/agent-history.ts +2 -54
  12. package/src/agent-manager.ts +406 -1288
  13. package/src/agent-runner.ts +27 -251
  14. package/src/agent-types.ts +32 -188
  15. package/src/cross-extension-rpc.ts +20 -96
  16. package/src/custom-agents.ts +13 -170
  17. package/src/index.ts +480 -1920
  18. package/src/invocation-config.ts +3 -118
  19. package/src/model-resolver.ts +0 -18
  20. package/src/output-file.ts +6 -61
  21. package/src/prompts.ts +2 -45
  22. package/src/schedule.ts +14 -35
  23. package/src/settings.ts +2 -301
  24. package/src/status-note.ts +1 -66
  25. package/src/types.ts +10 -177
  26. package/src/ui/agent-widget.ts +57 -278
  27. package/src/ui/conversation-blocks.ts +0 -6
  28. package/src/ui/conversation-timeline.ts +25 -139
  29. package/src/ui/conversation-viewer.ts +48 -212
  30. package/src/ui/schedule-menu.ts +8 -9
  31. package/src/usage.ts +2 -109
  32. package/src/worktree.ts +55 -69
  33. package/dist/abortable.d.ts +0 -13
  34. package/dist/abortable.d.ts.map +0 -1
  35. package/dist/abortable.js +0 -43
  36. package/dist/abortable.js.map +0 -1
  37. package/dist/agent-color.d.ts +0 -36
  38. package/dist/agent-color.d.ts.map +0 -1
  39. package/dist/agent-color.js +0 -124
  40. package/dist/agent-color.js.map +0 -1
  41. package/dist/agent-file-toggle.d.ts +0 -126
  42. package/dist/agent-file-toggle.d.ts.map +0 -1
  43. package/dist/agent-file-toggle.js +0 -259
  44. package/dist/agent-file-toggle.js.map +0 -1
  45. package/dist/agent-history-list.d.ts +0 -19
  46. package/dist/agent-history-list.d.ts.map +0 -1
  47. package/dist/agent-history-list.js +0 -69
  48. package/dist/agent-history-list.js.map +0 -1
  49. package/dist/agent-history.d.ts +0 -35
  50. package/dist/agent-history.d.ts.map +0 -1
  51. package/dist/agent-history.js +0 -188
  52. package/dist/agent-history.js.map +0 -1
  53. package/dist/agent-manager.d.ts +0 -503
  54. package/dist/agent-manager.d.ts.map +0 -1
  55. package/dist/agent-manager.js +0 -1572
  56. package/dist/agent-manager.js.map +0 -1
  57. package/dist/agent-recovery.d.ts +0 -37
  58. package/dist/agent-recovery.d.ts.map +0 -1
  59. package/dist/agent-recovery.js +0 -171
  60. package/dist/agent-recovery.js.map +0 -1
  61. package/dist/agent-runner.d.ts +0 -303
  62. package/dist/agent-runner.d.ts.map +0 -1
  63. package/dist/agent-runner.js +0 -1003
  64. package/dist/agent-runner.js.map +0 -1
  65. package/dist/agent-types.d.ts +0 -120
  66. package/dist/agent-types.d.ts.map +0 -1
  67. package/dist/agent-types.js +0 -301
  68. package/dist/agent-types.js.map +0 -1
  69. package/dist/child-context.d.ts +0 -3
  70. package/dist/child-context.d.ts.map +0 -1
  71. package/dist/child-context.js +0 -13
  72. package/dist/child-context.js.map +0 -1
  73. package/dist/context.d.ts +0 -13
  74. package/dist/context.d.ts.map +0 -1
  75. package/dist/context.js +0 -57
  76. package/dist/context.js.map +0 -1
  77. package/dist/cross-extension-rpc.d.ts +0 -67
  78. package/dist/cross-extension-rpc.d.ts.map +0 -1
  79. package/dist/cross-extension-rpc.js +0 -139
  80. package/dist/cross-extension-rpc.js.map +0 -1
  81. package/dist/custom-agents.d.ts +0 -55
  82. package/dist/custom-agents.d.ts.map +0 -1
  83. package/dist/custom-agents.js +0 -309
  84. package/dist/custom-agents.js.map +0 -1
  85. package/dist/default-agents.d.ts +0 -8
  86. package/dist/default-agents.d.ts.map +0 -1
  87. package/dist/default-agents.js +0 -123
  88. package/dist/default-agents.js.map +0 -1
  89. package/dist/enabled-models.d.ts +0 -50
  90. package/dist/enabled-models.d.ts.map +0 -1
  91. package/dist/enabled-models.js +0 -146
  92. package/dist/enabled-models.js.map +0 -1
  93. package/dist/env.d.ts +0 -7
  94. package/dist/env.d.ts.map +0 -1
  95. package/dist/env.js +0 -29
  96. package/dist/env.js.map +0 -1
  97. package/dist/group-join.d.ts +0 -33
  98. package/dist/group-join.d.ts.map +0 -1
  99. package/dist/group-join.js +0 -117
  100. package/dist/group-join.js.map +0 -1
  101. package/dist/index.d.ts +0 -51
  102. package/dist/index.d.ts.map +0 -1
  103. package/dist/index.js +0 -3687
  104. package/dist/index.js.map +0 -1
  105. package/dist/invocation-config.d.ts +0 -108
  106. package/dist/invocation-config.d.ts.map +0 -1
  107. package/dist/invocation-config.js +0 -84
  108. package/dist/invocation-config.js.map +0 -1
  109. package/dist/memory.d.ts +0 -54
  110. package/dist/memory.d.ts.map +0 -1
  111. package/dist/memory.js +0 -166
  112. package/dist/memory.js.map +0 -1
  113. package/dist/mention-clone.d.ts +0 -88
  114. package/dist/mention-clone.d.ts.map +0 -1
  115. package/dist/mention-clone.js +0 -154
  116. package/dist/mention-clone.js.map +0 -1
  117. package/dist/mention.d.ts +0 -82
  118. package/dist/mention.d.ts.map +0 -1
  119. package/dist/mention.js +0 -132
  120. package/dist/mention.js.map +0 -1
  121. package/dist/model-resolver.d.ts +0 -37
  122. package/dist/model-resolver.d.ts.map +0 -1
  123. package/dist/model-resolver.js +0 -96
  124. package/dist/model-resolver.js.map +0 -1
  125. package/dist/model-scope.d.ts +0 -50
  126. package/dist/model-scope.d.ts.map +0 -1
  127. package/dist/model-scope.js +0 -49
  128. package/dist/model-scope.js.map +0 -1
  129. package/dist/nested-tools.d.ts +0 -57
  130. package/dist/nested-tools.d.ts.map +0 -1
  131. package/dist/nested-tools.js +0 -301
  132. package/dist/nested-tools.js.map +0 -1
  133. package/dist/nico-overrides.d.ts +0 -54
  134. package/dist/nico-overrides.d.ts.map +0 -1
  135. package/dist/nico-overrides.js +0 -170
  136. package/dist/nico-overrides.js.map +0 -1
  137. package/dist/output-file.d.ts +0 -44
  138. package/dist/output-file.d.ts.map +0 -1
  139. package/dist/output-file.js +0 -156
  140. package/dist/output-file.js.map +0 -1
  141. package/dist/prompts.d.ts +0 -56
  142. package/dist/prompts.d.ts.map +0 -1
  143. package/dist/prompts.js +0 -92
  144. package/dist/prompts.js.map +0 -1
  145. package/dist/schedule-store.d.ts +0 -39
  146. package/dist/schedule-store.d.ts.map +0 -1
  147. package/dist/schedule-store.js +0 -156
  148. package/dist/schedule-store.js.map +0 -1
  149. package/dist/schedule.d.ts +0 -110
  150. package/dist/schedule.d.ts.map +0 -1
  151. package/dist/schedule.js +0 -360
  152. package/dist/schedule.js.map +0 -1
  153. package/dist/settings.d.ts +0 -354
  154. package/dist/settings.d.ts.map +0 -1
  155. package/dist/settings.js +0 -247
  156. package/dist/settings.js.map +0 -1
  157. package/dist/skill-loader.d.ts +0 -25
  158. package/dist/skill-loader.d.ts.map +0 -1
  159. package/dist/skill-loader.js +0 -94
  160. package/dist/skill-loader.js.map +0 -1
  161. package/dist/status-note.d.ts +0 -62
  162. package/dist/status-note.d.ts.map +0 -1
  163. package/dist/status-note.js +0 -86
  164. package/dist/status-note.js.map +0 -1
  165. package/dist/structured-output.d.ts +0 -62
  166. package/dist/structured-output.d.ts.map +0 -1
  167. package/dist/structured-output.js +0 -113
  168. package/dist/structured-output.js.map +0 -1
  169. package/dist/types.d.ts +0 -372
  170. package/dist/types.d.ts.map +0 -1
  171. package/dist/types.js +0 -6
  172. package/dist/types.js.map +0 -1
  173. package/dist/ui/agent-mention.d.ts +0 -83
  174. package/dist/ui/agent-mention.d.ts.map +0 -1
  175. package/dist/ui/agent-mention.js +0 -188
  176. package/dist/ui/agent-mention.js.map +0 -1
  177. package/dist/ui/agent-widget.d.ts +0 -241
  178. package/dist/ui/agent-widget.d.ts.map +0 -1
  179. package/dist/ui/agent-widget.js +0 -992
  180. package/dist/ui/agent-widget.js.map +0 -1
  181. package/dist/ui/ccstyle/diff/ansi-utils.d.ts +0 -11
  182. package/dist/ui/ccstyle/diff/ansi-utils.d.ts.map +0 -1
  183. package/dist/ui/ccstyle/diff/ansi-utils.js +0 -145
  184. package/dist/ui/ccstyle/diff/ansi-utils.js.map +0 -1
  185. package/dist/ui/ccstyle/diff/diff-presentation.d.ts +0 -12
  186. package/dist/ui/ccstyle/diff/diff-presentation.d.ts.map +0 -1
  187. package/dist/ui/ccstyle/diff/diff-presentation.js +0 -48
  188. package/dist/ui/ccstyle/diff/diff-presentation.js.map +0 -1
  189. package/dist/ui/ccstyle/diff/diff-renderer.d.ts +0 -46
  190. package/dist/ui/ccstyle/diff/diff-renderer.d.ts.map +0 -1
  191. package/dist/ui/ccstyle/diff/diff-renderer.js +0 -2049
  192. package/dist/ui/ccstyle/diff/diff-renderer.js.map +0 -1
  193. package/dist/ui/ccstyle/diff/line-width-safety.d.ts +0 -12
  194. package/dist/ui/ccstyle/diff/line-width-safety.d.ts.map +0 -1
  195. package/dist/ui/ccstyle/diff/line-width-safety.js +0 -58
  196. package/dist/ui/ccstyle/diff/line-width-safety.js.map +0 -1
  197. package/dist/ui/ccstyle/diff/render-utils.d.ts +0 -6
  198. package/dist/ui/ccstyle/diff/render-utils.d.ts.map +0 -1
  199. package/dist/ui/ccstyle/diff/render-utils.js +0 -25
  200. package/dist/ui/ccstyle/diff/render-utils.js.map +0 -1
  201. package/dist/ui/ccstyle/diff/shiki-highlight.d.ts +0 -19
  202. package/dist/ui/ccstyle/diff/shiki-highlight.d.ts.map +0 -1
  203. package/dist/ui/ccstyle/diff/shiki-highlight.js +0 -85
  204. package/dist/ui/ccstyle/diff/shiki-highlight.js.map +0 -1
  205. package/dist/ui/ccstyle/diff/types.d.ts +0 -22
  206. package/dist/ui/ccstyle/diff/types.d.ts.map +0 -1
  207. package/dist/ui/ccstyle/diff/types.js +0 -10
  208. package/dist/ui/ccstyle/diff/types.js.map +0 -1
  209. package/dist/ui/ccstyle/diff/write-display-utils.d.ts +0 -2
  210. package/dist/ui/ccstyle/diff/write-display-utils.d.ts.map +0 -1
  211. package/dist/ui/ccstyle/diff/write-display-utils.js +0 -12
  212. package/dist/ui/ccstyle/diff/write-display-utils.js.map +0 -1
  213. package/dist/ui/ccstyle/tool-renderer.d.ts +0 -17
  214. package/dist/ui/ccstyle/tool-renderer.d.ts.map +0 -1
  215. package/dist/ui/ccstyle/tool-renderer.js +0 -74
  216. package/dist/ui/ccstyle/tool-renderer.js.map +0 -1
  217. package/dist/ui/ccstyle/tool-result.d.ts +0 -44
  218. package/dist/ui/ccstyle/tool-result.d.ts.map +0 -1
  219. package/dist/ui/ccstyle/tool-result.js +0 -423
  220. package/dist/ui/ccstyle/tool-result.js.map +0 -1
  221. package/dist/ui/conversation-blocks.d.ts +0 -46
  222. package/dist/ui/conversation-blocks.d.ts.map +0 -1
  223. package/dist/ui/conversation-blocks.js +0 -313
  224. package/dist/ui/conversation-blocks.js.map +0 -1
  225. package/dist/ui/conversation-nvim.d.ts +0 -8
  226. package/dist/ui/conversation-nvim.d.ts.map +0 -1
  227. package/dist/ui/conversation-nvim.js +0 -117
  228. package/dist/ui/conversation-nvim.js.map +0 -1
  229. package/dist/ui/conversation-role.d.ts +0 -13
  230. package/dist/ui/conversation-role.d.ts.map +0 -1
  231. package/dist/ui/conversation-role.js +0 -39
  232. package/dist/ui/conversation-role.js.map +0 -1
  233. package/dist/ui/conversation-search.d.ts +0 -39
  234. package/dist/ui/conversation-search.d.ts.map +0 -1
  235. package/dist/ui/conversation-search.js +0 -124
  236. package/dist/ui/conversation-search.js.map +0 -1
  237. package/dist/ui/conversation-timeline.d.ts +0 -102
  238. package/dist/ui/conversation-timeline.d.ts.map +0 -1
  239. package/dist/ui/conversation-timeline.js +0 -555
  240. package/dist/ui/conversation-timeline.js.map +0 -1
  241. package/dist/ui/conversation-viewer.d.ts +0 -138
  242. package/dist/ui/conversation-viewer.d.ts.map +0 -1
  243. package/dist/ui/conversation-viewer.js +0 -1175
  244. package/dist/ui/conversation-viewer.js.map +0 -1
  245. package/dist/ui/schedule-menu.d.ts +0 -17
  246. package/dist/ui/schedule-menu.d.ts.map +0 -1
  247. package/dist/ui/schedule-menu.js +0 -95
  248. package/dist/ui/schedule-menu.js.map +0 -1
  249. package/dist/ui/select-item.d.ts +0 -28
  250. package/dist/ui/select-item.d.ts.map +0 -1
  251. package/dist/ui/select-item.js +0 -35
  252. package/dist/ui/select-item.js.map +0 -1
  253. package/dist/ui/viewer-keys.d.ts +0 -21
  254. package/dist/ui/viewer-keys.d.ts.map +0 -1
  255. package/dist/ui/viewer-keys.js +0 -18
  256. package/dist/ui/viewer-keys.js.map +0 -1
  257. package/dist/ui/workflow-card.d.ts +0 -176
  258. package/dist/ui/workflow-card.d.ts.map +0 -1
  259. package/dist/ui/workflow-card.js +0 -333
  260. package/dist/ui/workflow-card.js.map +0 -1
  261. package/dist/ui/workflow-dialog.d.ts +0 -306
  262. package/dist/ui/workflow-dialog.d.ts.map +0 -1
  263. package/dist/ui/workflow-dialog.js +0 -844
  264. package/dist/ui/workflow-dialog.js.map +0 -1
  265. package/dist/ui/workflow-menu.d.ts +0 -42
  266. package/dist/ui/workflow-menu.d.ts.map +0 -1
  267. package/dist/ui/workflow-menu.js +0 -127
  268. package/dist/ui/workflow-menu.js.map +0 -1
  269. package/dist/usage.d.ts +0 -136
  270. package/dist/usage.d.ts.map +0 -1
  271. package/dist/usage.js +0 -121
  272. package/dist/usage.js.map +0 -1
  273. package/dist/workflow/collisions.d.ts +0 -96
  274. package/dist/workflow/collisions.d.ts.map +0 -1
  275. package/dist/workflow/collisions.js +0 -89
  276. package/dist/workflow/collisions.js.map +0 -1
  277. package/dist/workflow/entry.d.ts +0 -33
  278. package/dist/workflow/entry.d.ts.map +0 -1
  279. package/dist/workflow/entry.js +0 -30
  280. package/dist/workflow/entry.js.map +0 -1
  281. package/dist/workflow/host.d.ts +0 -63
  282. package/dist/workflow/host.d.ts.map +0 -1
  283. package/dist/workflow/host.js +0 -363
  284. package/dist/workflow/host.js.map +0 -1
  285. package/dist/workflow/journal.d.ts +0 -98
  286. package/dist/workflow/journal.d.ts.map +0 -1
  287. package/dist/workflow/journal.js +0 -121
  288. package/dist/workflow/journal.js.map +0 -1
  289. package/dist/workflow/json-schema.d.ts +0 -52
  290. package/dist/workflow/json-schema.d.ts.map +0 -1
  291. package/dist/workflow/json-schema.js +0 -112
  292. package/dist/workflow/json-schema.js.map +0 -1
  293. package/dist/workflow/meta.d.ts +0 -68
  294. package/dist/workflow/meta.d.ts.map +0 -1
  295. package/dist/workflow/meta.js +0 -318
  296. package/dist/workflow/meta.js.map +0 -1
  297. package/dist/workflow/progress.d.ts +0 -225
  298. package/dist/workflow/progress.d.ts.map +0 -1
  299. package/dist/workflow/progress.js +0 -362
  300. package/dist/workflow/progress.js.map +0 -1
  301. package/dist/workflow/runtime.d.ts +0 -335
  302. package/dist/workflow/runtime.d.ts.map +0 -1
  303. package/dist/workflow/runtime.js +0 -831
  304. package/dist/workflow/runtime.js.map +0 -1
  305. package/dist/workflow/saved.d.ts +0 -91
  306. package/dist/workflow/saved.d.ts.map +0 -1
  307. package/dist/workflow/saved.js +0 -204
  308. package/dist/workflow/saved.js.map +0 -1
  309. package/dist/workflow/task.d.ts +0 -137
  310. package/dist/workflow/task.d.ts.map +0 -1
  311. package/dist/workflow/task.js +0 -208
  312. package/dist/workflow/task.js.map +0 -1
  313. package/dist/workflow/tool-description.d.ts +0 -39
  314. package/dist/workflow/tool-description.d.ts.map +0 -1
  315. package/dist/workflow/tool-description.js +0 -200
  316. package/dist/workflow/tool-description.js.map +0 -1
  317. package/dist/workflow/worker-source.d.ts +0 -48
  318. package/dist/workflow/worker-source.d.ts.map +0 -1
  319. package/dist/workflow/worker-source.js +0 -779
  320. package/dist/workflow/worker-source.js.map +0 -1
  321. package/dist/worktree.d.ts +0 -53
  322. package/dist/worktree.d.ts.map +0 -1
  323. package/dist/worktree.js +0 -165
  324. package/dist/worktree.js.map +0 -1
  325. package/dist/write-execution.d.ts +0 -42
  326. package/dist/write-execution.d.ts.map +0 -1
  327. package/dist/write-execution.js +0 -138
  328. package/dist/write-execution.js.map +0 -1
  329. package/dist/xml.d.ts +0 -11
  330. package/dist/xml.d.ts.map +0 -1
  331. package/dist/xml.js +0 -13
  332. package/dist/xml.js.map +0 -1
  333. package/src/abortable.ts +0 -43
  334. package/src/agent-color.ts +0 -161
  335. package/src/agent-file-toggle.ts +0 -269
  336. package/src/child-context.ts +0 -15
  337. package/src/mention-clone.ts +0 -196
  338. package/src/mention.ts +0 -141
  339. package/src/model-scope.ts +0 -70
  340. package/src/nested-tools.ts +0 -424
  341. package/src/structured-output.ts +0 -130
  342. package/src/ui/agent-mention.ts +0 -216
  343. package/src/ui/select-item.ts +0 -45
  344. package/src/ui/workflow-card.ts +0 -470
  345. package/src/ui/workflow-dialog.ts +0 -1115
  346. package/src/ui/workflow-menu.ts +0 -166
  347. package/src/workflow/collisions.ts +0 -123
  348. package/src/workflow/entry.ts +0 -47
  349. package/src/workflow/host.ts +0 -403
  350. package/src/workflow/journal.ts +0 -164
  351. package/src/workflow/json-schema.ts +0 -128
  352. package/src/workflow/meta.ts +0 -325
  353. package/src/workflow/progress.ts +0 -550
  354. package/src/workflow/runtime.ts +0 -1219
  355. package/src/workflow/saved.ts +0 -217
  356. package/src/workflow/task.ts +0 -302
  357. package/src/workflow/tool-description.ts +0 -200
  358. package/src/workflow/worker-source.ts +0 -781
  359. package/src/xml.ts +0 -13
package/dist/index.js DELETED
@@ -1,3687 +0,0 @@
1
- /**
2
- * pi-agents — A pi extension providing Claude Code-style autonomous sub-agents.
3
- *
4
- * Tools:
5
- * Agent — LLM-callable: spawn a sub-agent
6
- * get_subagent_result — LLM-callable: check background agent status/result
7
- * steer_subagent — LLM-callable: send a steering message to a running agent
8
- *
9
- * Commands:
10
- * /agents — Interactive agent management menu
11
- */
12
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
13
- import { isAbsolute, join } from "node:path";
14
- import { defineTool, getAgentDir, getSelectListTheme, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
15
- import { Container, isKeyRelease, Key, matchesKey, SelectList, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
16
- import { Type } from "@sinclair/typebox";
17
- import { abortable } from "./abortable.js";
18
- import { hasAgentBadge, renderAgentName } from "./agent-color.js";
19
- import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile, personalAgentsDir, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.js";
20
- import { readAgentHistory } from "./agent-history.js";
21
- import { canOpenAgentHistory, formatAgentHistoryOption, splitAgentRecords } from "./agent-history-list.js";
22
- import { AgentManager, isTopLevelAgent } from "./agent-manager.js";
23
- import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, steerAgent } from "./agent-runner.js";
24
- import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getConfig, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
25
- import { inChildSessionContext } from "./child-context.js";
26
- import { registerRpcHandlers } from "./cross-extension-rpc.js";
27
- import { loadCustomAgents } from "./custom-agents.js";
28
- import { GroupJoinManager } from "./group-join.js";
29
- import { isolationParam, resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
30
- import { describeMention, handleBase, isReservedHandle, parseMention, resolveHandleToType, stripAgentPrefix } from "./mention.js";
31
- import { runMentionClone } from "./mention-clone.js";
32
- import { describeModel, resolveModel } from "./model-resolver.js";
33
- import { checkModelScope, isScopeModelsEnabled, setScopeModelsEnabled } from "./model-scope.js";
34
- import { getMaxSubagentDepth, setMaxSubagentDepth } from "./nested-tools.js";
35
- import { createOutputFilePath, ensureOutputFile, getOutputTranscriptDefault, sessionTaskDir, setOutputTranscriptDefault, streamToOutputFile, writeInitialEntry } from "./output-file.js";
36
- import { SubagentScheduler } from "./schedule.js";
37
- import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
38
- import { applyAndEmitLoaded, loadSettings, saveAndEmitChanged } from "./settings.js";
39
- import { getForegroundOutcomeNote, getStatusNote, partialOutputSuffix } from "./status-note.js";
40
- import { createMentionProvider, mentionRoster } from "./ui/agent-mention.js";
41
- import { AgentWidget, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatCost, formatDuration, formatMs, formatTokens, formatTurns, getDisplayName, getPromptModeLabel, SPINNER, } from "./ui/agent-widget.js";
42
- import { showSchedulesMenu } from "./ui/schedule-menu.js";
43
- import { renderWorkflowCard, renderWorkflowEntryCard } from "./ui/workflow-card.js";
44
- import { showWorkflowsMenu } from "./ui/workflow-menu.js";
45
- import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent, PendingUsagePool, toReportedUsage } from "./usage.js";
46
- import { decideWorkflowCollision, FOREIGN_WORKFLOW_TOOL_NAMES } from "./workflow/collisions.js";
47
- import { WORKFLOW_ENTRY_TYPE, workflowEntryData } from "./workflow/entry.js";
48
- import { createWorkflowHost } from "./workflow/host.js";
49
- import { appendJournal, readJournal } from "./workflow/journal.js";
50
- import { extractMeta, workflowCallName } from "./workflow/meta.js";
51
- import { elapsedMs } from "./workflow/progress.js";
52
- import { runWorkflow } from "./workflow/runtime.js";
53
- import { resolveWorkflowScript } from "./workflow/saved.js";
54
- import { completeWorkflowTask, createWorkflowTask, failWorkflowTask, formatWorkflowNotification, resolveResumeTarget, updateWorkflowProgressBatch, workflowResultText, workflowRunId } from "./workflow/task.js";
55
- import { fullWorkflowToolDescription } from "./workflow/tool-description.js";
56
- import { isWorktreeIsolationEnabled, setWorktreeIsolationEnabled } from "./worktree.js";
57
- import { escapeXml } from "./xml.js";
58
- // ---- Shared helpers ----
59
- /** Tool execute return value for a text response. */
60
- function textResult(msg, details) {
61
- return { content: [{ type: "text", text: msg }], details: details };
62
- }
63
- export function renderRunningAgentStatus(frame, statsText, activity, theme) {
64
- const container = new Container();
65
- container.addChild(new Text(theme.fg("accent", frame) + (statsText ? " " + statsText : ""), 0, 0));
66
- container.addChild(new Text(theme.fg("dim", ` ⎿ ${activity}`), 0, 0));
67
- return container;
68
- }
69
- /** Format an agent's lifetime token total, or "" when zero. */
70
- function formatLifetimeTokens(o) {
71
- const t = getLifetimeTotal(o.lifetimeUsage);
72
- return t > 0 ? formatTokens(t) : "";
73
- }
74
- /**
75
- * Create an AgentActivity state and spawn callbacks for tracking tool usage.
76
- * Used by both foreground and background paths to avoid duplication.
77
- */
78
- function createActivityTracker(maxTurns, onStreamUpdate) {
79
- const state = {
80
- activeTools: new Map(),
81
- toolUses: 0,
82
- turnCount: 1,
83
- maxTurns,
84
- responseText: "",
85
- session: undefined,
86
- lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
87
- };
88
- const callbacks = {
89
- onToolActivity: (activity) => {
90
- if (activity.type === "start") {
91
- state.activeTools.set(activity.toolName + "_" + Date.now(), activity.toolName);
92
- }
93
- else {
94
- for (const [key, name] of state.activeTools) {
95
- if (name === activity.toolName) {
96
- state.activeTools.delete(key);
97
- break;
98
- }
99
- }
100
- state.toolUses++;
101
- }
102
- onStreamUpdate?.();
103
- },
104
- onTextDelta: (_delta, fullText) => {
105
- state.responseText = fullText;
106
- onStreamUpdate?.();
107
- },
108
- onTurnEnd: (turnCount) => {
109
- state.turnCount = turnCount;
110
- onStreamUpdate?.();
111
- },
112
- onSessionCreated: (session) => {
113
- state.session = session;
114
- },
115
- // Spend is accumulated on the AgentRecord (agent-manager), which is what
116
- // every surface reads; this callback exists here only to repaint on it.
117
- onAssistantUsage: (_usage) => {
118
- onStreamUpdate?.();
119
- },
120
- };
121
- return { state, callbacks };
122
- }
123
- /**
124
- * Advertised thinking levels, ordered to mirror pi-ai's EXTENDED_THINKING_LEVELS
125
- * (`off` + every `ThinkingLevel`). Single source for the Agent tool description,
126
- * the generated-agent template, and the `/agents` wizard so these lists can't
127
- * drift behind pi again (#147). Availability of any level still depends on the
128
- * host pi version and the selected model — pi clamps unsupported levels down.
129
- */
130
- const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
131
- /** Human-readable status label for agent completion. */
132
- function getStatusLabel(status, error) {
133
- switch (status) {
134
- case "error": return `Error: ${error ?? "unknown"}`;
135
- case "aborted": return "Aborted (max turns exceeded)";
136
- case "steered": return "Wrapped up (turn limit)";
137
- case "stopped": return "Stopped";
138
- default: return "Done";
139
- }
140
- }
141
- /** Format a structured task notification matching Claude Code's <task-notification> XML. */
142
- function formatTaskNotification(record, resultMaxLen, showCost = false) {
143
- const status = getStatusLabel(record.status, record.error);
144
- const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
145
- const totalTokens = getLifetimeTotal(record.lifetimeUsage);
146
- const contextPercent = getSessionContextPercent(record.session);
147
- const ctxXml = contextPercent !== null ? `<context_percent>${Math.round(contextPercent)}</context_percent>` : "";
148
- const compactXml = record.compactionCount ? `<compactions>${record.compactionCount}</compactions>` : "";
149
- // Only under `showCost`: this is LLM context, and a figure the orchestrator
150
- // did not ask for is a figure it may start reporting unprompted.
151
- const cost = showCost ? getLifetimeCost(record.lifetimeUsage) : 0;
152
- const costXml = cost > 0 ? `<estimated_cost_usd>${cost.toFixed(4)}</estimated_cost_usd>` : "";
153
- const resultPreview = record.result
154
- ? record.result.length > resultMaxLen
155
- ? record.result.slice(0, resultMaxLen) + "\n...(truncated, use get_subagent_result for full output)"
156
- : record.result
157
- : "No output.";
158
- return [
159
- `<task-notification>`,
160
- `<task-id>${record.id}</task-id>`,
161
- record.toolCallId ? `<tool-use-id>${escapeXml(record.toolCallId)}</tool-use-id>` : null,
162
- record.outputFile ? `<output-file>${escapeXml(record.outputFile)}</output-file>` : null,
163
- `<status>${escapeXml(status)}</status>`,
164
- `<summary>Agent "${escapeXml(record.description)}" ${record.status}${getStatusNote(record.status)}</summary>`,
165
- `<result>${escapeXml(resultPreview)}</result>`,
166
- `<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}${costXml}<duration_ms>${durationMs}</duration_ms></usage>`,
167
- `</task-notification>`,
168
- ].filter(Boolean).join('\n');
169
- }
170
- /** Build AgentDetails from a base + record-specific fields. */
171
- function buildDetails(base, record, activity, overrides) {
172
- return {
173
- ...base,
174
- toolUses: record.toolUses,
175
- tokens: formatLifetimeTokens(record),
176
- // Raw, and unconditional: `tokens` is preformatted because it is one stat,
177
- // but a cost is joined by "·" in one surface, "," in another and "|" in a
178
- // third — so it travels as a number and each renderer punctuates its own.
179
- cost: getLifetimeCost(record.lifetimeUsage),
180
- turnCount: activity?.turnCount,
181
- maxTurns: activity?.maxTurns,
182
- durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
183
- status: record.status,
184
- agentId: record.id,
185
- error: record.error,
186
- ...overrides,
187
- };
188
- }
189
- /** Build notification details for the custom message renderer. */
190
- function buildNotificationDetails(record, resultMaxLen, activity) {
191
- const totalTokens = getLifetimeTotal(record.lifetimeUsage);
192
- return {
193
- id: record.id,
194
- description: record.description,
195
- status: record.status,
196
- toolUses: record.toolUses,
197
- turnCount: activity?.turnCount ?? 0,
198
- maxTurns: activity?.maxTurns,
199
- totalTokens,
200
- // Carried unconditionally; the renderer gates on the setting. Details are
201
- // data, and a notification rendered before a mid-session toggle should not
202
- // be stuck with the old answer.
203
- totalCost: getLifetimeCost(record.lifetimeUsage),
204
- durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
205
- outputFile: record.outputFile,
206
- error: record.error,
207
- resultPreview: record.result
208
- ? record.result.length > resultMaxLen
209
- ? record.result.slice(0, resultMaxLen) + "…"
210
- : record.result
211
- : "No output.",
212
- };
213
- }
214
- /**
215
- * Format an agent's tool scope for the Agent tool description.
216
- *
217
- * This suffix describes BUILT-IN scope only — extension tools are resolved when
218
- * the agent runs (extensions can register asynchronously), so they cannot be
219
- * enumerated while the description is being built. That is why an agent with
220
- * `tools: "*, ext:mcp/search"` renders "*" and always has.
221
- *
222
- * Two distinctions matter, both of them capability claims the orchestrator acts on:
223
- *
224
- * - absent vs empty. `builtinToolNames: undefined` means the agent never narrowed
225
- * its tools (the shipped defaults); `[]` is what `tools: none` and an `ext:`-only
226
- * `tools:` parse to, and the runtime really does hand those agents no built-ins.
227
- * Rendering both "*" tells the orchestrator a tool-less agent can run `bash`.
228
- * - empty-with-extensions vs empty-without. Zero built-ins does NOT imply zero
229
- * tools: `tools: none` alongside `extensions:` still surfaces every extension
230
- * tool (see test/fixtures/.pi/agents/tools-none.md, which expects three). Calling
231
- * that "none" understates the agent instead of overstating it — better, but still
232
- * wrong, and it would route work away from the only agent able to do it. "none"
233
- * is therefore reserved for agents that genuinely can call nothing: `isolated`
234
- * agents and those with `extensions: false`.
235
- */
236
- export function formatToolsSuffix(cfg) {
237
- const tools = cfg?.builtinToolNames;
238
- if (!tools)
239
- return "*";
240
- if (tools.length === 0) {
241
- // `isolated` overrides extensions to false in the runner, so both mean the
242
- // agent has no extension tools either — and then it truly has nothing.
243
- const noExtensionTools = cfg?.isolated === true || cfg?.extensions === false;
244
- return noExtensionTools ? "none" : "no built-ins, extension tools only";
245
- }
246
- const isFullSet = tools.length === BUILTIN_TOOL_NAMES.length
247
- && BUILTIN_TOOL_NAMES.every((t) => tools.includes(t));
248
- return isFullSet ? "*" : tools.join(", ");
249
- }
250
- /** CLI flag that runs a workflow script at session start. */
251
- export const WORKFLOW_FILE_FLAG = "subagents-workflow-file";
252
- /**
253
- * Re-exported from where they now live, because this is where they were
254
- * defined and a consumer (or a test) that matched a session entry on
255
- * {@link WORKFLOW_ENTRY_TYPE} imports it from here.
256
- */
257
- export { FOREIGN_WORKFLOW_TOOL_NAMES, WORKFLOW_ENTRY_TYPE, workflowEntryData };
258
- export default function (pi) {
259
- // Child AgentSessions load normal extensions. Re-entering this extension there
260
- // would create another manager and leak handlers. Nested orchestration is
261
- // injected as scoped custom tools by the existing manager instead.
262
- if (inChildSessionContext())
263
- return;
264
- // ---- Register custom notification renderer ----
265
- pi.registerMessageRenderer("subagent-notification", (message, { expanded }, theme) => {
266
- const d = message.details;
267
- if (!d)
268
- return undefined;
269
- function renderOne(d) {
270
- const isError = d.status === "error" || d.status === "stopped" || d.status === "aborted";
271
- const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
272
- const statusText = isError ? d.status
273
- : d.status === "steered" ? "completed (steered)"
274
- : "completed";
275
- // Line 1: icon + agent description + status
276
- let line = `${icon} ${theme.bold(d.description)} ${theme.fg("dim", statusText)}`;
277
- // Line 2: stats
278
- const parts = [];
279
- if (d.turnCount > 0)
280
- parts.push(formatTurns(d.turnCount, d.maxTurns));
281
- if (d.toolUses > 0)
282
- parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
283
- if (d.totalTokens > 0)
284
- parts.push(formatTokens(d.totalTokens));
285
- if (showCost) {
286
- const costText = formatCost(d.totalCost ?? 0);
287
- if (costText)
288
- parts.push(costText);
289
- }
290
- if (d.durationMs > 0)
291
- parts.push(formatMs(d.durationMs));
292
- if (parts.length) {
293
- line += "\n " + parts.map(p => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
294
- }
295
- // Line 3: result preview (collapsed) or full (expanded)
296
- if (expanded) {
297
- const lines = d.resultPreview.split("\n").slice(0, 30);
298
- for (const l of lines)
299
- line += "\n" + theme.fg("dim", ` ${l}`);
300
- }
301
- else {
302
- const preview = d.resultPreview.split("\n")[0]?.slice(0, 80) ?? "";
303
- line += "\n " + theme.fg("dim", `⎿ ${preview}`);
304
- }
305
- // Line 4: output file link (if present)
306
- if (d.outputFile) {
307
- line += "\n " + theme.fg("muted", `transcript: ${d.outputFile}`);
308
- }
309
- return line;
310
- }
311
- const all = [d, ...(d.others ?? [])];
312
- const rendered = all.map(renderOne);
313
- // A group of agents lands as one notification, and the number a user wants
314
- // from it is what the batch cost — not four figures to add up by hand.
315
- // Derived from the per-agent details rather than carried alongside them:
316
- // one source, so the total can never disagree with the rows above it.
317
- if (showCost && all.length > 1) {
318
- const total = formatCost(all.reduce((sum, a) => sum + (a.totalCost ?? 0), 0));
319
- if (total) {
320
- const tokens = all.reduce((sum, a) => sum + a.totalTokens, 0);
321
- rendered.unshift(theme.fg("dim", `${all.length} agents · ${formatTokens(tokens)} · ${total}`));
322
- }
323
- }
324
- return new Text(rendered.join("\n"), 0, 0);
325
- });
326
- // ---- Workflow run rendered as a session entry ----
327
- // A workflow launched from the CLI flag has no tool call to hang its result
328
- // card on, so it renders here instead — through the SAME layout the tool
329
- // result uses, not a second one. Custom entries with no registered renderer
330
- // are silently dropped by the host, which is why this is registered at
331
- // activation rather than lazily.
332
- if (typeof pi.registerEntryRenderer === "function") {
333
- pi.registerEntryRenderer(WORKFLOW_ENTRY_TYPE, (entry, _options, theme) => renderWorkflowEntryCard(entry.data, theme));
334
- }
335
- // Registered at activation; READ from session_start. The host applies CLI
336
- // values after every extension factory has run, so `getFlag` here would only
337
- // ever hand back the registered default (see the read site below).
338
- if (typeof pi.registerFlag === "function") {
339
- pi.registerFlag(WORKFLOW_FILE_FLAG, {
340
- type: "string",
341
- description: `Run a workflow script at startup: --${WORKFLOW_FILE_FLAG}=<path>. ` +
342
- "Use the `=` form — the space form consumes the next argument, which would swallow a following prompt.",
343
- });
344
- }
345
- // Read directly rather than waiting for applyAndEmitLoaded below: this decides
346
- // the initial load, which happens hundreds of lines before settings are applied.
347
- let strictAgentFiles = loadSettings(process.cwd()).strictAgentFiles === true;
348
- /** Reload agents from project/global custom agent dirs and merge with defaults (called on init and each Agent invocation). */
349
- const reloadCustomAgents = (strict = false) => {
350
- const userAgents = loadCustomAgents(process.cwd(), strict);
351
- registerAgents(userAgents);
352
- };
353
- // Initial load — the only strict one. A bad edit mid-session must not kill the
354
- // session on the next unrelated spawn, so every later reload keeps warning.
355
- reloadCustomAgents(strictAgentFiles);
356
- // ---- Agent activity tracking + widget ----
357
- const agentActivity = new Map();
358
- // ---- Usage reporting (both off by default; see SubagentsSettings) ----
359
- /** Attach subagent spend to tool results, so the parent session counts it. */
360
- let reportUsage = false;
361
- function isReportUsageEnabled() { return reportUsage; }
362
- function setReportUsage(b) {
363
- reportUsage = b;
364
- // Whatever accumulated while it was on is stale the moment it goes off:
365
- // draining it later would bill the parent for a window the user opted out
366
- // of, in one lump, on some unrelated later tool call.
367
- if (!b)
368
- pendingUsage.drain();
369
- }
370
- /** Show `~$X` next to token counts in the subagent surfaces. */
371
- let showCost = false;
372
- function isShowCostEnabled() { return showCost; }
373
- function setShowCost(b) { showCost = b; widget.update(); }
374
- /** Name the model and thinking level on the widget's running rows. */
375
- let showModel = false;
376
- function isShowModelEnabled() { return showModel; }
377
- function setShowModel(b) { showModel = b; widget.update(); }
378
- /**
379
- * How much of the conversation viewer renders as Markdown. Read through a
380
- * getter by the viewer rather than captured like `showCost`, because the
381
- * viewer's `m` key writes back here while the overlay is on screen.
382
- */
383
- let viewerMarkdown = "assistant";
384
- function getViewerMarkdown() { return viewerMarkdown; }
385
- function setViewerMarkdown(mode) { viewerMarkdown = mode; }
386
- const pendingUsage = new PendingUsagePool();
387
- // ---- Cancellable pending notifications ----
388
- // Holds notifications briefly so get_subagent_result can cancel them
389
- // before they reach pi.sendMessage (fire-and-forget).
390
- const pendingNudges = new Map();
391
- const NUDGE_HOLD_MS = 200;
392
- // A queued result wait must observe completion before its held notification
393
- // can fire, so successful waits can still suppress that redundant nudge.
394
- const QUEUE_WAIT_POLL_MS = Math.floor(NUDGE_HOLD_MS / 4);
395
- function scheduleNudge(key, send, delay = NUDGE_HOLD_MS) {
396
- cancelNudge(key);
397
- pendingNudges.set(key, setTimeout(() => {
398
- pendingNudges.delete(key);
399
- try {
400
- send();
401
- }
402
- catch { /* ignore stale completion side-effect errors */ }
403
- }, delay));
404
- }
405
- function cancelNudge(key) {
406
- const timer = pendingNudges.get(key);
407
- if (timer != null) {
408
- clearTimeout(timer);
409
- pendingNudges.delete(key);
410
- }
411
- }
412
- // ---- Individual nudge helper (async join mode) ----
413
- function emitIndividualNudge(record) {
414
- if (record.resultConsumed)
415
- return; // re-check at send time
416
- const notification = formatTaskNotification(record, 500, showCost);
417
- const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : '';
418
- pi.sendMessage({
419
- customType: "subagent-notification",
420
- content: notification + footer,
421
- display: true,
422
- details: buildNotificationDetails(record, 500, agentActivity.get(record.id)),
423
- }, { deliverAs: "followUp", triggerTurn: true });
424
- }
425
- function sendIndividualNudge(record) {
426
- agentActivity.delete(record.id);
427
- widget.markFinished(record.id);
428
- scheduleNudge(record.id, () => emitIndividualNudge(record));
429
- widget.update();
430
- }
431
- // ---- Group join manager ----
432
- const groupJoin = new GroupJoinManager((records, partial) => {
433
- for (const r of records) {
434
- agentActivity.delete(r.id);
435
- widget.markFinished(r.id);
436
- }
437
- const groupKey = `group:${records.map(r => r.id).join(",")}`;
438
- scheduleNudge(groupKey, () => {
439
- // Re-check at send time
440
- const unconsumed = records.filter(r => !r.resultConsumed);
441
- if (unconsumed.length === 0) {
442
- widget.update();
443
- return;
444
- }
445
- const notifications = unconsumed.map(r => formatTaskNotification(r, 300, showCost)).join('\n\n');
446
- const label = partial
447
- ? `${unconsumed.length} agent(s) finished (partial — others still running)`
448
- : `${unconsumed.length} agent(s) finished`;
449
- const [first, ...rest] = unconsumed;
450
- const details = buildNotificationDetails(first, 300, agentActivity.get(first.id));
451
- if (rest.length > 0) {
452
- details.others = rest.map(r => buildNotificationDetails(r, 300, agentActivity.get(r.id)));
453
- }
454
- pi.sendMessage({
455
- customType: "subagent-notification",
456
- content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
457
- display: true,
458
- details,
459
- }, { deliverAs: "followUp", triggerTurn: true });
460
- });
461
- widget.update();
462
- }, 30_000);
463
- /** Helper: build event data for lifecycle events from an AgentRecord. */
464
- function buildEventData(record) {
465
- const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt;
466
- // All three fields are lifetime-accumulated (Σ over every assistant message_end),
467
- // so they survive compaction together — input + output ≤ total always.
468
- // tokens is omitted when nothing was ever produced (e.g. agent errored before
469
- // any message_end fired), preserving prior payload shape.
470
- const u = record.lifetimeUsage;
471
- const total = getLifetimeTotal(u);
472
- const tokens = total > 0
473
- ? { input: u.input, output: u.output, total }
474
- : undefined;
475
- // The whole run's spend as a pi `Usage` — pi's convention for handing spend
476
- // to a consumer, so `usage.cost.total` and `usage.cacheRead` are where a
477
- // listener already expects them and anything pi adds to `Usage` arrives
478
- // without a change here. Omitted when nothing was spent, so "spent nothing"
479
- // and "never ran" stay distinguishable. Ungated by `showCost`: that setting
480
- // governs what a human is shown, not what the event carries.
481
- //
482
- // `tokens` above is the other convention, kept as it shipped: a flat view
483
- // model like pi's own `SessionStats`, carrying the DISPLAY total, which
484
- // excludes cacheRead (#38). The two answer different questions and neither
485
- // derives from the other.
486
- const usage = toReportedUsage(u);
487
- return {
488
- id: record.id,
489
- type: record.type,
490
- description: record.description,
491
- result: record.transcriptPath ? undefined : record.result,
492
- error: record.error,
493
- transcriptPath: record.transcriptPath,
494
- status: record.status,
495
- toolUses: record.toolUses,
496
- durationMs,
497
- tokens,
498
- usage,
499
- };
500
- }
501
- const historyAgentSelection = { index: 0 };
502
- const runningAgentSelection = { index: 0 };
503
- const manager = new AgentManager((record) => {
504
- // Owned children — nested, or a workflow's — report only through their
505
- // owner: the parent's scoped tools, or the workflow's card, notification
506
- // and dialog. Keep them out of top-level lifecycle, transcript,
507
- // notification, and UI channels.
508
- if (!isTopLevelAgent(record))
509
- return;
510
- // Emit lifecycle event based on terminal status
511
- const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
512
- const eventData = buildEventData(record);
513
- if (isError) {
514
- pi.events.emit("subagents:failed", eventData);
515
- }
516
- else {
517
- pi.events.emit("subagents:completed", eventData);
518
- }
519
- // Persist final record for cross-extension history reconstruction
520
- pi.appendEntry("subagents:record", {
521
- id: record.id, type: record.type, description: record.description,
522
- status: record.status,
523
- result: record.transcriptPath ? undefined : record.result,
524
- error: record.error,
525
- transcriptPath: record.transcriptPath,
526
- startedAt: record.startedAt, completedAt: record.completedAt,
527
- });
528
- // Skip notification if result was already consumed via get_subagent_result
529
- if (record.resultConsumed) {
530
- agentActivity.delete(record.id);
531
- widget.markFinished(record.id);
532
- widget.update();
533
- return;
534
- }
535
- // If this agent is pending batch finalization (debounce window still open),
536
- // don't send an individual nudge — finalizeBatch will pick it up retroactively.
537
- if (currentBatchAgents.some(a => a.id === record.id)) {
538
- widget.update();
539
- return;
540
- }
541
- const result = groupJoin.onAgentComplete(record);
542
- if (result === 'pass') {
543
- sendIndividualNudge(record);
544
- }
545
- // 'held' → do nothing, group will fire later
546
- // 'delivered' → group callback already fired
547
- widget.update();
548
- }, undefined, (record) => {
549
- if (!isTopLevelAgent(record))
550
- return;
551
- // Agent-tool spawns refresh these surfaces in their tool handler, but RPC
552
- // and scheduler spawns enter through the manager directly.
553
- if (currentCtx?.hasUI && (currentCtx.mode === undefined || currentCtx.mode === "tui")) {
554
- widget.ensureTimer();
555
- widget.update();
556
- }
557
- // Emit started event when agent transitions to running (including from queue)
558
- pi.events.emit("subagents:started", {
559
- id: record.id,
560
- type: record.type,
561
- description: record.description,
562
- });
563
- }, (record, info) => {
564
- if (!isTopLevelAgent(record))
565
- return;
566
- // Emit compacted event when agent's session compacts (preserves count on record).
567
- pi.events.emit("subagents:compacted", {
568
- id: record.id,
569
- type: record.type,
570
- description: record.description,
571
- reason: info.reason,
572
- tokensBefore: info.tokensBefore,
573
- compactionCount: record.compactionCount,
574
- });
575
- }, (_record, usage) => {
576
- // Every assistant message from every agent — nested included, exactly once.
577
- // Parked here until a tool result can carry it back to the parent session;
578
- // see `PendingUsagePool`. Skipped entirely when the feature is off, so no
579
- // pool grows in a session that will never drain it.
580
- if (reportUsage)
581
- pendingUsage.add(usage);
582
- });
583
- // Expose manager via Symbol.for() global registry for cross-package access.
584
- // Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
585
- // Documented for callers in docs/rpc.md ("The manager registry").
586
- //
587
- // Claim the slot only if it's free: subagent sessions re-activate this
588
- // extension in the same process (session.bindExtensions in agent-runner.ts),
589
- // and unconditionally overwriting would point the registry at a short-lived
590
- // child manager — and the child's shutdown would then delete the root
591
- // session's entry. The first activation (the root session) wins; child
592
- // activations leave it alone.
593
- const MANAGER_KEY = Symbol.for("pi-subagents:manager");
594
- // Process-external callers may supply arbitrary options. Nested ownership and
595
- // config-root metadata are internal capabilities issued only by scoped tools.
596
- /**
597
- * Resolve the agent type and spawn. Trusts its options — every caller must
598
- * either be in-process or have gone through `spawnTopLevel` first.
599
- */
600
- const spawnResolved = (piRef, ctxRef, type, prompt, options) => {
601
- // Cross-extension callers get the same dispatch contract as the LLM (#183).
602
- // The RPC layer already throws for an unresolvable model rather than falling
603
- // back silently; a bad agent type should not be quieter. Throws become error
604
- // envelopes at the RPC boundary. Reload first so an agent file added mid
605
- // session is spawnable here too, not only through the Agent tool.
606
- reloadCustomAgents();
607
- const dispatch = resolveSpawnType(type);
608
- if (!dispatch.ok)
609
- throw new Error(dispatch.message);
610
- // Every programmatic spawn lands here — cross-extension RPC, both `@handle`
611
- // mention paths, and the `Symbol.for("pi-subagents:manager")` registry — and
612
- // none came through the Agent tool, which is where the UI activity tracker is
613
- // otherwise created. Without one the widget has no tool name
614
- // and no turn count, so the row reads `thinking…` for the agent's whole life
615
- // while the header's tool-use count climbs beside it (#181). Double-tracking
616
- // is not possible: the Agent tool calls `manager.spawn` directly. The tracker
617
- // callbacks are the funnel's own — a caller's are not honoured, since a
618
- // half-wired tracker renders worse than none.
619
- //
620
- // The turn limit is resolved rather than read off `options`, which a mention
621
- // spawn deliberately omits so the agent's own config can decide: a tracker
622
- // built with `undefined` renders `↻3` where the Agent tool renders `↻3≤20`.
623
- // Like the tool's own, it is a prediction — editing the agent file mid-run
624
- // leaves the displayed ceiling stale.
625
- const { state, callbacks } = createActivityTracker(resolveEffectiveMaxTurns(dispatch.type, options?.maxTurns));
626
- // Repaints are left to the manager's `onStart` callback, which already starts
627
- // the widget timer for agents that enter this way.
628
- const id = manager.spawn(piRef, ctxRef, dispatch.type, prompt, { ...options, ...callbacks });
629
- agentActivity.set(id, state);
630
- return id;
631
- };
632
- const spawnTopLevel = (piRef, ctxRef, type, prompt, options) => {
633
- const safeOptions = { ...(options ?? {}) };
634
- delete safeOptions.parentAgentId;
635
- // Internal too: a forged value would hide an RPC-spawned agent inside
636
- // someone else's workflow, and take it out of the concurrency pool with it.
637
- delete safeOptions.workflowId;
638
- delete safeOptions.depth;
639
- delete safeOptions.maxSubagentDepth;
640
- delete safeOptions.configCwd;
641
- // Also internal: it names a transcript directory, so a forged value would
642
- // be a path-traversal primitive.
643
- delete safeOptions.rootSessionId;
644
- // Worse than rootSessionId: this one names a file to OPEN and replay as a
645
- // conversation. Only the mention dispatcher may set it, and only from a
646
- // path this extension itself recorded — never from anything a caller sent.
647
- delete safeOptions.resumeSessionFile;
648
- // Bypasses handle allocation, so a forged value would duplicate a live
649
- // agent's name and make `@handle` ambiguous. Same rule: dispatcher only.
650
- delete safeOptions.reclaim;
651
- // Every spawn through here is DETACHED — the caller gets an id back and
652
- // awaits nothing. A forged `blocking` would charge it to the foreground
653
- // pool and could defer it behind a queue whose gate nobody is holding.
654
- delete safeOptions.blocking;
655
- return spawnResolved(piRef, ctxRef, type, prompt, safeOptions);
656
- };
657
- /**
658
- * Resolve a tool's `agent_id` as an id OR a handle, so the model addresses
659
- * agents by the same names the user types. Ids are tried first, keeping the
660
- * existing behaviour exact — a handle is only consulted when the string is
661
- * not an id at all. Only live records: a tombstone has nothing to steer and
662
- * no result to read. Callers still enforce the nested-ownership rejection.
663
- */
664
- const resolveAgentRef = (ref) => {
665
- const byId = manager.getRecord(ref);
666
- if (byId)
667
- return byId;
668
- const resolved = manager.resolveMention(ref);
669
- return resolved?.kind === "live" ? resolved.record : undefined;
670
- };
671
- const registryEntry = {
672
- waitForAll: () => manager.waitForAll(),
673
- hasRunning: () => manager.hasRunning(),
674
- spawn: spawnTopLevel,
675
- getRecord: (id) => {
676
- const record = manager.getRecord(id);
677
- return record !== undefined && isTopLevelAgent(record) ? record : undefined;
678
- },
679
- };
680
- const ownsManagerRegistry = globalThis[MANAGER_KEY] === undefined;
681
- if (ownsManagerRegistry) {
682
- globalThis[MANAGER_KEY] = registryEntry;
683
- }
684
- // --- Cross-extension RPC via pi.events ---
685
- let currentCtx;
686
- // RPC handlers + the `subagents:ready` broadcast are wired on `session_start`
687
- // (a bound lifecycle event), not at factory time. pi runs every extension
688
- // factory before the `extensions:` filter and only fires lifecycle events for
689
- // survivors, so a child session that filtered pi-subagents out never reaches
690
- // session_start — and must not advertise or answer RPC it can't service
691
- // (currentCtx would stay undefined → spawn always "No active session"). Gating
692
- // here makes a filtered session behave like an absent one (#142).
693
- let rpcHandle;
694
- /** Whether the `@handle` autocomplete wrapper has been stacked on pi's provider. */
695
- let mentionProviderRegistered = false;
696
- // ---- Subagent scheduler ----
697
- // Session-scoped: store is constructed inside session_start once sessionId
698
- // is available. Mirrors pi-chonky-tasks's session-scoped task store —
699
- // schedules reset on /new, restore on /resume.
700
- const scheduler = new SubagentScheduler();
701
- function startScheduler(ctx) {
702
- try {
703
- const sessionId = ctx.sessionManager?.getSessionId?.();
704
- if (!sessionId)
705
- return; // sessionId not yet available — try again on next event
706
- const path = resolveStorePath(ctx.cwd, sessionId);
707
- const store = new ScheduleStore(path);
708
- scheduler.start(pi, ctx, manager, store);
709
- pi.events.emit("subagents:scheduler_ready", { sessionId, jobCount: store.list().length });
710
- }
711
- catch (err) {
712
- // Scheduling is non-essential — log and move on so the rest of the
713
- // extension keeps working if e.g. .pi/ is unwritable.
714
- console.warn("[pi-subagents] Failed to start scheduler:", err);
715
- }
716
- }
717
- // Capture ctx from session_start for RPC spawn handler + start the scheduler.
718
- // This also wires the RPC handlers and broadcasts readiness — on the first
719
- // bound session_start, so a filtered-out activation never advertises (#142).
720
- pi.on("session_start", async (_event, ctx) => {
721
- currentCtx = ctx;
722
- manager.restoreRecovered(ctx.cwd);
723
- const branchEntries = ctx.sessionManager?.getBranch?.() ?? [];
724
- const restoredRecords = branchEntries
725
- .filter((entry) => entry?.customType === "subagents:record" && entry?.data && typeof entry.data.id === "string")
726
- .map((entry) => entry.data);
727
- manager.restoreCompleted(restoredRecords);
728
- historyAgentSelection.id = undefined;
729
- historyAgentSelection.index = 0;
730
- runningAgentSelection.id = undefined;
731
- runningAgentSelection.index = 0;
732
- if (ctx.hasUI && (ctx.mode === undefined || ctx.mode === "tui")) {
733
- widget.setUICtx(ctx.ui);
734
- widget.update();
735
- }
736
- manager.clearCompleted(true);
737
- // Guard mirrors the `!scheduler.isActive()` pattern below: session_start
738
- // fires once per activation, but a double-bind must not leak listeners.
739
- if (!rpcHandle) {
740
- rpcHandle = registerRpcHandlers({
741
- events: pi.events,
742
- pi,
743
- getCtx: () => currentCtx,
744
- manager: {
745
- spawn: spawnTopLevel,
746
- awaitStartup: (id) => manager.awaitStartup(id),
747
- getRecord: (id) => manager.getRecord(id),
748
- // Unguarded on purpose: the stop handler now runs the top-level check
749
- // itself off `getRecord`, and reports the refusal instead of the
750
- // "Agent not found" a false from here used to be read as.
751
- abort: (id) => manager.abort(id),
752
- consumeResult: (id) => {
753
- const record = resolveAgentRef(id);
754
- // Same guard as get_subagent_result: a running agent has no result
755
- // to consume, and its notification is still the caller's only
756
- // signal that it finished.
757
- if (!record || record.parentAgentId)
758
- return false;
759
- if (record.status === "running" || record.status === "queued")
760
- return false;
761
- record.resultConsumed = true;
762
- cancelNudge(record.id);
763
- return true;
764
- },
765
- },
766
- });
767
- // Broadcast readiness so extensions loaded alongside us can discover us.
768
- // Emitting after all factories have run (rather than at factory time)
769
- // also avoids the race where a consumer loaded after us misses the event.
770
- pi.events.emit("subagents:ready", {});
771
- }
772
- if (isSchedulingEnabled() && !scheduler.isActive())
773
- startScheduler(ctx);
774
- // Stack `@handle` suggestions on pi's built-in autocomplete. Registered at
775
- // most once per activation: pi appends wrappers to a list it never prunes,
776
- // so a second call would layer a duplicate provider on the first. TUI only
777
- // — print mode has no such method, and RPC mode's is a no-op.
778
- if (ctx.mode === "tui" && !mentionProviderRegistered && typeof ctx.ui.addAutocompleteProvider === "function") {
779
- mentionProviderRegistered = true;
780
- ctx.ui.addAutocompleteProvider(current => createMentionProvider(current,
781
- // Plain text, not renderAgentName: the same label the widget shows,
782
- // but the autocomplete description cannot carry ANSI.
783
- () => mentionRoster(manager, mentionTypes(), type => getConfig(type).displayName), isAgentMentionsEnabled));
784
- }
785
- // Last, and only here: CLI flag values are applied by the host AFTER every
786
- // extension factory has run, so this is the earliest point the real value
787
- // exists. Detached inside — a workflow must not hold up session startup.
788
- resolveWorkflowCollisions(ctx);
789
- runWorkflowFlag(ctx);
790
- });
791
- /** Agent types `@` can start, in the shape the roster wants. */
792
- const mentionTypes = () => getAvailableTypes().map(name => ({ name, description: getAgentConfig(name)?.description ?? name }));
793
- /**
794
- * `@handle message` typed at the prompt addresses that agent instead of the
795
- * main model — Claude Code's prompt mention, same grammar (see mention.ts).
796
- *
797
- * The handle names the *agent*, not one process, so one syntax covers its
798
- * whole lifecycle: message it while it runs, resume it once it has finished,
799
- * start it if it never ran. Everything that isn't an agent mention falls
800
- * through untouched, which is what keeps `@src/foo.ts summarize this`, a bare
801
- * `@handle`, and ordinary prose working. A delivered mention costs no
802
- * main-model turn; the answer arrives through the ordinary completion
803
- * notification either way.
804
- */
805
- pi.on("input", async (event, ctx) => {
806
- // Never hijack text the extension layer itself submitted (pi.sendMessage,
807
- // scheduled prompts) — only something a person typed can be a mention.
808
- if (event.source === "extension" || !isAgentMentionsEnabled())
809
- return { action: "continue" };
810
- // Claiming the turn is TUI only, matching the `@` completion that teaches
811
- // the syntax. Pi defaults `session.prompt()` to source "interactive", so a
812
- // headless `pi -p "@explore …"` reaches here too — and claiming it would
813
- // answer with silence, which the background hold cannot fix: `handled`
814
- // returns from prompt() before any turn starts, so the loop that patch wraps
815
- // never runs (it holds subagents spawned by the Agent tool MID-turn, a
816
- // different path). The agent would detach, `ctx.ui.notify` is a no-op
817
- // outside the TUI, and print mode would exit having printed nothing.
818
- //
819
- // `model` mode has none of that problem: it queues a reminder and lets the
820
- // turn run, so the answer is the model's own, printed as usual. It is the
821
- // only branch allowed to act headlessly; everything else falls through to
822
- // the main model exactly as it did before mentions existed.
823
- const canDispatchDirectly = ctx.mode === "tui";
824
- if (!canDispatchDirectly && getAgentMentionMode() !== "model")
825
- return { action: "continue" };
826
- const mention = parseMention(event.text);
827
- if (!mention)
828
- return { action: "continue" };
829
- // `@main` addresses the main conversation, never a subagent — the one name
830
- // `assignHandle` refuses to allocate. An explicit escape hatch for text
831
- // that would otherwise read as a mention, so the prefix is dropped and the
832
- // rest goes to the model with its attachments intact.
833
- if (isReservedHandle(mention.handle)) {
834
- return { action: "transform", text: mention.message, ...(event.images && { images: event.images }) };
835
- }
836
- // As typed first, so an agent actually called `agent-foo` wins over Claude
837
- // Code's `@agent-` + `foo` spelling rather than being shadowed by it.
838
- const alias = stripAgentPrefix(mention.handle);
839
- const resolved = manager.resolveMention(mention.handle)
840
- ?? (alias ? manager.resolveMention(alias) : undefined);
841
- // Steering and resuming are direct in every mode, so headless they are not
842
- // available at all. Falling through here rather than dropping to the start
843
- // path below matters: the handle names an agent that already exists, and
844
- // asking the model to start another one is not what was typed.
845
- if (resolved && !canDispatchDirectly)
846
- return { action: "continue" };
847
- if (resolved?.kind === "live") {
848
- const record = resolved.record;
849
- const target = `@${record.alias ?? record.handle ?? mention.handle}`;
850
- if (record.status === "running" || record.status === "queued") {
851
- // Steering interrupts after the current tool call, exactly like the
852
- // steer_subagent tool. Un-consume the result so the agent's reply to
853
- // this message is still relayed even if the LLM read its last answer.
854
- record.resultConsumed = false;
855
- manager.steer(record.id, mention.message);
856
- pi.events.emit("subagents:steered", { id: record.id, message: mention.message });
857
- ctx.ui.notify(`Sent to ${target}`, "info");
858
- return { action: "handled" };
859
- }
860
- if (record.session) {
861
- // Both derived from the record's OWN type: a mention names an existing
862
- // agent, so its frontmatter is what governs — `output_transcript: false`
863
- // must keep holding, since record.outputFile is the sole gate every
864
- // downstream consumer keys off and a resume must not re-open it.
865
- const config = getAgentConfig(record.type);
866
- const resumedRecord = await startBackgroundResume(ctx, record, mention.message, {
867
- outputTranscript: config?.outputTranscript ?? getOutputTranscriptDefault(),
868
- maxTurns: normalizeMaxTurns(config?.maxTurns ?? getDefaultMaxTurns()),
869
- });
870
- ctx.ui.notify(resumedRecord ? `Resuming ${target}` : `Could not resume ${target} — it is still running.`, resumedRecord ? "info" : "warning");
871
- return { action: "handled" };
872
- }
873
- // A live record with no session never got far enough to continue, so it
874
- // falls through to the start-fresh path below, like Claude's
875
- // `no_transcript`.
876
- }
877
- // Evicted, but its conversation is still on disk: reopen it. This is an
878
- // ordinary spawn carrying a session file, so the new record picks up the
879
- // widget row, transcript and completion notification unchanged —
880
- // and `reclaim` hands it back the names the tombstone was holding.
881
- if (resolved?.kind === "tombstone") {
882
- const entry = resolved.entry;
883
- const target = `@${entry.alias ?? entry.handle}`;
884
- // Checked here rather than left to SessionManager.open: that runs inside
885
- // runAgent, whose rejection lands on the record as an agent error, not in
886
- // the catch below. A `/new` in another pi window or a manual delete makes
887
- // the conversation unrecoverable (Claude Code's `not_reachable`), so drop
888
- // the entry — a row that can only ever fail is worse than none — and say
889
- // so rather than quietly sending this message to an unrelated agent.
890
- if (!existsSync(entry.sessionFile)) {
891
- manager.dropTombstone(entry.handle);
892
- ctx.ui.notify(`Could not resume ${target} — its session is gone.`, "warning");
893
- return { action: "handled" };
894
- }
895
- // The Agent tool deliberately falls back to general-purpose for a type it
896
- // cannot resolve (#183), which covers a deleted file AND a merely
897
- // disabled one. A resume must not inherit that: reopening this
898
- // conversation under a different agent's prompt and tools is not
899
- // continuing it, and the new record would re-tombstone under the
900
- // substitute, so the handle would never find its way back.
901
- reloadCustomAgents();
902
- const dispatch = resolveSpawnType(entry.type);
903
- if (!dispatch.ok || dispatch.fellBackFrom !== undefined) {
904
- // The tombstone stays: re-enabling the agent makes the handle work
905
- // again, which a drop would foreclose.
906
- ctx.ui.notify(`Could not resume ${target} — the ${entry.type} agent is no longer available.`, "warning");
907
- return { action: "handled" };
908
- }
909
- try {
910
- // spawnResolved, not spawnTopLevel: the latter strips
911
- // `resumeSessionFile` and `reclaim` as untrusted. This path is the
912
- // exception — both come from a tombstone this extension wrote.
913
- const id = spawnResolved(pi, ctx, dispatch.type, mention.message, {
914
- description: entry.description,
915
- reclaim: { handle: entry.handle, alias: entry.alias },
916
- resumeSessionFile: entry.sessionFile,
917
- isBackground: true,
918
- });
919
- // The agent may still be starting — wait, so a startup failure lands in
920
- // the catch below instead of being announced as a resume.
921
- await manager.awaitStartup(id);
922
- // The tombstone deliberately stays. `resolveMention` prefers the live
923
- // record holding these same names, so it cannot shadow the resume — and
924
- // if this run dies before establishing its own session, the original
925
- // transcript is still the right thing for the next mention to reopen.
926
- // Once the resumed record is evicted it overwrites this entry in place,
927
- // keyed by the same handle, so nothing accumulates.
928
- ctx.ui.notify(`Resuming ${target}`, "info");
929
- }
930
- catch (err) {
931
- // The type is already settled above, so what is left is a spawn-time
932
- // failure: a strict worktree-isolation error, an unusable cwd.
933
- ctx.ui.notify(`Could not resume ${target}: ${err instanceof Error ? err.message : String(err)}`, "warning");
934
- }
935
- return { action: "handled" };
936
- }
937
- // No agent under that handle — but the name may still be an agent type, in
938
- // which case the mention starts one.
939
- const typeHandle = mention.handle;
940
- const type = resolveHandleToType(typeHandle, getAvailableTypes())
941
- ?? (alias ? resolveHandleToType(alias, getAvailableTypes()) : undefined);
942
- if (!type)
943
- return { action: "continue" };
944
- // Claude Code never starts the agent itself: `@agent-<type>` becomes an
945
- // attachment asking the main model to do it, and the model writes the
946
- // agent's prompt from the conversation rather than forwarding the typed
947
- // text. That buys a real `Agent` tool call — transcript, per-tool widget
948
- // detail, tool-use-id correlation, join grouping — and a prompt with the
949
- // context a cold spawn lacks.
950
- //
951
- // It also costs a visible turn, spent narrating a decision the user already
952
- // made by typing the handle. So the turn is taken by a clone of this
953
- // conversation instead (mention-clone.ts): same messages, same system
954
- // prompt, off-screen, holding only the `Agent` tool. Nothing reaches the
955
- // chat, and what it starts is an ordinary top-level agent.
956
- if (getAgentMentionMode() === "model") {
957
- const label = `@${handleBase(type)}`;
958
- // "Prompting", not "Starting": in this mode nothing starts until the
959
- // off-screen clone has taken a whole model turn writing the agent's
960
- // prompt, and that wait is the one thing the chat cannot show. `direct`
961
- // says "Started" because by then it has. The distinction tells the user
962
- // which of the two they are waiting on.
963
- ctx.ui.notify(`Prompting ${label}…`, "info");
964
- // Not awaited: the clone runs a full model turn, and prompt() is blocked
965
- // until this hook returns. The user gets their prompt back immediately
966
- // and the agent appears in the widget when it starts.
967
- void runMentionClone({ ctx, type, message: mention.message, agentTool: registeredAgentTool })
968
- .then(async (result) => {
969
- if (result.spawned)
970
- return;
971
- // A clone that could not run must not swallow the mention: start the
972
- // agent the direct way rather than leaving the user with a toast and
973
- // nothing running.
974
- try {
975
- const id = spawnTopLevel(pi, ctx, type, mention.message, {
976
- description: describeMention(mention.message),
977
- isBackground: true,
978
- });
979
- // Same reason as the direct path below: the agent may still be
980
- // starting, and a failure there must reach this catch.
981
- await manager.awaitStartup(id);
982
- ctx.ui.notify(`Started ${label} directly — ${result.error}`, "warning");
983
- }
984
- catch (err) {
985
- ctx.ui.notify(`Could not start ${label}: ${err instanceof Error ? err.message : String(err)}`, "error");
986
- }
987
- });
988
- return { action: "handled" };
989
- }
990
- try {
991
- // Nothing else to pass: runAgent resolves model, thinking and max turns
992
- // from the agent's own config when the spawn omits them, and the
993
- // manager's onStart/onComplete callbacks own the widget and completion
994
- // notification — the same contract the scheduler and
995
- // cross-extension RPC spawns run under.
996
- const id = spawnTopLevel(pi, ctx, type, mention.message, {
997
- description: describeMention(mention.message),
998
- isBackground: true,
999
- });
1000
- // The agent may still be starting (a worktree copy is an awaited git
1001
- // call) — report a failure that lands there as a failed start, not as a
1002
- // "Started" toast for an agent that never ran.
1003
- await manager.awaitStartup(id);
1004
- ctx.ui.notify(`Started @${handleBase(type)}`, "info");
1005
- }
1006
- catch (err) {
1007
- ctx.ui.notify(`Could not start @${handleBase(type)}: ${err instanceof Error ? err.message : String(err)}`, "error");
1008
- }
1009
- return { action: "handled" };
1010
- });
1011
- pi.on("session_before_switch", () => {
1012
- manager.clearCompleted(true);
1013
- scheduler.stop();
1014
- });
1015
- // On shutdown, abort all agents immediately and clean up.
1016
- // If the session is going down, there's nothing left to consume agent results.
1017
- pi.on("session_shutdown", async () => {
1018
- rpcHandle?.unsubSpawn();
1019
- rpcHandle?.unsubStop();
1020
- rpcHandle?.unsubPing();
1021
- rpcHandle?.unsubConsume();
1022
- rpcHandle = undefined;
1023
- currentCtx = undefined;
1024
- // Only release the global slot if this activation claimed it — a child
1025
- // session's shutdown must not delete the root session's registry entry.
1026
- if (ownsManagerRegistry && globalThis[MANAGER_KEY] === registryEntry) {
1027
- delete globalThis[MANAGER_KEY];
1028
- }
1029
- scheduler.stop();
1030
- // Before abortAll, and not folded into it: a workflow owns a worker thread
1031
- // as well as its children, and only its own signal terminates that.
1032
- for (const task of workflowTasks.values())
1033
- task.abortController.abort();
1034
- workflowTasks.clear();
1035
- manager.abortAll();
1036
- for (const timer of pendingNudges.values())
1037
- clearTimeout(timer);
1038
- pendingNudges.clear();
1039
- widget.dispose();
1040
- // Awaited: it emits `session_shutdown` into every retained child session so
1041
- // extensions bound there can release what they armed in `session_start` (#242).
1042
- // pi awaits this handler, and the process exits right after — unawaited, those
1043
- // handlers would never run. Internally bounded, so a hung one can't strand quit.
1044
- await manager.dispose(pi);
1045
- });
1046
- // Live widget: show running agents above editor.
1047
- // widgetMode (default "background") selects what the widget shows: "all" =
1048
- // every agent; "background" = hide foreground (they already render inline as
1049
- // the Agent tool result, so showing them here too is a duplicate, #118), keep
1050
- // everything else; "off" = hide the widget entirely. Read live at render time.
1051
- let widgetMode = "background";
1052
- function getWidgetMode() { return widgetMode; }
1053
- const widget = new AgentWidget(manager, agentActivity, getWidgetMode, {
1054
- canOpenHistory: (record) => canOpenAgentHistory(record, currentCtx?.cwd),
1055
- onOpen: (record) => {
1056
- if (currentCtx)
1057
- void viewAgentConversation(currentCtx, record);
1058
- },
1059
- showCost: isShowCostEnabled,
1060
- }, isShowModelEnabled);
1061
- function setWidgetMode(m) { widgetMode = m; widget.update(); }
1062
- // Claude Code-style `@handle message` prompt mentions. Read live by both the
1063
- // `input` hook and the stacked autocomplete provider, so the toggle applies
1064
- // immediately — the provider itself can never be unregistered (pi's wrapper
1065
- // list is append-only), it just delegates everything when this is off.
1066
- let agentMentionMode = "model";
1067
- function getAgentMentionMode() { return agentMentionMode; }
1068
- function setAgentMentionMode(mode) { agentMentionMode = mode; }
1069
- // `model` and `direct` differ only in who starts a not-yet-running agent, so
1070
- // everything that just asks "are mentions live at all" — the suggestion list,
1071
- // the steer and resume branches — reads this instead of the mode.
1072
- function isAgentMentionsEnabled() { return agentMentionMode !== "off"; }
1073
- // Project/global default for writing the subagent .output transcript lives in
1074
- // output-file.ts (both spawn paths read it). A custom agent's
1075
- // `output_transcript` frontmatter overrides it per spawn; when the frontmatter
1076
- // is silent, this default applies. Read live at spawn time.
1077
- // ---- Join mode configuration ----
1078
- let defaultJoinMode = 'smart';
1079
- function getDefaultJoinMode() { return defaultJoinMode; }
1080
- function setDefaultJoinMode(mode) { defaultJoinMode = mode; }
1081
- // What an unqualified top-level spawn means. Defaults to background,
1082
- // following Claude Code; `backgroundByDefault: false` restores the previous
1083
- // foreground default. Nested spawns ignore this — see nested-tools.ts.
1084
- let backgroundByDefault = true;
1085
- function getBackgroundByDefault() { return backgroundByDefault; }
1086
- function setBackgroundByDefault(b) { backgroundByDefault = b; }
1087
- // Master switch for the schedule subagent feature. Defaults to enabled.
1088
- // Read once at extension init (before tool registration) so the Agent tool's
1089
- // param schema reflects the persisted setting. Runtime toggles via /agents
1090
- // → Settings short-circuit the menu entry + the execute-time addJob path
1091
- // immediately, but the schema-level removal only takes effect on next
1092
- // extension load (next pi session). Documented in CHANGELOG/README.
1093
- let schedulingEnabled = true;
1094
- function isSchedulingEnabled() { return schedulingEnabled; }
1095
- function setSchedulingEnabled(b) { schedulingEnabled = b; }
1096
- // Master switch for scripted workflows. Defaults to ON. Off means the
1097
- // `SubagentWorkflow` tool is never registered: the model is not told the
1098
- // feature exists (zero context cost) and has nothing to call. The
1099
- // `/agents → Workflows` view and `--subagents-workflow-file` are refused too, so
1100
- // there is no second door into the same machinery.
1101
- //
1102
- // `workflowsPinned` records that the answer came from the user — a boolean in
1103
- // subagents.json, or the settings toggle — rather than from this default. It
1104
- // is what `resolveWorkflowCollisions` checks before yielding to another
1105
- // extension's workflow tool: a default may be overridden by what else is
1106
- // loaded, an explicit choice may not.
1107
- let workflowsEnabled = true;
1108
- let workflowsPinned = false;
1109
- function isWorkflowsEnabled() { return workflowsEnabled; }
1110
- function isWorkflowsPinned() { return workflowsPinned; }
1111
- function setWorkflowsEnabled(b) {
1112
- workflowsEnabled = b;
1113
- workflowsPinned = true;
1114
- }
1115
- // ---- Disable default agents configuration ----
1116
- // When enabled, the three hardcoded default agents (general-purpose, Explore,
1117
- // Plan) are not registered. User-defined agents from project/global custom
1118
- // agent dirs are completely unaffected — only DEFAULT_AGENTS are suppressed.
1119
- // Defaults to false; opt-in via `/agents → Settings` or subagents.json.
1120
- // State lives in agent-types.ts (isDefaultsDisabled) because registerAgents
1121
- // needs it; this wrapper just re-registers after flipping it.
1122
- function setDisableDefaultAgents(b) {
1123
- setDefaultsDisabled(b);
1124
- reloadCustomAgents(); // re-register with new setting
1125
- }
1126
- // ---- Agent tool description mode ----
1127
- // "full" (default) keeps the rich Claude Code-style description; "compact"
1128
- // swaps in a ~75% smaller one for small/local models (#91). Read once at
1129
- // tool registration — flipping it applies on the next pi session.
1130
- let toolDescriptionMode = "full";
1131
- function getToolDescriptionMode() { return toolDescriptionMode; }
1132
- function setToolDescriptionMode(mode) { toolDescriptionMode = mode; }
1133
- // ---- Batch tracking for smart join mode ----
1134
- // Collects background agent IDs spawned in the current turn for smart grouping.
1135
- // Uses a debounced timer: each new agent resets the 100ms window so that all
1136
- // parallel tool calls (which may be dispatched across multiple microtasks by the
1137
- // framework) are captured in the same batch.
1138
- let currentBatchAgents = [];
1139
- let batchFinalizeTimer;
1140
- let batchCounter = 0;
1141
- /** Finalize the current batch: if 2+ smart-mode agents, register as a group. */
1142
- function finalizeBatch() {
1143
- batchFinalizeTimer = undefined;
1144
- const batchAgents = [...currentBatchAgents];
1145
- currentBatchAgents = [];
1146
- const smartAgents = batchAgents.filter(a => a.joinMode === 'smart' || a.joinMode === 'group');
1147
- if (smartAgents.length >= 2) {
1148
- const groupId = `batch-${++batchCounter}`;
1149
- const ids = smartAgents.map(a => a.id);
1150
- groupJoin.registerGroup(groupId, ids);
1151
- // Retroactively process agents that already completed during the debounce window.
1152
- // Their onComplete fired but was deferred (agent was in currentBatchAgents),
1153
- // so we feed them into the group now.
1154
- for (const id of ids) {
1155
- const record = manager.getRecord(id);
1156
- if (!record)
1157
- continue;
1158
- record.groupId = groupId;
1159
- if (record.completedAt != null && !record.resultConsumed) {
1160
- groupJoin.onAgentComplete(record);
1161
- }
1162
- }
1163
- }
1164
- else {
1165
- // No group formed — send individual nudges for any agents that completed
1166
- // during the debounce window and had their notification deferred.
1167
- for (const { id } of batchAgents) {
1168
- const record = manager.getRecord(id);
1169
- if (record?.completedAt != null && !record.resultConsumed) {
1170
- sendIndividualNudge(record);
1171
- }
1172
- }
1173
- }
1174
- }
1175
- /**
1176
- * Launch a detached resume of an existing agent and wire everything a
1177
- * re-running agent needs: transcript anchoring, activity tracking, join-mode
1178
- * batching, the widget refresh, and the `subagents:created` event.
1179
- *
1180
- * Shared by the Agent tool's `resume` + `run_in_background` branch and the
1181
- * `@handle message` prompt mention — they differ only in how they report the
1182
- * outcome. Returns the record, or undefined when the manager refused because
1183
- * the agent is still running (see AgentManager.resume).
1184
- *
1185
- * Callers must have already established that the record has a session.
1186
- */
1187
- async function startBackgroundResume(ctx, existing, prompt, opts) {
1188
- const id = existing.id;
1189
- const joinMode = resolveJoinMode(defaultJoinMode, true);
1190
- // Assigned unconditionally: the completion notification carries this as
1191
- // `<tool-use-id>`, so a mention-resume (which passes none) has to CLEAR the
1192
- // id left by the spawn that created the record. Keeping it would point the
1193
- // orchestrator's new result at a tool call that was answered runs ago.
1194
- existing.toolCallId = opts.toolCallId;
1195
- if (joinMode)
1196
- existing.joinMode = joinMode;
1197
- // Reuse the agent's transcript rather than starting a fresh one: the
1198
- // path is deterministic per agent+session, so writing an initial entry
1199
- // would truncate the previous run's turns (see ensureOutputFile).
1200
- if (opts.outputTranscript) {
1201
- existing.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
1202
- ensureOutputFile(existing.outputFile);
1203
- }
1204
- // Anchor streaming past the turns already on disk, captured BEFORE the
1205
- // run starts. The resumed prompt lands as an ordinary user message at
1206
- // this index, so it is written exactly once.
1207
- const transcriptAnchor = existing.session?.messages.length ?? 0;
1208
- const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(opts.maxTurns);
1209
- // resumeAgent has no onSessionCreated — the session predates this run —
1210
- // so seed it directly, or the widget shows no context % for the agent.
1211
- bgState.session = existing.session;
1212
- // No `signal`: a background spawn deliberately omits it, and a detached
1213
- // resume must behave the same. Passing it would abort this agent when
1214
- // the parent turn is interrupted (user Esc), while agents started with
1215
- // run_in_background in that same turn keep going.
1216
- const record = await manager.resume(id, prompt, undefined, {
1217
- isBackground: true,
1218
- onToolActivity: bgCallbacks.onToolActivity,
1219
- onAssistantUsage: bgCallbacks.onAssistantUsage,
1220
- // Fires when the run actually starts — immediately, or on queue
1221
- // drain. Wiring it here (rather than after resume() returns) means a
1222
- // resume stopped while still queued never started streaming, so
1223
- // there is no subscription left behind for a later run to trip over.
1224
- onStarted: () => {
1225
- const rec = manager.getRecord(id);
1226
- if (rec?.session && rec.outputFile) {
1227
- rec.outputCleanup = streamToOutputFile(rec.session, rec.outputFile, id, ctx.cwd, transcriptAnchor);
1228
- }
1229
- },
1230
- });
1231
- if (!record)
1232
- return undefined;
1233
- if (joinMode != null && joinMode !== 'async') {
1234
- currentBatchAgents.push({ id, joinMode });
1235
- if (batchFinalizeTimer)
1236
- clearTimeout(batchFinalizeTimer);
1237
- batchFinalizeTimer = setTimeout(finalizeBatch, 100);
1238
- }
1239
- agentActivity.set(id, bgState);
1240
- // This agent already finished once, so the widget holds a finished-age
1241
- // for it that is past the linger limit — without clearing it, the
1242
- // resumed run's ✓/✗ line never renders and the agent just vanishes.
1243
- widget.markRunning(id);
1244
- widget.ensureTimer();
1245
- widget.update();
1246
- // Resume ignores subagent_type (the record keeps the type it was
1247
- // spawned with), so report the record's own identity — a "created"
1248
- // event carrying the caller's type would re-register the agent under
1249
- // the wrong one in cross-extension mirrors keyed by id.
1250
- pi.events.emit("subagents:created", {
1251
- id,
1252
- type: existing.type,
1253
- description: existing.description,
1254
- isBackground: true,
1255
- });
1256
- return record;
1257
- }
1258
- // Grab UI context from first tool execution + clear lingering widget on new turn
1259
- pi.on("tool_execution_start", async (_event, ctx) => {
1260
- if (ctx.hasUI && (ctx.mode === undefined || ctx.mode === "tui"))
1261
- widget.setUICtx(ctx.ui);
1262
- widget.onTurnStart();
1263
- });
1264
- /** Build the full type list text dynamically from available agents only. */
1265
- const buildTypeListText = () => {
1266
- const available = getAvailableTypes();
1267
- return available.map((name) => {
1268
- const cfg = getAgentConfig(name);
1269
- const modelSuffix = cfg?.model ? ` (${getModelLabelFromConfig(cfg.model)})` : "";
1270
- const toolsSuffix = ` (Tools: ${formatToolsSuffix(cfg)})`;
1271
- return `- ${name}: ${cfg?.description ?? name}${modelSuffix}${toolsSuffix}`;
1272
- }).join("\n");
1273
- };
1274
- /** First sentence of an agent description — for the compact type list. */
1275
- const firstSentence = (text) => {
1276
- const match = text.match(/^.*?[.!?](?=\s|$)/s);
1277
- return (match ? match[0] : text).replace(/\s+/g, " ").trim();
1278
- };
1279
- /** Compact type list: one line per agent, first sentence only. */
1280
- const buildCompactTypeListText = () => getAvailableTypes().map((name) => {
1281
- const cfg = getAgentConfig(name);
1282
- return `- ${name}: ${firstSentence(cfg?.description ?? name)} (Tools: ${formatToolsSuffix(cfg)})`;
1283
- }).join("\n");
1284
- /** Derive a short model label from a model string. */
1285
- function getModelLabelFromConfig(model) {
1286
- // Strip provider prefix (e.g. "anthropic/claude-sonnet-4-6" → "claude-sonnet-4-6")
1287
- const name = model.includes("/") ? model.split("/").pop() : model;
1288
- // Strip trailing date suffix (e.g. "claude-haiku-4-5-20251001" → "claude-haiku-4-5")
1289
- return name.replace(/-\d{8}$/, "");
1290
- }
1291
- // Apply persisted settings on startup and emit `subagents:settings_loaded`.
1292
- // Global + project merged; missing → defaults; corrupt file emits a warning
1293
- // to stderr and falls back to defaults.
1294
- applyAndEmitLoaded({
1295
- setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
1296
- setMaxConcurrentForeground: (n) => manager.setMaxConcurrentForeground(n),
1297
- setDefaultMaxTurns,
1298
- setGraceTurns,
1299
- setDefaultJoinMode,
1300
- setBackgroundByDefault,
1301
- setSchedulingEnabled,
1302
- setScopeModels: setScopeModelsEnabled,
1303
- setStrictAgentFiles: (b) => { strictAgentFiles = b; },
1304
- setDisableDefaultAgents: setDisableDefaultAgents,
1305
- setToolDescriptionMode: setToolDescriptionMode,
1306
- setAgentMentions: setAgentMentionMode,
1307
- setRememberAgents,
1308
- setWidgetMode: setWidgetMode,
1309
- setOutputTranscript: setOutputTranscriptDefault,
1310
- setWorktreeIsolation: setWorktreeIsolationEnabled,
1311
- setWorkflowsEnabled: setWorkflowsEnabled,
1312
- setMaxSubagentDepth: setMaxSubagentDepth,
1313
- setFallbackSubagent: setFallbackSubagent,
1314
- setReportUsage,
1315
- setShowCost,
1316
- setShowModel,
1317
- setViewerMarkdown,
1318
- }, (event, payload) => pi.events.emit(event, payload));
1319
- // ---- Agent tool ----
1320
- // Schedule param + its guideline are gated on `schedulingEnabled` (read once
1321
- // at registration; flipping the setting later requires next pi session for
1322
- // the schema to update). Defining the shape once and spreading it via Partial
1323
- // preserves Type.Object's inference when present and produces a
1324
- // `schedule`-free schema when absent — zero LLM-context cost in disabled mode.
1325
- const scheduleParamShape = {
1326
- schedule: Type.Optional(Type.String({
1327
- description: 'Opt-in only — fire later instead of now. Omit to run immediately (the default, almost always correct). ' +
1328
- 'Formats: 6-field cron ("0 0 9 * * 1" = 9am Mon), interval ("5m"/"1h"), one-shot ("+10m" or ISO). ' +
1329
- 'Forces run_in_background; incompatible with inherit_context and resume. Returns job ID.',
1330
- })),
1331
- };
1332
- const scheduleParam = isSchedulingEnabled() ? scheduleParamShape : {};
1333
- const scheduleGuideline = isSchedulingEnabled()
1334
- ? `\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.`
1335
- : "";
1336
- // Same trade as scheduleParam/scheduleGuideline above: `isolationParam` drops
1337
- // the field from the schema when the project set `worktreeIsolation: false`,
1338
- // so the prose has to go with it. Left in, it would teach the model to pass a
1339
- // parameter that isn't declared — accepted (TypeBox sets no
1340
- // `additionalProperties: false`) and then silently dropped by the resolver.
1341
- // With no per-result note by design, the model would have every reason to go
1342
- // on reporting a `pi-agent-*` branch that was never created.
1343
- const isolationGuideline = isWorktreeIsolationEnabled()
1344
- ? `\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.`
1345
- : "";
1346
- const isolationCompactGuideline = isWorktreeIsolationEnabled()
1347
- ? `\n- isolation: "worktree" gives the agent its own git worktree (removed on completion); changes land on a branch named in the result.`
1348
- : "";
1349
- // Compact Agent tool description (#91, `toolDescriptionMode: "compact"`) —
1350
- // the same load-bearing facts as the full version at ~75% fewer tokens, for
1351
- // small/local models. Per-option details live in the param descriptions.
1352
- const compactAgentToolDescription = `Launch an autonomous agent for complex, multi-step tasks. Agent types:
1353
- ${buildCompactTypeListText()}
1354
-
1355
- Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global).
1356
-
1357
- Notes:
1358
- - description: 3-5 words (shown in UI). Prompts must be self-contained — the agent has not seen this conversation.
1359
- - Parallel work: one message, multiple Agent calls — they run concurrently.
1360
- - 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.
1361
- - The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
1362
- - resume continues a previous agent by ID; steer_subagent messages a running one.${isolationCompactGuideline}`;
1363
- const fullAgentToolDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
1364
-
1365
- Available agent types and the tools they have access to:
1366
- ${buildTypeListText()}
1367
-
1368
- Custom agents can be defined in .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it.
1369
-
1370
- When using the Agent tool, specify a subagent_type parameter to select which agent type to use.
1371
-
1372
- ## When not to use
1373
-
1374
- If the target is already known, use a direct tool — \`read\` for a known path, \`grep\`/\`find\` for a specific symbol or string. Reserve this tool for open-ended questions that span the codebase, or tasks that match an available agent type.
1375
-
1376
- ## Usage notes
1377
-
1378
- - Always include a short (3-5 word) description summarizing what the agent will do (shown in UI).
1379
- - 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.
1380
- - 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.
1381
- - 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.
1382
- - 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.
1383
- - **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.
1384
- - **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.
1385
- - 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.
1386
- - Use steer_subagent to send mid-run messages to a running background agent.
1387
- - 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.
1388
- - If an agent's description says it should be used proactively, try to use it without the user having to ask for it first.
1389
- - Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
1390
- - Use thinking to control extended thinking level.
1391
- - Use inherit_context if the agent needs the parent conversation history.${isolationGuideline}${scheduleGuideline}
1392
-
1393
- ## Writing the prompt
1394
-
1395
- 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.
1396
- - Explain what you're trying to accomplish and why.
1397
- - Describe what you've already learned or ruled out.
1398
- - Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction.
1399
- - If you need a short response, say so ("report in under 200 words").
1400
- - Lookups: hand over the exact command. Investigations: hand over the question — prescribed steps become dead weight when the premise is wrong.
1401
-
1402
- Terse command-style prompts produce shallow, generic work.
1403
-
1404
- **Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.`;
1405
- // `toolDescriptionMode: "custom"` — user-authored description with live
1406
- // dynamic parts. Project file wins over global; missing/empty falls back to
1407
- // "full" (a stale fallback beats a blank tool description). Only the prose
1408
- // is customizable — the parameter schema stays code-owned.
1409
- const renderToolDescriptionTemplate = (template) => {
1410
- const vars = {
1411
- typeList: buildTypeListText,
1412
- compactTypeList: buildCompactTypeListText,
1413
- agentDir: getAgentDir,
1414
- isolationGuideline: () => isolationGuideline,
1415
- scheduleGuideline: () => scheduleGuideline,
1416
- };
1417
- // Replacement callback (not a string) — agent descriptions may contain `$&` etc.
1418
- return template.replace(/\{\{(\w+)\}\}/g, (raw, name) => {
1419
- if (vars[name])
1420
- return vars[name]();
1421
- console.warn(`[pi-subagents] agent-tool-description.md: unknown placeholder ${raw} left as-is`);
1422
- return raw;
1423
- });
1424
- };
1425
- const loadCustomToolDescription = () => {
1426
- for (const path of [
1427
- join(process.cwd(), ".pi", "agent-tool-description.md"),
1428
- join(getAgentDir(), "agent-tool-description.md"),
1429
- ]) {
1430
- try {
1431
- if (!existsSync(path))
1432
- continue;
1433
- const text = readFileSync(path, "utf-8").trim();
1434
- if (text)
1435
- return renderToolDescriptionTemplate(text);
1436
- console.warn(`[pi-subagents] ${path} is empty — ignoring`);
1437
- }
1438
- catch (err) {
1439
- console.warn(`[pi-subagents] failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`);
1440
- }
1441
- }
1442
- return undefined;
1443
- };
1444
- const agentToolDescription = (() => {
1445
- const mode = getToolDescriptionMode();
1446
- if (mode === "compact")
1447
- return compactAgentToolDescription;
1448
- if (mode === "custom") {
1449
- const custom = loadCustomToolDescription();
1450
- if (custom)
1451
- return custom;
1452
- console.warn('[pi-subagents] toolDescriptionMode is "custom" but no agent-tool-description.md found — using "full"');
1453
- }
1454
- return fullAgentToolDescription;
1455
- })();
1456
- // Held rather than registered inline: the mention clone reuses this exact
1457
- // definition, so the agent it starts is an ordinary top-level spawn instead
1458
- // of a second implementation that has to be kept in step with this one.
1459
- const agentTool = defineTool({
1460
- name: SUBAGENT_TOOL_NAMES.AGENT,
1461
- label: "Agent",
1462
- description: agentToolDescription,
1463
- promptSnippet: "Launch autonomous sub-agents for complex multi-step tasks",
1464
- promptGuidelines: [
1465
- "Use Agent with specialized agents when the task matches an agent type's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing — if you delegate research to a subagent, do not also perform the same searches yourself.",
1466
- "For broad codebase exploration or research, spawn Agent with an appropriate subagent_type (e.g. Explore). Otherwise use direct tools (read, grep, find) when the target is already known.",
1467
- "When an agent runs in the background, you will be notified on completion — do not poll or sleep waiting for it. Continue with other work instead.",
1468
- "Trust but verify: an agent's summary describes intent, not outcome. When an agent writes or edits code, check the actual changes before reporting work as done.",
1469
- ],
1470
- parameters: Type.Object({
1471
- prompt: Type.String({
1472
- description: "The task for the agent to perform.",
1473
- }),
1474
- description: Type.String({
1475
- description: "A short (3-5 word) description of the task (shown in UI).",
1476
- }),
1477
- name: Type.Optional(Type.String({
1478
- description: '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.',
1479
- })),
1480
- subagent_type: Type.String({
1481
- 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.`,
1482
- }),
1483
- model: Type.Optional(Type.String({
1484
- description: 'Optional model override. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to use the agent type\'s default.',
1485
- })),
1486
- thinking: Type.Optional(Type.String({
1487
- description: `Thinking level: ${THINKING_LEVELS.join(", ")}. Overrides agent default.`,
1488
- })),
1489
- max_turns: Type.Optional(Type.Number({
1490
- description: "Maximum number of agentic turns before stopping. Omit for unlimited (default).",
1491
- minimum: 1,
1492
- })),
1493
- run_in_background: Type.Optional(Type.Boolean({
1494
- 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.",
1495
- })),
1496
- resume: Type.Optional(Type.String({
1497
- 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.",
1498
- })),
1499
- isolated: Type.Optional(Type.Boolean({
1500
- description: "If true, agent gets no extension/MCP tools — only built-in tools.",
1501
- })),
1502
- inherit_context: Type.Optional(Type.Boolean({
1503
- description: "If true, fork parent conversation into the agent. Default: false (fresh context).",
1504
- })),
1505
- ...isolationParam(isWorktreeIsolationEnabled()),
1506
- ...scheduleParam,
1507
- }),
1508
- // ---- Custom rendering: Claude Code style ----
1509
- renderCall(args, theme, context) {
1510
- // A badge closes its own background, which would clear the tool block's row tint
1511
- // for the rest of the line, so the badge restores it. The tint is opened here too:
1512
- // the TUI's Box paints it, but HTML export takes it from CSS, and restoring a
1513
- // background the line never opened is what banded the export before. The line is
1514
- // deliberately left open — Box.applyBackgroundToLine pads to width and *then*
1515
- // wraps, so closing here would leave that padding untinted, and HTML export closes
1516
- // any open span per line anyway. No badge means no tint, so an uncolored agent
1517
- // renders exactly the line it always did.
1518
- const rowBackground = hasAgentBadge(args.subagent_type)
1519
- ? theme.getBgAnsi(context.isPartial ? "toolPendingBg" : context.isError ? "toolErrorBg" : "toolSuccessBg")
1520
- : "";
1521
- const desc = args.description ?? "";
1522
- const name = renderAgentName(args.subagent_type, theme, {
1523
- fallbackColor: "toolTitle",
1524
- restoreBackground: rowBackground,
1525
- bold: true,
1526
- });
1527
- return new Text(rowBackground + "▸ " + name + (desc ? " " + theme.fg("muted", desc) : ""), 0, 0);
1528
- },
1529
- renderResult(result, { expanded, isPartial }, theme, renderContext) {
1530
- const details = result.details;
1531
- const text = result.content[0]?.type === "text" ? result.content[0].text : "";
1532
- // Pi reports pre-execution failures (extension block, abort, argument
1533
- // validation) as `{ content: [reason], details: {} }` with isError set —
1534
- // no status to render, so show the reason instead of inventing one (#199).
1535
- if (renderContext.isError || !details?.status) {
1536
- return new Text(text, 0, 0);
1537
- }
1538
- // Helper: build "haiku · thinking: high · ↻5≤30 · 3 tool uses · 33.8k tokens" stats string
1539
- const stats = (d) => {
1540
- const parts = [];
1541
- if (d.modelName)
1542
- parts.push(d.modelName);
1543
- if (d.tags)
1544
- parts.push(...d.tags);
1545
- if (d.turnCount != null && d.turnCount > 0) {
1546
- parts.push(formatTurns(d.turnCount, d.maxTurns));
1547
- }
1548
- if (d.toolUses > 0)
1549
- parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
1550
- if (d.tokens)
1551
- parts.push(d.tokens);
1552
- if (showCost) {
1553
- const costText = formatCost(d.cost ?? 0);
1554
- if (costText)
1555
- parts.push(costText);
1556
- }
1557
- return parts.map(p => fgPreservingNestedStyles(theme, "dim", p)).join(" " + theme.fg("dim", "·") + " ");
1558
- };
1559
- // ---- While running (streaming) ----
1560
- if (isPartial || details.status === "running") {
1561
- const frame = SPINNER[details.spinnerFrame ?? 0];
1562
- const s = stats(details);
1563
- return renderRunningAgentStatus(frame, s, details.activity ?? "thinking…", theme);
1564
- }
1565
- // ---- Background agent launched ----
1566
- if (details.status === "background") {
1567
- return new Text(theme.fg("dim", ` ⎿ Running in background (ID: ${details.agentId})`), 0, 0);
1568
- }
1569
- // ---- Completed / Steered ----
1570
- if (details.status === "completed" || details.status === "steered") {
1571
- const duration = formatMs(details.durationMs);
1572
- const isSteered = details.status === "steered";
1573
- const icon = isSteered ? theme.fg("warning", "✓") : theme.fg("success", "✓");
1574
- const s = stats(details);
1575
- let line = icon + (s ? " " + s : "");
1576
- line += " " + theme.fg("dim", "·") + " " + theme.fg("dim", duration);
1577
- if (expanded) {
1578
- const resultText = result.content[0]?.type === "text" ? result.content[0].text : "";
1579
- if (resultText) {
1580
- const lines = resultText.split("\n").slice(0, 50);
1581
- for (const l of lines) {
1582
- line += "\n" + theme.fg("dim", ` ${l}`);
1583
- }
1584
- if (resultText.split("\n").length > 50) {
1585
- line += "\n" + theme.fg("muted", " ... (use get_subagent_result with verbose for full output)");
1586
- }
1587
- }
1588
- }
1589
- else {
1590
- const doneText = isSteered ? "Wrapped up (turn limit)" : "Done";
1591
- line += "\n" + theme.fg("dim", ` ⎿ ${doneText}`);
1592
- }
1593
- return new Text(line, 0, 0);
1594
- }
1595
- // ---- Stopped (user-initiated abort) ----
1596
- if (details.status === "stopped") {
1597
- const s = stats(details);
1598
- let line = theme.fg("dim", "■") + (s ? " " + s : "");
1599
- line += "\n" + theme.fg("dim", " ⎿ Stopped");
1600
- return new Text(line, 0, 0);
1601
- }
1602
- // Anything left ("queued", or a status added later) has no rendering of
1603
- // its own — the turn-limit wording below must not be the catch-all.
1604
- if (details.status !== "error" && details.status !== "aborted") {
1605
- return new Text(text, 0, 0);
1606
- }
1607
- // ---- Error / Aborted (hard max_turns) ----
1608
- const s = stats(details);
1609
- let line = theme.fg("error", "✗") + (s ? " " + s : "");
1610
- if (details.status === "error") {
1611
- line += "\n" + theme.fg("error", ` ⎿ Error: ${details.error ?? "unknown"}`);
1612
- }
1613
- else {
1614
- line += "\n" + theme.fg("warning", " ⎿ Aborted (max turns exceeded)");
1615
- }
1616
- return new Text(line, 0, 0);
1617
- },
1618
- // ---- Execute ----
1619
- execute: async (toolCallId, params, signal, onUpdate, ctx) => {
1620
- // Ensure we have UI context for widget rendering
1621
- widget.setUICtx(ctx.ui);
1622
- // Reload custom agents so new project/global .md files are picked up without restart
1623
- reloadCustomAgents();
1624
- const rawType = params.subagent_type;
1625
- // Single decision point for dispatch (#183): unknown, disabled and
1626
- // case-ambiguous types are refused here, BEFORE anything spawns, so a
1627
- // background or scheduled call can't start running the wrong agent while
1628
- // the caller is still unaware. `fallbackSubagent` decides whether an
1629
- // unresolvable type falls back or fails closed.
1630
- const dispatch = resolveSpawnType(rawType);
1631
- // `resume` replays a stored session and ignores `subagent_type` entirely,
1632
- // but the parameter is required by the schema — so gating it here would
1633
- // make a live agent unresumable the moment its type is deleted, disabled,
1634
- // or gains a case-clashing sibling. Only a real spawn is gated.
1635
- if (!dispatch.ok && !params.resume)
1636
- return textResult(dispatch.message);
1637
- const subagentType = dispatch.ok ? dispatch.type : rawType;
1638
- // What the caller actually asked for, named once: `fellBackFrom` is "" for
1639
- // a blank request, so reading it inline invites the `??`-vs-`||` slip that
1640
- // once persisted an empty type into a scheduled job.
1641
- const requestedType = (dispatch.ok && dispatch.fellBackFrom) || subagentType;
1642
- // Computed at resolution rather than after the run, so the background and
1643
- // schedule branches carry it too — previously it existed only on the
1644
- // foreground path. Resume deliberately doesn't: it replays the stored
1645
- // session and ignores `subagent_type` entirely, so a note about type
1646
- // substitution would be describing something that didn't happen.
1647
- const fallbackNote = dispatch.ok && dispatch.fellBackFrom !== undefined
1648
- ? `Note: Unknown agent type "${dispatch.fellBackFrom}" — using ${resolveType(subagentType) ? subagentType : "the fallback agent config"}.\n\n`
1649
- : "";
1650
- const displayName = getDisplayName(subagentType);
1651
- // Get agent config (if any)
1652
- const customConfig = getAgentConfig(subagentType);
1653
- const resolvedConfig = resolveAgentInvocationConfig(customConfig, params, {
1654
- worktreeAllowed: isWorktreeIsolationEnabled(),
1655
- defaultRunInBackground: getBackgroundByDefault(),
1656
- });
1657
- // Resolve model from agent config first; tool-call params only fill gaps.
1658
- let model = ctx.model;
1659
- if (resolvedConfig.modelInput) {
1660
- const resolved = resolveModel(resolvedConfig.modelInput, ctx.modelRegistry);
1661
- if (typeof resolved === "string") {
1662
- if (resolvedConfig.modelFromParams)
1663
- return textResult(resolved);
1664
- // config-specified: silent fallback to parent
1665
- }
1666
- else {
1667
- model = resolved;
1668
- }
1669
- }
1670
- // Scope validation: the effective resolved model is checked against the
1671
- // user's enabledModels list. Policy (hard error vs warn-and-proceed) lives
1672
- // in model-scope.ts so the nested delegation tools apply the same rule.
1673
- const scopeVerdict = checkModelScope({
1674
- model,
1675
- cwd: ctx.cwd,
1676
- modelRegistry: ctx.modelRegistry,
1677
- callerSupplied: resolvedConfig.modelFromParams,
1678
- agentLabel: customConfig?.displayName ?? subagentType,
1679
- modelInput: resolvedConfig.modelInput,
1680
- });
1681
- if (scopeVerdict.kind === "error")
1682
- return textResult(scopeVerdict.message);
1683
- if (scopeVerdict.kind === "warn")
1684
- ctx.ui.notify(scopeVerdict.message, "warning");
1685
- const thinking = resolvedConfig.thinking;
1686
- const inheritContext = resolvedConfig.inheritContext;
1687
- const runInBackground = resolvedConfig.runInBackground;
1688
- const isolated = resolvedConfig.isolated;
1689
- const isolation = resolvedConfig.isolation;
1690
- // Whether this spawn writes its .output transcript. Per-agent
1691
- // frontmatter (`output_transcript`) wins; otherwise the project/global
1692
- // default applies. `attachTranscript` below is the SOLE gate — every
1693
- // downstream consumer keys off record.outputFile being set, so no spawn
1694
- // path can re-enable the transcript by accident.
1695
- const outputTranscript = customConfig?.outputTranscript ?? getOutputTranscriptDefault();
1696
- const attachTranscript = (rec, agentId) => {
1697
- if (!rec || !outputTranscript)
1698
- return;
1699
- rec.outputFile = createOutputFilePath(ctx.cwd, agentId, ctx.sessionManager.getSessionId());
1700
- writeInitialEntry(rec.outputFile, agentId, params.prompt, ctx.cwd);
1701
- };
1702
- // Unconditional, not "only when it differs from the parent": a thinking
1703
- // level reads as a property of a model, and an agent that inherited the
1704
- // parent's model used to show the level with nothing to attach it to.
1705
- // This is the pre-session snapshot — agent-manager overwrites it with the
1706
- // effective values the moment a session reports them.
1707
- const { modelName, modelId } = model ? describeModel(model) : { modelName: undefined, modelId: undefined };
1708
- // What the caller SPELLED, kept only if it names a different model than the
1709
- // one that won. Model input is fuzzy — `"haiku"` and
1710
- // `"anthropic/claude-haiku-4-5"` are the same model — so comparing the two
1711
- // strings would disclose an override that never happened. A spelling that
1712
- // resolves to nothing is still worth disclosing: it cannot have taken effect.
1713
- const askedModel = ((asked) => {
1714
- if (!asked)
1715
- return undefined;
1716
- const resolvedAsked = resolveModel(asked, ctx.modelRegistry);
1717
- if (typeof resolvedAsked === "string")
1718
- return asked;
1719
- return resolvedAsked.provider === model?.provider && resolvedAsked.id === model?.id ? undefined : asked;
1720
- })(resolvedConfig.overridden?.model);
1721
- const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
1722
- const agentInvocation = {
1723
- modelName,
1724
- modelId,
1725
- thinking,
1726
- // Only set where the agent file outranked the caller, so the surfaces can
1727
- // disclose a parameter that was accepted but could not take effect (#182).
1728
- requestedThinking: resolvedConfig.overridden?.thinking,
1729
- requestedModel: askedModel,
1730
- // Explicit value only — the default fallback would just add noise.
1731
- // Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
1732
- maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
1733
- isolated,
1734
- inheritContext,
1735
- runInBackground,
1736
- isolation,
1737
- };
1738
- // Tool-result render shows the mode label too; viewer's header already does.
1739
- const modeLabel = getPromptModeLabel(subagentType);
1740
- const { tags: invocationTags } = buildInvocationTags(agentInvocation);
1741
- const agentTags = modeLabel ? [modeLabel, ...invocationTags] : invocationTags;
1742
- const detailBase = {
1743
- displayName,
1744
- description: params.description,
1745
- subagentType,
1746
- modelName,
1747
- tags: agentTags.length > 0 ? agentTags : undefined,
1748
- };
1749
- /**
1750
- * `detailBase` for a record that exists, which outranks it: the base is a
1751
- * snapshot of what this call REQUESTED, and pi may have resolved a
1752
- * different model or clamped the thinking level (agent-manager writes the
1753
- * effective values back when the session reports them). Resume goes
1754
- * further and ignores the model/thinking parameters outright — it runs on
1755
- * the session it is reopening — so rendering the base there advertises
1756
- * settings the run never used.
1757
- *
1758
- * The mode label is rebuilt rather than carried over: it hangs off the
1759
- * agent TYPE, not the invocation, so tags taken straight from
1760
- * buildInvocationTags would silently drop `twin`.
1761
- */
1762
- const detailBaseFor = (rec) => {
1763
- if (!rec?.invocation)
1764
- return detailBase;
1765
- const type = rec.type;
1766
- const { modelName: recModelName, tags } = buildInvocationTags(rec.invocation);
1767
- const recModeLabel = getPromptModeLabel(type);
1768
- const recTags = recModeLabel ? [recModeLabel, ...tags] : tags;
1769
- return {
1770
- displayName: getDisplayName(type),
1771
- description: rec.description,
1772
- subagentType: type,
1773
- modelName: recModelName,
1774
- tags: recTags.length > 0 ? recTags : undefined,
1775
- };
1776
- };
1777
- // ---- Schedule: register a job, don't spawn now ----
1778
- if (params.schedule) {
1779
- if (!isSchedulingEnabled()) {
1780
- return textResult("Scheduling is disabled in this project. Enable via /agents → Settings → Scheduling.");
1781
- }
1782
- if (params.resume) {
1783
- return textResult("Cannot combine `schedule` with `resume` — schedules create fresh agents.");
1784
- }
1785
- if (params.inherit_context) {
1786
- return textResult("Cannot combine `schedule` with `inherit_context` — there is no parent conversation at fire time.");
1787
- }
1788
- if (params.run_in_background === false) {
1789
- return textResult("Cannot combine `schedule` with `run_in_background: false` — scheduled jobs always run in background.");
1790
- }
1791
- if (!scheduler.isActive()) {
1792
- return textResult("Scheduler is not active in this session yet. Try again after the session has fully started.");
1793
- }
1794
- try {
1795
- const job = scheduler.addJob({
1796
- name: params.description,
1797
- description: params.description,
1798
- schedule: params.schedule,
1799
- // The caller's own name, not the substitute — the scheduler re-resolves
1800
- // at fire time, and the original is what a user edits.
1801
- subagent_type: requestedType,
1802
- prompt: params.prompt,
1803
- model: params.model,
1804
- thinking: thinking,
1805
- max_turns: effectiveMaxTurns,
1806
- isolated: isolated,
1807
- isolation: isolation,
1808
- });
1809
- const next = scheduler.getNextRun(job.id);
1810
- return textResult(`${fallbackNote}Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
1811
- `Next run: ${next ?? "(unknown)"}. ` +
1812
- `Manage via /agents → Scheduled jobs.`);
1813
- }
1814
- catch (err) {
1815
- return textResult(err instanceof Error ? err.message : String(err));
1816
- }
1817
- }
1818
- // Resume existing agent
1819
- if (params.resume) {
1820
- const existing = manager.getRecord(params.resume);
1821
- if (!existing || !isTopLevelAgent(existing)) {
1822
- return textResult(`Agent not found: "${params.resume}". It may have been cleaned up.`);
1823
- }
1824
- if (!existing.session) {
1825
- return textResult(`Agent "${params.resume}" has no active session to resume.`);
1826
- }
1827
- // Background resume: detached run that notifies on completion, mirroring
1828
- // a background spawn. Previously run_in_background was silently ignored
1829
- // on resume (this branch returned before the background branch below),
1830
- // so a resumed agent always blocked the main loop until it finished.
1831
- if (runInBackground) {
1832
- const id = existing.id;
1833
- // A detached resume hands control back while the record stays
1834
- // "running", so nothing stops the model from resuming the same agent
1835
- // again mid-run. manager.resume() refuses that (it would orphan the
1836
- // live run's abort controller); say why here, where the model can act
1837
- // on it, instead of letting it read as a generic failure.
1838
- if (existing.status === "running" || existing.status === "queued") {
1839
- return textResult(`Agent "${params.resume}" is still ${existing.status} — it can only be resumed once its current run finishes.\n` +
1840
- `Use steer_subagent to send it a message mid-run, or get_subagent_result to wait for it.`);
1841
- }
1842
- const record = await startBackgroundResume(ctx, existing, params.prompt, {
1843
- outputTranscript,
1844
- maxTurns: effectiveMaxTurns,
1845
- toolCallId,
1846
- });
1847
- if (!record) {
1848
- return textResult(`Failed to resume agent "${params.resume}".`);
1849
- }
1850
- const isQueued = record.status === "queued";
1851
- return textResult(`Agent ${isQueued ? "queued" : "resumed"} in background.\n` +
1852
- `Agent ID: ${id}\n` +
1853
- `Type: ${existing.type}\n` +
1854
- (record.outputFile ? `Output file: ${record.outputFile}\n` : "") +
1855
- (isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
1856
- `\nYou will be notified when this agent completes.\n` +
1857
- `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.`, { ...detailBaseFor(record), toolUses: record.toolUses, tokens: "", durationMs: 0, status: "background", agentId: id });
1858
- }
1859
- const record = await manager.resume(params.resume, params.prompt, signal);
1860
- if (!record) {
1861
- return textResult(`Failed to resume agent "${params.resume}".`);
1862
- }
1863
- // A failed resume surfaces the error, plus any partial output THIS
1864
- // resume produced (never the previous turn's answer, #144).
1865
- if (record.status === "error") {
1866
- return textResult(`Agent failed: ${record.error}${partialOutputSuffix(record)}`, buildDetails(detailBaseFor(record), record));
1867
- }
1868
- return textResult(record.result?.trim() || "No output.", buildDetails(detailBaseFor(record), record));
1869
- }
1870
- // Background execution
1871
- if (runInBackground) {
1872
- const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns);
1873
- // Wrap onSessionCreated to wire output file streaming.
1874
- // The callback lazily reads record.outputFile (set right after spawn)
1875
- // rather than closing over a value that doesn't exist yet.
1876
- let id;
1877
- const origBgOnSession = bgCallbacks.onSessionCreated;
1878
- bgCallbacks.onSessionCreated = (session) => {
1879
- origBgOnSession(session);
1880
- const rec = manager.getRecord(id);
1881
- if (rec?.outputFile) {
1882
- rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd, undefined);
1883
- }
1884
- };
1885
- // A throw here means the agent never started. Let it out: pi marks a
1886
- // tool call failed only when execute throws, and a returned message
1887
- // reads to the model as a subagent that ran and reported this (#179).
1888
- id = manager.spawn(pi, ctx, subagentType, params.prompt, {
1889
- description: params.description,
1890
- name: params.name,
1891
- model,
1892
- maxTurns: effectiveMaxTurns,
1893
- isolated,
1894
- inheritContext,
1895
- thinkingLevel: thinking,
1896
- isBackground: true,
1897
- isolation,
1898
- invocation: agentInvocation,
1899
- outputTranscript,
1900
- rootSessionId: ctx.sessionManager.getSessionId(),
1901
- ...bgCallbacks,
1902
- });
1903
- // Set output file + join mode synchronously after spawn, before the
1904
- // event loop yields — onSessionCreated is async so this is safe.
1905
- const joinMode = resolveJoinMode(defaultJoinMode, true);
1906
- const record = manager.getRecord(id);
1907
- if (record && joinMode) {
1908
- record.joinMode = joinMode;
1909
- record.toolCallId = toolCallId;
1910
- attachTranscript(record, id);
1911
- }
1912
- // With isolation: "worktree" the agent isn't running yet — the repo
1913
- // copy is an awaited git call. Wait for it here, after the synchronous
1914
- // wiring above, so a strict-isolation failure still fails THIS tool
1915
- // call instead of being reported as a subagent that ran (#179).
1916
- await manager.awaitStartup(id);
1917
- if (joinMode == null || joinMode === 'async') {
1918
- // Foreground/no join mode or explicit async — not part of any batch
1919
- }
1920
- else {
1921
- // smart or group — add to current batch
1922
- currentBatchAgents.push({ id, joinMode });
1923
- // Debounce: reset timer on each new agent so parallel tool calls
1924
- // dispatched across multiple event loop ticks are captured together
1925
- if (batchFinalizeTimer)
1926
- clearTimeout(batchFinalizeTimer);
1927
- batchFinalizeTimer = setTimeout(finalizeBatch, 100);
1928
- }
1929
- agentActivity.set(id, bgState);
1930
- widget.ensureTimer();
1931
- widget.update();
1932
- // Emit created event
1933
- pi.events.emit("subagents:created", {
1934
- id,
1935
- type: subagentType,
1936
- description: params.description,
1937
- isBackground: true,
1938
- });
1939
- const isQueued = record?.status === "queued";
1940
- return textResult(`${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
1941
- `Agent ID: ${id}\n` +
1942
- `Type: ${displayName}\n` +
1943
- `Description: ${params.description}\n` +
1944
- (record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
1945
- (isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
1946
- `\nYou will be notified when this agent completes.\n` +
1947
- `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
1948
- `Do not duplicate this agent's work.`, { ...detailBaseFor(record), toolUses: 0, tokens: "", durationMs: 0, status: "background", agentId: id });
1949
- }
1950
- // Foreground (synchronous) execution — stream progress via onUpdate
1951
- let spinnerFrame = 0;
1952
- const startedAt = Date.now();
1953
- let fgId;
1954
- // Set only while the spawn is parked on a foreground concurrency slot
1955
- // (maxConcurrentForeground); undefined the rest of the time, including
1956
- // always when the limit is unset.
1957
- let queuedAhead;
1958
- const streamUpdate = () => {
1959
- // Spend from the record, everything else from the live tracker. `fgId`
1960
- // is set in onSessionCreated below, which fires before the first
1961
- // assistant message — so nothing is spent while this reads zero.
1962
- const fgRecord = fgId ? manager.getRecord(fgId) : undefined;
1963
- const details = {
1964
- ...detailBaseFor(fgRecord),
1965
- toolUses: fgState.toolUses,
1966
- tokens: fgRecord ? formatLifetimeTokens(fgRecord) : "",
1967
- cost: fgRecord ? getLifetimeCost(fgRecord.lifetimeUsage) : 0,
1968
- turnCount: fgState.turnCount,
1969
- maxTurns: fgState.maxTurns,
1970
- durationMs: Date.now() - startedAt,
1971
- // Deliberately still "running" while queued: the renderer routes any
1972
- // status it doesn't know to raw text (see the catch-all below), which
1973
- // would drop the spinner and read as hung. Only the activity line
1974
- // changes — "thinking…" would be a lie for an agent that has not
1975
- // started and may not for minutes.
1976
- status: "running",
1977
- activity: queuedAhead === undefined
1978
- ? describeActivity(fgState.activeTools, fgState.responseText)
1979
- : `queued — waiting for a foreground slot${queuedAhead > 0 ? ` (${queuedAhead} ahead)` : ""}`,
1980
- spinnerFrame: spinnerFrame % SPINNER.length,
1981
- };
1982
- onUpdate?.({
1983
- content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
1984
- details: details,
1985
- });
1986
- };
1987
- const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(effectiveMaxTurns, streamUpdate);
1988
- // Wire session creation: register in widget + stream to output file.
1989
- // The output file path is set synchronously after spawn (below),
1990
- // before onSessionCreated fires — same pattern as background agents.
1991
- const origOnSession = fgCallbacks.onSessionCreated;
1992
- fgCallbacks.onSessionCreated = (session) => {
1993
- origOnSession(session);
1994
- // It really started — stop reporting it as queued, and repaint now
1995
- // rather than leaving the stale line up for the next spinner tick.
1996
- // Guarded, so a spawn that never queued emits no extra update.
1997
- if (queuedAhead !== undefined) {
1998
- queuedAhead = undefined;
1999
- streamUpdate();
2000
- }
2001
- for (const a of manager.listAgents()) {
2002
- if (a.session === session) {
2003
- fgId = a.id;
2004
- agentActivity.set(a.id, fgState);
2005
- widget.ensureTimer();
2006
- break;
2007
- }
2008
- }
2009
- // Stream conversation to output file (foreground agent logging)
2010
- if (fgId) {
2011
- const rec = manager.getRecord(fgId);
2012
- if (rec?.outputFile) {
2013
- rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd, undefined);
2014
- }
2015
- }
2016
- };
2017
- // Animate spinner at ~80ms (smooth rotation through 10 braille frames)
2018
- const spinnerInterval = setInterval(() => {
2019
- spinnerFrame++;
2020
- streamUpdate();
2021
- }, 80);
2022
- streamUpdate();
2023
- let record;
2024
- try {
2025
- const fgResult = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
2026
- description: params.description,
2027
- name: params.name,
2028
- model,
2029
- maxTurns: effectiveMaxTurns,
2030
- isolated,
2031
- inheritContext,
2032
- thinkingLevel: thinking,
2033
- isolation,
2034
- invocation: agentInvocation,
2035
- outputTranscript,
2036
- signal,
2037
- rootSessionId: ctx.sessionManager.getSessionId(),
2038
- // Deliberately does NOT set fgId: that drives agentActivity, the
2039
- // widget and the `finally` cleanup below, none of which should see an
2040
- // agent that has no session and may never get one.
2041
- onQueued: (_id, ahead) => { queuedAhead = ahead; streamUpdate(); },
2042
- ...fgCallbacks,
2043
- }, (fgAgentId) => {
2044
- // onSpawned: called synchronously after spawn, before onSessionCreated fires.
2045
- // Set up the output file so streamToOutputFile can pick it up.
2046
- const fgRec = manager.getRecord(fgAgentId);
2047
- attachTranscript(fgRec, fgAgentId);
2048
- });
2049
- record = fgResult.record;
2050
- }
2051
- finally {
2052
- // Runs on both paths, so a startup throw — which now propagates, see
2053
- // the background spawn above (#179) — no longer leaves the spinner
2054
- // ticking or a finished agent on the widget.
2055
- clearInterval(spinnerInterval);
2056
- if (fgId) {
2057
- agentActivity.delete(fgId);
2058
- widget.markFinished(fgId);
2059
- }
2060
- }
2061
- // Get final token count — from the record, like the cost below it, so the
2062
- // two describe the same work when the agent delegated to nested children.
2063
- const tokenText = formatLifetimeTokens(record);
2064
- const details = buildDetails(detailBaseFor(record), record, fgState, { tokens: tokenText });
2065
- if (record.status === "error") {
2066
- // Error headline + any partial output the run produced before failing.
2067
- return textResult(`${fallbackNote}Agent failed: ${record.error}${partialOutputSuffix(record)}`, details);
2068
- }
2069
- const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
2070
- const statsParts = [`${record.toolUses} tool uses`];
2071
- if (tokenText)
2072
- statsParts.push(tokenText);
2073
- if (showCost) {
2074
- const costText = formatCost(getLifetimeCost(record.lifetimeUsage));
2075
- if (costText)
2076
- statsParts.push(costText);
2077
- }
2078
- return textResult(`${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getForegroundOutcomeNote(record.status)}.\n\n` +
2079
- (record.result?.trim() || "No output."), details);
2080
- },
2081
- });
2082
- /**
2083
- * Wrap a tool so its results carry back whatever subagent spend the parent
2084
- * session has not been told about yet (see `PendingUsagePool`).
2085
- *
2086
- * Pi copies `AgentToolResult.usage` onto the persisted tool-result message and
2087
- * folds it into `getSessionStats()`, which is what the footer, the statusline
2088
- * and `/cost` read — so this is the whole of "report usage to the parent".
2089
- *
2090
- * Nothing is attached to a call with no tool-call id. That is the `@handle`
2091
- * mention path (`mention-clone.ts`), which invokes this tool from a fork of the
2092
- * conversation that is discarded moments later: the result never becomes a
2093
- * message in the real session, so usage hung on it would be spend the user paid
2094
- * for and nobody counted. Skipping leaves it pending for the next real result.
2095
- */
2096
- function withUsageReporting(tool) {
2097
- return {
2098
- ...tool,
2099
- execute: async (toolCallId, ...rest) => {
2100
- const result = await tool.execute(toolCallId, ...rest);
2101
- if (!reportUsage || !toolCallId)
2102
- return result;
2103
- const usage = pendingUsage.drain();
2104
- return usage ? { ...result, usage } : result;
2105
- },
2106
- };
2107
- }
2108
- function registerToolReportingUsage(tool) {
2109
- pi.registerTool(withUsageReporting(tool));
2110
- }
2111
- // The mention path is handed THIS object, not the bare `agentTool` — see the
2112
- // mention-clone header on why the clone must call the registered tool.
2113
- const registeredAgentTool = withUsageReporting(agentTool);
2114
- pi.registerTool(registeredAgentTool);
2115
- // ---- Workflow tool ----
2116
- /**
2117
- * Live runs, by task id. The tool returns before the run finishes, so its
2118
- * result card looks the task up here on every render rather than freezing a
2119
- * snapshot into `details` — that is what makes the inline card follow a
2120
- * background run.
2121
- */
2122
- const workflowTasks = new Map();
2123
- /**
2124
- * Run a task to completion against the real manager, settling the record
2125
- * either way. Never rejects: a run that cannot start (bad `meta`, oversized
2126
- * source, non-JSON `args`) is a failed workflow, and both callers here are
2127
- * detached — a rejection would surface as an unhandled one.
2128
- */
2129
- async function runWorkflowTask(ctx, task) {
2130
- try {
2131
- const result = await runWorkflow({
2132
- script: task.script,
2133
- args: task.args,
2134
- signal: task.abortController.signal,
2135
- host: createWorkflowHost({
2136
- pi,
2137
- ctx,
2138
- manager,
2139
- signal: task.abortController.signal,
2140
- rootSessionId: ctx.sessionManager.getSessionId(),
2141
- workflowId: task.id,
2142
- }),
2143
- onProgress: entries => updateWorkflowProgressBatch(task, entries),
2144
- // The dialog's pause / skip / retry keys run through this; it is dropped
2145
- // again when the task settles.
2146
- onControl: control => { task.control = control; },
2147
- journal: {
2148
- ...(task.replay !== undefined ? { entries: task.replay } : {}),
2149
- ...(task.journalPath !== undefined
2150
- ? { append: (entry) => appendJournal(task.journalPath, entry) }
2151
- : {}),
2152
- },
2153
- });
2154
- completeWorkflowTask(task, result);
2155
- }
2156
- catch (err) {
2157
- failWorkflowTask(task, err instanceof Error ? err.message : String(err));
2158
- }
2159
- }
2160
- /**
2161
- * Hand a finished run back to the model through the SAME channel a background
2162
- * agent uses — held briefly by `scheduleNudge`, delivered as a follow-up that
2163
- * triggers a turn, rendered by the existing `subagent-notification` renderer.
2164
- */
2165
- function notifyWorkflowFinished(task) {
2166
- widget.update();
2167
- const result = workflowResultText(task);
2168
- scheduleNudge(task.id, () => {
2169
- pi.sendMessage({
2170
- customType: "subagent-notification",
2171
- content: formatWorkflowNotification(task),
2172
- display: true,
2173
- details: {
2174
- id: task.id,
2175
- description: `Workflow ${task.workflowName ?? task.id}`,
2176
- status: task.status === "completed" ? "completed" : task.status === "killed" ? "stopped" : "error",
2177
- toolUses: task.totalToolCalls,
2178
- // A workflow has agents, not turns; rendering "↻0" would be noise.
2179
- turnCount: 0,
2180
- totalTokens: task.totalTokens,
2181
- durationMs: elapsedMs(task, Date.now()),
2182
- error: task.error,
2183
- resultPreview: result.length > 500 ? `${result.slice(0, 500)}…` : result,
2184
- },
2185
- }, { deliverAs: "followUp", triggerTurn: true });
2186
- });
2187
- }
2188
- // Defined unconditionally, registered only when the feature is on — the same
2189
- // shape the Agent tool uses. Keeping the definition out of the `if` means the
2190
- // switch changes exactly one thing: whether pi is ever told about the tool.
2191
- const workflowTool = defineTool({
2192
- name: SUBAGENT_TOOL_NAMES.WORKFLOW,
2193
- label: "SubagentWorkflow",
2194
- description: renderToolDescriptionTemplate(fullWorkflowToolDescription),
2195
- promptSnippet: "Run a deterministic script that orchestrates many subagents",
2196
- promptGuidelines: [
2197
- "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.",
2198
- "Prefer `pipeline` over `parallel` — a barrier costs wall-clock whenever the stages are unevenly sized.",
2199
- "A workflow runs in the background and notifies you when it finishes — do not poll or sleep waiting for it.",
2200
- ],
2201
- parameters: Type.Object({
2202
- script: Type.Optional(Type.String({
2203
- maxLength: 524288,
2204
- description: "Inline workflow source. Must begin with `export const meta = { name, description }`.",
2205
- })),
2206
- scriptPath: Type.Optional(Type.String({
2207
- description: "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.",
2208
- })),
2209
- name: Type.Optional(Type.String({
2210
- description: "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.",
2211
- })),
2212
- args: Type.Optional(Type.Any({
2213
- description: "Exposed to the script as the global `args`, verbatim. Must be JSON-shaped.",
2214
- })),
2215
- resumeFromRunId: Type.Optional(Type.String({
2216
- pattern: "^wf_[a-z0-9-]{6,}$",
2217
- description: "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.",
2218
- })),
2219
- // Accepted and ignored, as in Claude Code. Models reach for them because
2220
- // every other tool has them, and a hard schema rejection would cost a
2221
- // whole turn to re-emit a script that was already correct. The `meta`
2222
- // block is the one place a workflow is named.
2223
- title: Type.Optional(Type.String({ description: "Ignored — set the workflow title in the script's `meta` block." })),
2224
- description: Type.Optional(Type.String({ description: "Ignored — set the workflow description in the script's `meta` block." })),
2225
- }),
2226
- renderCall(args, theme) {
2227
- return new Text(`${theme.fg("toolTitle", "▸ ")}${theme.bold(theme.fg("toolTitle", "SubagentWorkflow"))} ${theme.fg("muted", workflowCallName(args))}`, 0, 0);
2228
- },
2229
- renderResult(result, _options, theme, renderContext) {
2230
- const text = result.content[0]?.type === "text" ? result.content[0].text : "";
2231
- const taskId = result.details?.taskId;
2232
- const task = taskId !== undefined ? workflowTasks.get(taskId) : undefined;
2233
- // No task means the run predates this session (a reloaded transcript) or
2234
- // the call never started one — show what `execute` said instead.
2235
- if (renderContext.isError || !task)
2236
- return new Text(text, 0, 0);
2237
- return renderWorkflowCard({
2238
- progress: task.workflowProgress,
2239
- task: {
2240
- status: task.status,
2241
- workflowName: task.workflowName,
2242
- startTime: task.startTime,
2243
- endTime: task.endTime,
2244
- totalPausedMs: task.totalPausedMs,
2245
- },
2246
- meta: task.meta,
2247
- agentCount: task.agentCount,
2248
- totalTokens: task.totalTokens,
2249
- }, theme);
2250
- },
2251
- execute: async (toolCallId, params, _signal, _onUpdate, ctx) => {
2252
- const resumeFrom = resolveResumeTarget(params.resumeFromRunId, workflowTasks);
2253
- if (resumeFrom !== undefined && !resumeFrom.ok)
2254
- return textResult(resumeFrom.message);
2255
- // A resume with no source of its own re-runs what that run ran. The
2256
- // common case is an edited script, but "run that again, cheaply" should
2257
- // not require repeating a path the run already knows.
2258
- const resolved = resolveWorkflowScript(params.script === undefined && params.scriptPath === undefined && params.name === undefined
2259
- && resumeFrom !== undefined
2260
- ? { scriptPath: resumeFrom.scriptPath }
2261
- : params, ctx.cwd);
2262
- if (!resolved.ok)
2263
- return textResult(resolved.message);
2264
- // Parsed before anything is scheduled: a bad `meta` is an authoring error
2265
- // the model can fix immediately, and reporting it as a background run
2266
- // that failed a second later would just cost a turn.
2267
- let meta;
2268
- try {
2269
- meta = extractMeta(resolved.script).meta;
2270
- }
2271
- catch (err) {
2272
- return textResult(err instanceof Error ? err.message : String(err));
2273
- }
2274
- const runId = workflowRunId();
2275
- // Every invocation lands on disk next to the agent transcripts, so
2276
- // iterating is edit-the-file-then-rerun-with-scriptPath rather than
2277
- // re-emitting the whole source. The journal sits beside it under the same
2278
- // id, which is what makes a run id enough to resume from.
2279
- let savedPath;
2280
- let journalPath;
2281
- try {
2282
- const dir = sessionTaskDir(ctx.cwd, ctx.sessionManager.getSessionId());
2283
- savedPath = join(dir, `${runId}.workflow.js`);
2284
- writeFileSync(savedPath, resolved.script, "utf-8");
2285
- journalPath = join(dir, `${runId}.workflow.jsonl`);
2286
- }
2287
- catch (err) {
2288
- savedPath = undefined;
2289
- journalPath = undefined;
2290
- console.warn(`[pi-subagents] could not persist workflow script: ${err instanceof Error ? err.message : String(err)}`);
2291
- }
2292
- const replay = resumeFrom !== undefined ? readJournal(resumeFrom.journalPath) : undefined;
2293
- const task = createWorkflowTask({
2294
- id: runId,
2295
- script: resolved.script,
2296
- scriptPath: resolved.scriptPath ?? savedPath,
2297
- args: params.args,
2298
- meta,
2299
- toolCallId,
2300
- ...(journalPath !== undefined ? { journalPath } : {}),
2301
- ...(replay !== undefined && replay.length > 0 ? { replay, resumedFrom: resumeFrom.runId } : {}),
2302
- });
2303
- workflowTasks.set(runId, task);
2304
- // The run's own row has to appear now, not when it settles. Its agents
2305
- // are owned by it, so their lifecycle callbacks no longer refresh these
2306
- // surfaces — nothing else would register the widget for a run whose
2307
- // first agent has not started yet.
2308
- widget.update();
2309
- // Background, like Claude Code: the id comes back now and the run keeps
2310
- // going without the tool call.
2311
- void runWorkflowTask(ctx, task).then(() => notifyWorkflowFinished(task));
2312
- return {
2313
- content: [{
2314
- type: "text",
2315
- text: `Workflow "${meta.name}" started in the background.\n` +
2316
- `Task ID: ${runId}\n` +
2317
- (task.scriptPath ? `Script: ${task.scriptPath}\n` : "") +
2318
- (task.resumedFrom !== undefined
2319
- ? `Resuming ${task.resumedFrom}: ${task.replay?.length ?? 0} recorded call(s) available to replay.\n`
2320
- : params.resumeFromRunId !== undefined
2321
- ? `Nothing to replay from ${params.resumeFromRunId} — every agent runs live.\n`
2322
- : "") +
2323
- `\nYou will be notified when it finishes — do NOT poll or sleep waiting for it.\n` +
2324
- `To iterate, edit the script file and call SubagentWorkflow again with scriptPath.`,
2325
- }],
2326
- details: { taskId: runId },
2327
- };
2328
- },
2329
- });
2330
- if (isWorkflowsEnabled())
2331
- pi.registerTool(workflowTool);
2332
- /**
2333
- * Act on {@link decideWorkflowCollision} — the half that needs the host.
2334
- *
2335
- * The policy (what counts as a conflict, what a pin changes, whether there is
2336
- * anything left to withdraw) lives in `workflow/collisions.ts`; this is the
2337
- * host-facing shell around it: read the registry, warn, and take our tool out
2338
- * of the active set.
2339
- *
2340
- * ## Why this can only happen at session_start
2341
- *
2342
- * `getAllTools` throws during extension loading ("Action methods cannot be
2343
- * called during extension loading"), and load order means a check at
2344
- * registration time could not see an extension that has not loaded yet. So
2345
- * the decision cannot gate `registerTool`; it has to undo it. `setActiveTools`
2346
- * is what makes that real rather than cosmetic — pi rebuilds the system
2347
- * prompt from the new set, and `session_start` runs before any turn, so the
2348
- * model never sees a spec we withdrew. A later `_refreshToolRegistry` keeps
2349
- * the active set it had and only adds names new to the registry, so ours does
2350
- * not creep back.
2351
- *
2352
- * Best-effort and swallowed. A diagnostic that took the session down would be
2353
- * worse than the collision it reports.
2354
- */
2355
- let collisionsChecked = false;
2356
- function resolveWorkflowCollisions(ctx) {
2357
- if (collisionsChecked)
2358
- return;
2359
- collisionsChecked = true;
2360
- const warn = (message) => {
2361
- if (ctx.hasUI)
2362
- ctx.ui.notify(message, "warning");
2363
- else
2364
- console.warn(`[pi-subagents] ${message}`);
2365
- };
2366
- try {
2367
- if (!isWorkflowsEnabled())
2368
- return;
2369
- const verdict = decideWorkflowCollision({
2370
- tools: pi.getAllTools(),
2371
- // Identifies our own registration: this extension does not know its
2372
- // install path, and the description is the one field certainly ours.
2373
- ownDescription: workflowTool.description,
2374
- pinned: isWorkflowsPinned(),
2375
- });
2376
- if (verdict.kind === "none")
2377
- return;
2378
- if (verdict.kind === "report") {
2379
- warn(verdict.message);
2380
- return;
2381
- }
2382
- workflowsEnabled = false; // not setWorkflowsEnabled: this is not the user pinning it
2383
- widget.update();
2384
- warn(verdict.message);
2385
- if (!verdict.withdraw)
2386
- return;
2387
- const active = pi.getActiveTools();
2388
- if (active.includes(SUBAGENT_TOOL_NAMES.WORKFLOW)) {
2389
- pi.setActiveTools(active.filter(name => name !== SUBAGENT_TOOL_NAMES.WORKFLOW));
2390
- }
2391
- }
2392
- catch {
2393
- // getAllTools/setActiveTools are unavailable in some hosts (print mode,
2394
- // RPC). Not being able to check is not a reason to fail the session.
2395
- }
2396
- }
2397
- /**
2398
- * `--subagents-workflow-file=<path>` — run a script at startup, with no LLM
2399
- * round-trip deciding whether to call the tool.
2400
- *
2401
- * Read here rather than at activation because that is the only place the real
2402
- * value exists: the host activates extensions first and applies collected CLI
2403
- * flags second, so `getFlag` during activation returns the registered default
2404
- * and nothing else. `examples/extensions/ssh.ts` reads its flag from
2405
- * session_start for exactly this reason.
2406
- */
2407
- let workflowFlagHandled = false;
2408
- function runWorkflowFlag(ctx) {
2409
- if (workflowFlagHandled)
2410
- return;
2411
- const flag = typeof pi.getFlag === "function" ? pi.getFlag(WORKFLOW_FILE_FLAG) : undefined;
2412
- if (flag === undefined || flag === false)
2413
- return;
2414
- workflowFlagHandled = true;
2415
- const report = (message, level) => {
2416
- if (ctx.hasUI)
2417
- ctx.ui.notify(message, level);
2418
- else
2419
- console.warn(`[pi-subagents] ${message}`);
2420
- };
2421
- // The flag is the same machinery by another door, so the master switch has
2422
- // to close it too — silently ignoring a flag the user typed would be worse
2423
- // than saying why nothing ran.
2424
- if (!isWorkflowsEnabled()) {
2425
- report(`--${WORKFLOW_FILE_FLAG} ignored: workflows are off. Turn them on in /agents → Settings → Workflows, ` +
2426
- 'or set `"workflowsEnabled": true` in .pi/subagents.json.', "warning");
2427
- return;
2428
- }
2429
- // A bare `--subagents-workflow-file` parses to boolean `true`. Say what was
2430
- // missing rather than reading a file called "true".
2431
- if (typeof flag !== "string" || flag.trim() === "") {
2432
- report(`--${WORKFLOW_FILE_FLAG} needs a path: --${WORKFLOW_FILE_FLAG}=<path>`, "warning");
2433
- return;
2434
- }
2435
- const path = isAbsolute(flag.trim()) ? flag.trim() : join(ctx.cwd, flag.trim());
2436
- let script;
2437
- try {
2438
- script = readFileSync(path, "utf-8");
2439
- }
2440
- catch (err) {
2441
- report(`Could not read ${path}: ${err instanceof Error ? err.message : String(err)}`, "warning");
2442
- return;
2443
- }
2444
- let meta;
2445
- try {
2446
- meta = extractMeta(script).meta;
2447
- }
2448
- catch (err) {
2449
- report(err instanceof Error ? err.message : String(err), "warning");
2450
- return;
2451
- }
2452
- const task = createWorkflowTask({ id: workflowRunId(), script, scriptPath: path, meta });
2453
- workflowTasks.set(task.id, task);
2454
- widget.update();
2455
- report(`Running workflow ${meta.name}…`, "info");
2456
- // Detached: session_start is awaited by the host, and a workflow can run for
2457
- // minutes — blocking here would hold the whole session's startup.
2458
- void runWorkflowTask(ctx, task).then(() => {
2459
- // No tool call to attach a result card to, so the card becomes a session
2460
- // entry (same layout), and the outcome is handed to the model as context
2461
- // for its next turn rather than forcing one.
2462
- pi.appendEntry(WORKFLOW_ENTRY_TYPE, workflowEntryData(task));
2463
- pi.sendMessage({
2464
- customType: "workflow-result",
2465
- content: formatWorkflowNotification(task),
2466
- display: false,
2467
- }, { deliverAs: "nextTurn" });
2468
- widget.update();
2469
- });
2470
- }
2471
- // ---- get_subagent_result tool ----
2472
- registerToolReportingUsage(defineTool({
2473
- name: SUBAGENT_TOOL_NAMES.GET_RESULT,
2474
- label: "Get Agent Result",
2475
- description: "Check status and retrieve a background agent's full result — its completion notification carries only a preview. Use the agent ID returned by Agent.",
2476
- promptSnippet: "Check status and retrieve results from a background agent",
2477
- parameters: Type.Object({
2478
- agent_id: Type.String({
2479
- 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`).",
2480
- }),
2481
- wait: Type.Optional(Type.Boolean({
2482
- description: "If true, wait for the agent to complete before returning. Default: false.",
2483
- })),
2484
- verbose: Type.Optional(Type.Boolean({
2485
- description: "If true, include the agent's full conversation (messages + tool calls). Default: false.",
2486
- })),
2487
- }),
2488
- execute: async (_toolCallId, params, signal, _onUpdate, _ctx) => {
2489
- const record = resolveAgentRef(params.agent_id);
2490
- if (!record || !isTopLevelAgent(record)) {
2491
- return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
2492
- }
2493
- // Wait for completion if requested. Cancellation stops only this tool
2494
- // call; the background agent keeps running and remains unconsumed so its
2495
- // completion notification can still be delivered.
2496
- // Queued agents have no promise yet (it's created when the queue starts
2497
- // them), so poll until they leave the queue, then await like a running one.
2498
- if (params.wait && (record.status === "running" || record.status === "queued")) {
2499
- while (record.status === "queued") {
2500
- await abortable(new Promise((resolve) => setTimeout(resolve, QUEUE_WAIT_POLL_MS)), signal);
2501
- }
2502
- if (record.promise)
2503
- await abortable(record.promise, signal);
2504
- }
2505
- const displayName = getDisplayName(record.type);
2506
- const duration = formatDuration(record.startedAt, record.completedAt);
2507
- const tokens = formatLifetimeTokens(record);
2508
- const contextPercent = getSessionContextPercent(record.session);
2509
- const statsParts = [`Tool uses: ${record.toolUses}`];
2510
- if (tokens)
2511
- statsParts.push(tokens);
2512
- if (showCost) {
2513
- const costText = formatCost(getLifetimeCost(record.lifetimeUsage));
2514
- if (costText)
2515
- statsParts.push(`Cost: ${costText}`);
2516
- }
2517
- if (contextPercent !== null)
2518
- statsParts.push(`Context: ${Math.round(contextPercent)}%`);
2519
- if (record.compactionCount)
2520
- statsParts.push(`Compactions: ${record.compactionCount}`);
2521
- statsParts.push(`Duration: ${duration}`);
2522
- let output = `Agent: ${record.id}\n` +
2523
- `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
2524
- `Description: ${record.description}\n\n`;
2525
- if (record.status === "running") {
2526
- output += "Agent is still running. Use wait: true or check back later.";
2527
- }
2528
- else if (record.status === "error") {
2529
- output += `Error: ${record.error}${partialOutputSuffix(record)}`;
2530
- }
2531
- else {
2532
- output += record.result?.trim() || "No output.";
2533
- }
2534
- // Mark result as consumed — suppresses the completion notification
2535
- if (record.status !== "running" && record.status !== "queued") {
2536
- record.resultConsumed = true;
2537
- cancelNudge(params.agent_id);
2538
- }
2539
- // Verbose: include full conversation
2540
- if (params.verbose && record.session) {
2541
- const conversation = getAgentConversation(record.session);
2542
- if (conversation) {
2543
- output += `\n\n--- Agent Conversation ---\n${conversation}`;
2544
- }
2545
- }
2546
- return textResult(output);
2547
- },
2548
- }));
2549
- // ---- steer_subagent tool ----
2550
- registerToolReportingUsage(defineTool({
2551
- name: SUBAGENT_TOOL_NAMES.STEER,
2552
- label: "Steer Agent",
2553
- description: "Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " +
2554
- "and be injected into its conversation, allowing you to redirect its work mid-run. Only works on running agents.",
2555
- promptSnippet: "Send a steering message to redirect a running background agent",
2556
- parameters: Type.Object({
2557
- agent_id: Type.String({
2558
- 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`).",
2559
- }),
2560
- message: Type.String({
2561
- description: "The steering message to send. This will appear as a user message in the agent's conversation.",
2562
- }),
2563
- }),
2564
- execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
2565
- const record = resolveAgentRef(params.agent_id);
2566
- if (!record || !isTopLevelAgent(record)) {
2567
- return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
2568
- }
2569
- if (record.status !== "running") {
2570
- return textResult(`Agent "${params.agent_id}" is not running (status: ${record.status}). Cannot steer a non-running agent.`);
2571
- }
2572
- if (!record.session) {
2573
- // Session not ready yet — queue the steer for delivery once initialized
2574
- if (!record.pendingSteers)
2575
- record.pendingSteers = [];
2576
- record.pendingSteers.push(params.message);
2577
- pi.events.emit("subagents:steered", { id: record.id, message: params.message });
2578
- return textResult(`Steering message queued for agent ${record.id}. It will be delivered once the session initializes.`);
2579
- }
2580
- try {
2581
- await steerAgent(record.session, params.message);
2582
- pi.events.emit("subagents:steered", { id: record.id, message: params.message });
2583
- const tokens = formatLifetimeTokens(record);
2584
- const contextPercent = getSessionContextPercent(record.session);
2585
- const stateParts = [];
2586
- if (tokens)
2587
- stateParts.push(tokens);
2588
- if (showCost) {
2589
- const costText = formatCost(getLifetimeCost(record.lifetimeUsage));
2590
- if (costText)
2591
- stateParts.push(costText);
2592
- }
2593
- stateParts.push(`${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`);
2594
- if (contextPercent !== null)
2595
- stateParts.push(`context ${Math.round(contextPercent)}% full`);
2596
- if (record.compactionCount)
2597
- stateParts.push(`${record.compactionCount} compaction${record.compactionCount === 1 ? "" : "s"}`);
2598
- return textResult(`Steering message sent to agent ${record.id}. The agent will process it after its current tool execution.\n` +
2599
- `Current state: ${stateParts.join(" · ")}`);
2600
- }
2601
- catch (err) {
2602
- return textResult(`Failed to steer agent: ${err instanceof Error ? err.message : String(err)}`);
2603
- }
2604
- },
2605
- }));
2606
- // ---- /agents interactive menu ----
2607
- // Directory resolution and the frontmatter edits live in agent-file-toggle.ts
2608
- // so they are reachable from tests — this command handler is only registered
2609
- // through `registerCommand`, which every test mocks.
2610
- function getModelLabel(type, registry) {
2611
- const cfg = getAgentConfig(type);
2612
- if (!cfg?.model)
2613
- return "inherit"; // no model configured → really inherits parent
2614
- const label = getModelLabelFromConfig(cfg.model);
2615
- if (!registry)
2616
- return label;
2617
- const resolved = resolveModel(cfg.model, registry);
2618
- // Configured but unresolvable: the runtime silently falls back to the parent
2619
- // model, so flag it (and the fallback) rather than hiding the config.
2620
- if (typeof resolved === "string")
2621
- return `${label} (unavailable, fallback: inherit)`;
2622
- // Surface what it actually resolved to when that differs from the config —
2623
- // e.g. a provider fallback or a looser version pin. Cosmetic separator/date
2624
- // differences are normalized away so an effectively-identical match stays quiet.
2625
- const resolvedFull = `${resolved.provider}/${resolved.id}`;
2626
- const norm = (s) => s.toLowerCase().replace(/\./g, "-").replace(/-\d{8}$/, "");
2627
- if (norm(cfg.model) === norm(resolvedFull))
2628
- return label;
2629
- return `${label} (→ ${resolvedFull.replace(/-\d{8}$/, "")})`;
2630
- }
2631
- async function showAgentsMenu(ctx) {
2632
- reloadCustomAgents();
2633
- const allNames = getAllTypes();
2634
- // Build select options
2635
- const options = [];
2636
- // Keep active sessions and durable terminal history as separate menu rows.
2637
- const agents = manager.listAgents().filter(isTopLevelAgent);
2638
- const { active, history } = splitAgentRecords(agents, ctx.cwd);
2639
- if (active.length > 0) {
2640
- const running = active.filter(a => a.status === "running").length;
2641
- const queued = active.filter(a => a.status === "queued").length;
2642
- options.push(`Running agents (${active.length}) — ${running} running, ${queued} queued`);
2643
- }
2644
- if (history.length > 0)
2645
- options.push(`Agent history (${history.length})`);
2646
- // Agent types list
2647
- if (allNames.length > 0) {
2648
- options.push(`Agent types (${allNames.length})`);
2649
- }
2650
- // Scheduled jobs entry (always present when scheduler is active)
2651
- if (scheduler.isActive()) {
2652
- const jobCount = scheduler.list().length;
2653
- options.push(`Scheduled jobs (${jobCount})`);
2654
- }
2655
- // Workflow runs, on the same terms as scheduled jobs: shown only when the
2656
- // feature is on, so the menu never advertises something switched off.
2657
- if (isWorkflowsEnabled()) {
2658
- options.push(`Workflows (${workflowTasks.size})`);
2659
- }
2660
- // Actions
2661
- options.push("Create new agent");
2662
- options.push("Settings");
2663
- const noAgentsMsg = allNames.length === 0 && agents.length === 0
2664
- ? "No agents found. Create specialized subagents that can be delegated to.\n\n" +
2665
- "Each subagent has its own context window, custom system prompt, and specific tools.\n\n" +
2666
- "Try creating: Code Reviewer, Security Auditor, Test Writer, or Documentation Writer.\n\n"
2667
- : "";
2668
- if (noAgentsMsg) {
2669
- ctx.ui.notify(noAgentsMsg, "info");
2670
- }
2671
- const choice = await ctx.ui.select("Agents", options);
2672
- if (!choice)
2673
- return;
2674
- if (choice.startsWith("Running agents (")) {
2675
- await showRunningAgents(ctx);
2676
- await showAgentsMenu(ctx);
2677
- }
2678
- else if (choice.startsWith("Agent history (")) {
2679
- await showAgentHistory(ctx);
2680
- await showAgentsMenu(ctx);
2681
- }
2682
- else if (choice.startsWith("Agent types (")) {
2683
- await showAllAgentsList(ctx);
2684
- await showAgentsMenu(ctx);
2685
- }
2686
- else if (choice.startsWith("Scheduled jobs (")) {
2687
- await showSchedulesMenu(ctx, scheduler);
2688
- await showAgentsMenu(ctx);
2689
- }
2690
- else if (choice.startsWith("Workflows (")) {
2691
- await showWorkflowsMenu(ctx, workflowMenuDeps);
2692
- await showAgentsMenu(ctx);
2693
- }
2694
- else if (choice === "Create new agent") {
2695
- await showCreateWizard(ctx);
2696
- }
2697
- else if (choice === "Settings") {
2698
- await showSettings(ctx);
2699
- await showAgentsMenu(ctx);
2700
- }
2701
- }
2702
- async function showAllAgentsList(ctx) {
2703
- const allNames = getAllTypes();
2704
- if (allNames.length === 0) {
2705
- ctx.ui.notify("No agents.", "info");
2706
- return;
2707
- }
2708
- // Source indicators: defaults unmarked, custom agents get • (project) or ◦ (global)
2709
- // Disabled agents get ✕ prefix
2710
- const sourceIndicator = (cfg) => {
2711
- const disabled = cfg?.enabled === false;
2712
- if (cfg?.source === "project")
2713
- return disabled ? "✕• " : "• ";
2714
- if (cfg?.source === "global")
2715
- return disabled ? "✕◦ " : "◦ ";
2716
- if (disabled)
2717
- return "✕ ";
2718
- return " ";
2719
- };
2720
- // One row per agent (name in the left column, model on the right); the
2721
- // full description renders below the highlighted row via SettingsList,
2722
- // exactly like the Settings menu — so long descriptions never wrap the list.
2723
- const items = allNames.map(name => {
2724
- const cfg = getAgentConfig(name);
2725
- const disabled = cfg?.enabled === false;
2726
- const model = getModelLabel(name, ctx.modelRegistry);
2727
- return {
2728
- id: name,
2729
- label: `${sourceIndicator(cfg)}${name}`,
2730
- currentValue: model,
2731
- description: disabled ? "(disabled)" : (cfg?.description ?? name),
2732
- // Single-value list so Enter "activates" the row (fires onChange with the
2733
- // agent's id) without offering anything to actually cycle.
2734
- values: [model],
2735
- };
2736
- });
2737
- const hasCustom = allNames.some(n => { const c = getAgentConfig(n); return c && !c.isDefault && c.enabled !== false; });
2738
- const hasDisabled = allNames.some(n => getAgentConfig(n)?.enabled === false);
2739
- const legendParts = [];
2740
- if (hasCustom)
2741
- legendParts.push("• = project ◦ = global");
2742
- if (hasDisabled)
2743
- legendParts.push("✕ = disabled");
2744
- const selected = await ctx.ui.custom((_tui, _theme, _kb, done) => {
2745
- const slTheme = getSettingsListTheme();
2746
- const list = new SettingsList(items, Math.min(items.length, 12), slTheme, id => done(id), // Enter/Space on a row → return that agent's name
2747
- () => done(undefined));
2748
- const container = new Container();
2749
- container.addChild(new Text("Agent types", 0, 0));
2750
- if (legendParts.length)
2751
- container.addChild(new Text(slTheme.hint(legendParts.join(" ")), 0, 0));
2752
- container.addChild(new Spacer(1));
2753
- container.addChild(list);
2754
- return {
2755
- render: (w) => container.render(w),
2756
- invalidate: () => container.invalidate(),
2757
- handleInput: (data) => list.handleInput?.(data),
2758
- };
2759
- });
2760
- if (selected && getAgentConfig(selected)) {
2761
- await showAgentDetail(ctx, selected);
2762
- await showAllAgentsList(ctx);
2763
- }
2764
- }
2765
- function makeUniqueAgentOptionLabels(pairs) {
2766
- const counts = new Map();
2767
- for (const pair of pairs)
2768
- counts.set(pair.label, (counts.get(pair.label) ?? 0) + 1);
2769
- const used = new Set();
2770
- for (const pair of pairs) {
2771
- if ((counts.get(pair.label) ?? 0) === 1) {
2772
- used.add(pair.label);
2773
- continue;
2774
- }
2775
- const suffix = ` · #${pair.record.id.slice(-8)}`;
2776
- let candidate = `${pair.label}${suffix}`;
2777
- let n = 2;
2778
- while (used.has(candidate))
2779
- candidate = `${pair.label}${suffix}-${n++}`;
2780
- pair.label = candidate;
2781
- used.add(candidate);
2782
- }
2783
- }
2784
- async function selectAgentFromReadOnlyList(ctx, title, pairs, selection) {
2785
- const options = pairs.map(({ record, label }) => ({ value: record.id, label }));
2786
- const rememberedIndex = selection.id ? pairs.findIndex(({ record }) => record.id === selection.id) : -1;
2787
- const initialIndex = rememberedIndex >= 0
2788
- ? rememberedIndex
2789
- : Math.max(0, Math.min(selection.index, pairs.length - 1));
2790
- const remember = (id) => {
2791
- const index = pairs.findIndex(({ record }) => record.id === id);
2792
- if (index >= 0) {
2793
- selection.id = id;
2794
- selection.index = index;
2795
- }
2796
- };
2797
- const choice = await ctx.ui.custom((_tui, _theme, _kb, done) => {
2798
- const list = new SelectList(options, Math.min(options.length, 10), getSelectListTheme());
2799
- list.setSelectedIndex(initialIndex);
2800
- const initialItem = options[initialIndex];
2801
- if (initialItem)
2802
- remember(initialItem.value);
2803
- list.onSelectionChange = item => remember(item.value);
2804
- list.onSelect = item => {
2805
- remember(item.value);
2806
- done(item.value);
2807
- };
2808
- list.onCancel = () => done(undefined);
2809
- const container = new Container();
2810
- container.addChild(new Text(title, 0, 0));
2811
- container.addChild(new Spacer(1));
2812
- container.addChild(list);
2813
- return {
2814
- render: (width) => container.render(width),
2815
- invalidate: () => container.invalidate(),
2816
- handleInput: (data) => {
2817
- if (!isKeyRelease(data))
2818
- list.handleInput(data);
2819
- },
2820
- };
2821
- });
2822
- if (!choice)
2823
- return undefined;
2824
- return pairs.find(({ record }) => record.id === choice)?.record;
2825
- }
2826
- async function showRunningAgents(ctx) {
2827
- const agents = manager.listAgents().filter(record => isTopLevelAgent(record) && (record.status === "running" || record.status === "queued"));
2828
- if (agents.length === 0) {
2829
- ctx.ui.notify("No agents.", "info");
2830
- return;
2831
- }
2832
- const pairs = agents.map(record => ({
2833
- record,
2834
- label: `${getDisplayName(record.type)} (${record.description}) · ${record.toolUses} tools · ${record.status} · ${formatDuration(record.startedAt, record.completedAt)}`,
2835
- }));
2836
- makeUniqueAgentOptionLabels(pairs);
2837
- const record = await selectAgentFromReadOnlyList(ctx, "Running agents", pairs, runningAgentSelection);
2838
- if (!record)
2839
- return;
2840
- await viewAgentConversation(ctx, record);
2841
- await showRunningAgents(ctx);
2842
- }
2843
- async function showAgentHistory(ctx) {
2844
- const { history } = splitAgentRecords(manager.listAgents().filter(isTopLevelAgent), ctx.cwd);
2845
- if (history.length === 0) {
2846
- ctx.ui.notify("No agent history.", "info");
2847
- return;
2848
- }
2849
- const pairs = history.map(record => ({ record, label: formatAgentHistoryOption(record, Date.now()) }));
2850
- makeUniqueAgentOptionLabels(pairs);
2851
- const record = await selectAgentFromReadOnlyList(ctx, "Agent history", pairs, historyAgentSelection);
2852
- if (!record)
2853
- return;
2854
- await viewAgentConversation(ctx, record);
2855
- await showAgentHistory(ctx);
2856
- }
2857
- async function viewAgentConversation(ctx, record) {
2858
- const { ConversationViewer, VIEWPORT_HEIGHT_PCT, createStaticConversationSource } = await import("./ui/conversation-viewer.js");
2859
- const messages = record.transcriptPath ? readAgentHistory(ctx.cwd, record.transcriptPath) : undefined;
2860
- const session = record.session ?? (messages ? createStaticConversationSource(messages) : undefined);
2861
- if (!session) {
2862
- ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`, "info");
2863
- return;
2864
- }
2865
- const isHistory = record.session === undefined;
2866
- const activity = agentActivity.get(record.id);
2867
- await ctx.ui.custom((tui, theme, keybindings, done) => new ConversationViewer(tui, session, record, activity, theme, done, isHistory ? undefined : () => {
2868
- if (manager.abort(record.id))
2869
- ctx.ui.notify(`Stopped "${record.description}".`, "info");
2870
- }, keybindings, isHistory ? undefined : (message) => manager.steer(record.id, message), { pi, ctx, readOnly: isHistory }), {
2871
- overlay: true,
2872
- overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
2873
- });
2874
- }
2875
- async function showAgentDetail(ctx, name) {
2876
- const cfg = getAgentConfig(name);
2877
- if (!cfg) {
2878
- ctx.ui.notify(`Agent config not found for "${name}".`, "warning");
2879
- return;
2880
- }
2881
- const file = locateAgentFile(name, cfg.sourcePath);
2882
- const isDefault = cfg.isDefault === true;
2883
- const disabled = cfg.enabled === false;
2884
- let menuOptions;
2885
- if (disabled && file) {
2886
- // Disabled agent with a file — offer Enable
2887
- menuOptions = isDefault
2888
- ? ["Enable", "Edit", "Reset to default", "Delete", "Back"]
2889
- : ["Enable", "Edit", "Delete", "Back"];
2890
- }
2891
- else if (isDefault && !file) {
2892
- // Default agent with no .md override
2893
- menuOptions = ["Eject (export as .md)", "Disable", "Back"];
2894
- }
2895
- else if (isDefault && file) {
2896
- // Default agent with .md override (ejected)
2897
- menuOptions = ["Edit", "Disable", "Reset to default", "Delete", "Back"];
2898
- }
2899
- else {
2900
- // User-defined agent
2901
- menuOptions = ["Edit", "Disable", "Delete", "Back"];
2902
- }
2903
- const choice = await ctx.ui.select(name, menuOptions);
2904
- if (!choice || choice === "Back")
2905
- return;
2906
- if (choice === "Edit" && file) {
2907
- const content = readFileSync(file.path, "utf-8");
2908
- const edited = await ctx.ui.editor(`Edit ${name}`, content);
2909
- if (edited !== undefined && edited !== content) {
2910
- const { writeFileSync } = await import("node:fs");
2911
- writeFileSync(file.path, edited, "utf-8");
2912
- reloadCustomAgents();
2913
- ctx.ui.notify(`Updated ${file.path}`, "info");
2914
- }
2915
- }
2916
- else if (choice === "Delete") {
2917
- if (file) {
2918
- const confirmed = await ctx.ui.confirm("Delete agent", `Delete ${name} from ${file.location} (${file.path})?`);
2919
- if (confirmed) {
2920
- unlinkSync(file.path);
2921
- reloadCustomAgents();
2922
- ctx.ui.notify(`Deleted ${file.path}`, "info");
2923
- }
2924
- }
2925
- }
2926
- else if (choice === "Reset to default" && file) {
2927
- const confirmed = await ctx.ui.confirm("Reset to default", `Delete override ${file.path} and restore embedded default?`);
2928
- if (confirmed) {
2929
- unlinkSync(file.path);
2930
- reloadCustomAgents();
2931
- ctx.ui.notify(`Restored default ${name}`, "info");
2932
- }
2933
- }
2934
- else if (choice.startsWith("Eject")) {
2935
- await ejectAgent(ctx, name, cfg);
2936
- }
2937
- else if (choice === "Disable") {
2938
- await disableAgent(ctx, name);
2939
- }
2940
- else if (choice === "Enable") {
2941
- await enableAgent(ctx, name);
2942
- }
2943
- }
2944
- /** Eject a default agent: write its embedded config as a .md file. */
2945
- async function ejectAgent(ctx, name, cfg) {
2946
- const location = await ctx.ui.select("Choose location", [
2947
- "Project (.pi/agents/)",
2948
- `Personal (${personalAgentsDir()})`,
2949
- ]);
2950
- if (!location)
2951
- return;
2952
- const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
2953
- mkdirSync(targetDir, { recursive: true });
2954
- const targetPath = join(targetDir, `${name}.md`);
2955
- if (existsSync(targetPath)) {
2956
- const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
2957
- if (!overwrite)
2958
- return;
2959
- }
2960
- const content = serializeAgentFile(cfg);
2961
- const { writeFileSync } = await import("node:fs");
2962
- writeFileSync(targetPath, content, "utf-8");
2963
- reloadCustomAgents();
2964
- ctx.ui.notify(`Ejected ${name} to ${targetPath}`, "info");
2965
- }
2966
- /** Disable an agent: set enabled: false in its .md file, or create a stub for built-in defaults. */
2967
- async function disableAgent(ctx, name) {
2968
- const file = locateAgentFile(name, getAgentConfig(name)?.sourcePath);
2969
- if (file) {
2970
- // Existing file — set enabled: false in frontmatter (idempotent)
2971
- const content = readFileSync(file.path, "utf-8");
2972
- const { content: updated, outcome } = disableInContent(content);
2973
- if (outcome === "already-disabled") {
2974
- ctx.ui.notify(`${name} is already disabled.`, "info");
2975
- return;
2976
- }
2977
- if (outcome === "no-frontmatter") {
2978
- // Nothing to edit — say so rather than rewriting the file unchanged and
2979
- // reporting success for a change that never happened.
2980
- ctx.ui.notify(`Cannot disable ${name}: ${file.path} has no frontmatter block.`, "error");
2981
- return;
2982
- }
2983
- const { writeFileSync } = await import("node:fs");
2984
- writeFileSync(file.path, updated, "utf-8");
2985
- reloadCustomAgents();
2986
- ctx.ui.notify(`Disabled ${name} (${file.path})`, "info");
2987
- return;
2988
- }
2989
- // No file (built-in default) — create a stub
2990
- const location = await ctx.ui.select("Choose location", [
2991
- "Project (.pi/agents/)",
2992
- `Personal (${personalAgentsDir()})`,
2993
- ]);
2994
- if (!location)
2995
- return;
2996
- const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
2997
- mkdirSync(targetDir, { recursive: true });
2998
- const targetPath = join(targetDir, `${name}.md`);
2999
- const { writeFileSync } = await import("node:fs");
3000
- writeFileSync(targetPath, "---\nenabled: false\n---\n", "utf-8");
3001
- reloadCustomAgents();
3002
- ctx.ui.notify(`Disabled ${name} (${targetPath})`, "info");
3003
- }
3004
- /** Enable a disabled agent by removing enabled: false from its frontmatter. */
3005
- async function enableAgent(ctx, name) {
3006
- const file = locateAgentFile(name, getAgentConfig(name)?.sourcePath);
3007
- if (!file)
3008
- return;
3009
- const content = readFileSync(file.path, "utf-8");
3010
- const { content: updated, changed } = enableInContent(content);
3011
- if (!changed && !isEmptyStub(updated)) {
3012
- // The file carries no `enabled: false` to remove, so it was never disabled
3013
- // by us — reporting success here would hide a no-op.
3014
- ctx.ui.notify(`${name} is not disabled in ${file.path}.`, "info");
3015
- return;
3016
- }
3017
- const { writeFileSync } = await import("node:fs");
3018
- // If the file was just a stub ("---\n---\n"), delete it to restore the built-in default
3019
- if (isEmptyStub(updated)) {
3020
- unlinkSync(file.path);
3021
- reloadCustomAgents();
3022
- ctx.ui.notify(`Enabled ${name} (removed ${file.path})`, "info");
3023
- }
3024
- else {
3025
- writeFileSync(file.path, updated, "utf-8");
3026
- reloadCustomAgents();
3027
- ctx.ui.notify(`Enabled ${name} (${file.path})`, "info");
3028
- }
3029
- }
3030
- async function showCreateWizard(ctx) {
3031
- const location = await ctx.ui.select("Choose location", [
3032
- "Project (.pi/agents/)",
3033
- `Personal (${personalAgentsDir()})`,
3034
- ]);
3035
- if (!location)
3036
- return;
3037
- const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
3038
- const method = await ctx.ui.select("Creation method", [
3039
- "Generate with Claude (recommended)",
3040
- "Manual configuration",
3041
- ]);
3042
- if (!method)
3043
- return;
3044
- if (method.startsWith("Generate")) {
3045
- await showGenerateWizard(ctx, targetDir);
3046
- }
3047
- else {
3048
- await showManualWizard(ctx, targetDir);
3049
- }
3050
- }
3051
- async function showGenerateWizard(ctx, targetDir) {
3052
- const description = await ctx.ui.input("Describe what this agent should do");
3053
- if (!description)
3054
- return;
3055
- const name = await ctx.ui.input("Agent name (filename, no spaces)");
3056
- if (!name)
3057
- return;
3058
- mkdirSync(targetDir, { recursive: true });
3059
- const targetPath = join(targetDir, `${name}.md`);
3060
- if (existsSync(targetPath)) {
3061
- const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
3062
- if (!overwrite)
3063
- return;
3064
- }
3065
- ctx.ui.notify("Generating agent definition...", "info");
3066
- const generatePrompt = `Create a custom pi sub-agent definition file based on this description: "${description}"
3067
-
3068
- Write a markdown file to: ${targetPath}
3069
-
3070
- The file format is a markdown file with YAML frontmatter and a system prompt body:
3071
-
3072
- \`\`\`markdown
3073
- ---
3074
- description: <one-line description shown in UI>
3075
- color: <optional agent name badge color: red, blue, green, yellow, purple, orange, pink, cyan, an Agency Agents alias, or quoted "#RRGGBB">
3076
- tools: <comma-separated built-in tools: read, bash, edit, write, grep, find, ls. Use "none" for no tools. Omit for all tools>
3077
- model: <optional model as "provider/modelId", e.g. "anthropic/claude-haiku-4-5". Omit to inherit parent model>
3078
- thinking: <optional thinking level: ${THINKING_LEVELS.join(", ")}. Omit to inherit>
3079
- max_turns: <optional max agentic turns. 0 or omit for unlimited (default)>
3080
- prompt_mode: <"replace" (body IS the full system prompt) or "append" (body is appended to default prompt). Default: replace>
3081
- extensions: <true (inherit all MCP/extension tools), false (none), or comma-separated names. Default: true>
3082
- skills: <true (inherit all), false (none), or comma-separated skill names to preload into prompt. Default: true>
3083
- disallowed_tools: <comma-separated tool names to block, even if otherwise available. Omit for none>
3084
- inherit_context: <true to fork parent conversation into agent so it sees chat history. Default: false>
3085
- run_in_background: <pin this agent to background (true) or foreground (false). Omit to follow the backgroundByDefault setting, which is background>
3086
- output_transcript: <false to write no transcript file or path for this agent. Independent of persist_session. Default: true>
3087
- isolated: <true for no extension/MCP tools, only built-in tools. Default: false>
3088
- memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>${
3089
- // Offering the field on a project that turned worktrees off would bake a
3090
- // request that is refused at spawn time into a file that outlives the
3091
- // session — the #231 pathology (models fill the fields they are shown)
3092
- // one layer up. Built per invocation, so this read is live.
3093
- isWorktreeIsolationEnabled()
3094
- ? `\nisolation: <"worktree" to run in isolated git worktree; "off" to refuse one even when the caller asks. Omit for normal>`
3095
- : ""}
3096
- ---
3097
-
3098
- <system prompt body — instructions for the agent>
3099
- \`\`\`
3100
-
3101
- Guidelines for choosing settings:
3102
- - For read-only tasks (review, analysis): tools: read, bash, grep, find, ls
3103
- - For code modification tasks: include edit, write
3104
- - Use prompt_mode: append if the agent should keep the default system prompt and add specialization on top
3105
- - Use prompt_mode: replace for fully custom agents with their own personality/instructions
3106
- - Set inherit_context: true if the agent needs to know what was discussed in the parent conversation
3107
- - Set isolated: true if the agent should NOT have access to MCP servers or other extensions
3108
- - Set output_transcript: false to skip writing this agent's transcript; this alone doesn't keep the run off disk (persist_session, isolation: worktree commits, and memory still write) — set those too if that's the goal
3109
- - Only include frontmatter fields that differ from defaults — omit fields where the default is fine
3110
-
3111
- Write the file using the write tool. Only write the file, nothing else.`;
3112
- const { record } = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, {
3113
- description: `Generate ${name} agent`,
3114
- maxTurns: 5,
3115
- // Exempt from maxConcurrentForeground. This runs from a modal wizard, not
3116
- // a tool call: it passes no signal, and Esc in `ctx.ui` never reaches the
3117
- // manager — so a user waiting behind a full pool would have no way to
3118
- // cancel at all. It is also one human action that cannot fan out, which
3119
- // is what the limit exists to bound. It still counts once started.
3120
- bypassQueue: true,
3121
- });
3122
- if (record.status === "error") {
3123
- ctx.ui.notify(`Generation failed: ${record.error}`, "warning");
3124
- return;
3125
- }
3126
- reloadCustomAgents();
3127
- if (existsSync(targetPath)) {
3128
- ctx.ui.notify(`Created ${targetPath}`, "info");
3129
- }
3130
- else {
3131
- ctx.ui.notify("Agent generation completed but file was not created. Check the agent output.", "warning");
3132
- }
3133
- }
3134
- async function showManualWizard(ctx, targetDir) {
3135
- // 1. Name
3136
- const name = await ctx.ui.input("Agent name (filename, no spaces)");
3137
- if (!name)
3138
- return;
3139
- // 2. Description
3140
- const description = await ctx.ui.input("Description (one line)");
3141
- if (!description)
3142
- return;
3143
- // 3. Tools
3144
- const toolChoice = await ctx.ui.select("Tools", ["all", "none", "read-only (read, bash, grep, find, ls)", "custom..."]);
3145
- if (!toolChoice)
3146
- return;
3147
- let tools;
3148
- if (toolChoice === "all") {
3149
- tools = BUILTIN_TOOL_NAMES.join(", ");
3150
- }
3151
- else if (toolChoice === "none") {
3152
- tools = "none";
3153
- }
3154
- else if (toolChoice.startsWith("read-only")) {
3155
- tools = "read, bash, grep, find, ls";
3156
- }
3157
- else {
3158
- const customTools = await ctx.ui.input("Tools (comma-separated)", BUILTIN_TOOL_NAMES.join(", "));
3159
- if (!customTools)
3160
- return;
3161
- tools = customTools;
3162
- }
3163
- // 4. Model
3164
- const modelChoice = await ctx.ui.select("Model", [
3165
- "inherit (parent model)",
3166
- "haiku",
3167
- "sonnet",
3168
- "opus",
3169
- "custom...",
3170
- ]);
3171
- if (!modelChoice)
3172
- return;
3173
- let model;
3174
- if (modelChoice === "haiku")
3175
- model = "anthropic/claude-haiku-4-5";
3176
- else if (modelChoice === "sonnet")
3177
- model = "anthropic/claude-sonnet-4-6";
3178
- else if (modelChoice === "opus")
3179
- model = "anthropic/claude-opus-4-6";
3180
- else if (modelChoice === "custom...") {
3181
- model = (await ctx.ui.input("Model (provider/modelId)")) || undefined;
3182
- }
3183
- // 5. Thinking
3184
- // "inherit" is a UI-only pseudo-choice (omit the field); the rest mirror pi.
3185
- const thinkingChoice = await ctx.ui.select("Thinking level", ["inherit", ...THINKING_LEVELS]);
3186
- if (!thinkingChoice)
3187
- return;
3188
- // 6. System prompt
3189
- const systemPrompt = await ctx.ui.editor("System prompt", "");
3190
- if (systemPrompt === undefined)
3191
- return;
3192
- const content = buildNewAgentFile({
3193
- description,
3194
- tools,
3195
- model,
3196
- thinking: thinkingChoice === "inherit" ? undefined : thinkingChoice,
3197
- systemPrompt,
3198
- });
3199
- mkdirSync(targetDir, { recursive: true });
3200
- const targetPath = join(targetDir, `${name}.md`);
3201
- if (existsSync(targetPath)) {
3202
- const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
3203
- if (!overwrite)
3204
- return;
3205
- }
3206
- const { writeFileSync } = await import("node:fs");
3207
- writeFileSync(targetPath, content, "utf-8");
3208
- reloadCustomAgents();
3209
- ctx.ui.notify(`Created ${targetPath}`, "info");
3210
- }
3211
- /**
3212
- * Every settings mutation writes this WHOLE object back to disk, so a field
3213
- * missing here is erased from the user's subagents.json the next time they
3214
- * toggle something unrelated. `SubagentsSettings` has every field optional,
3215
- * so a `: SubagentsSettings` return annotation would let a newly-added setting
3216
- * be forgotten here and still type-check. `satisfies` instead: it still checks
3217
- * each value's type and rejects a mistyped key, but leaves the return type
3218
- * inferred so `_NoMissingSettingsKeys` below can check completeness.
3219
- */
3220
- function snapshotSettings() {
3221
- return {
3222
- maxConcurrent: manager.getMaxConcurrent(),
3223
- // 0 = unlimited, and the default — see SubagentsSettings.
3224
- maxConcurrentForeground: manager.getMaxConcurrentForeground(),
3225
- // 0 = unlimited — per SubagentsSettings.defaultMaxTurns docstring and
3226
- // normalizeMaxTurns() in agent-runner.ts (which maps 0 → undefined).
3227
- defaultMaxTurns: getDefaultMaxTurns() ?? 0,
3228
- graceTurns: getGraceTurns(),
3229
- defaultJoinMode: getDefaultJoinMode(),
3230
- backgroundByDefault: getBackgroundByDefault(),
3231
- schedulingEnabled: isSchedulingEnabled(),
3232
- scopeModels: isScopeModelsEnabled(),
3233
- strictAgentFiles,
3234
- disableDefaultAgents: isDefaultsDisabled(),
3235
- toolDescriptionMode: getToolDescriptionMode(),
3236
- agentMentions: getAgentMentionMode(),
3237
- rememberAgents: getRememberAgents(),
3238
- widgetMode: getWidgetMode(),
3239
- outputTranscript: getOutputTranscriptDefault(),
3240
- worktreeIsolation: isWorktreeIsolationEnabled(),
3241
- // The user's answer, not the effective one. A stand-down for another
3242
- // extension's workflow tool is scoped to the session it was detected in;
3243
- // writing it here would let an unrelated settings change three menus away
3244
- // freeze it into the file as an explicit `false`, which then survives
3245
- // uninstalling the extension it was deferring to. undefined is dropped by
3246
- // JSON.stringify, so unset stays unset — same reasoning as
3247
- // `fallbackSubagent` below.
3248
- workflowsEnabled: isWorkflowsPinned() ? isWorkflowsEnabled() : undefined,
3249
- maxSubagentDepth: getMaxSubagentDepth(),
3250
- // Deliberately NOT `?? "general-purpose"`: every settings change writes the
3251
- // whole snapshot, and materializing the implicit default would turn it into
3252
- // explicit configuration — which then fails loudly if general-purpose later
3253
- // goes away. undefined is dropped by JSON.stringify.
3254
- fallbackSubagent: getFallbackSubagent(),
3255
- reportUsage: isReportUsageEnabled(),
3256
- showCost: isShowCostEnabled(),
3257
- showModel: isShowModelEnabled(),
3258
- viewerMarkdown: getViewerMarkdown(),
3259
- };
3260
- }
3261
- const _settingsSnapshotIsComplete = true;
3262
- void _settingsSnapshotIsComplete;
3263
- const NUMERIC_IDS = new Set([
3264
- "maxConcurrent", "maxConcurrentForeground", "defaultMaxTurns", "graceTurns", "maxSubagentDepth",
3265
- ]);
3266
- async function showSettings(ctx) {
3267
- function buildItems() {
3268
- const mc = manager.getMaxConcurrent();
3269
- const mcf = manager.getMaxConcurrentForeground();
3270
- const dmt = getDefaultMaxTurns() ?? 0;
3271
- const gt = getGraceTurns();
3272
- const msd = getMaxSubagentDepth();
3273
- // Label what unset actually does — it targets general-purpose even when
3274
- // that is unregistered (the permissive hardcoded tier), so showing "none"
3275
- // there would advertise strict dispatch for the most permissive state.
3276
- // `values` still offers only resolvable targets, so the user cannot
3277
- // persist a fallback that would hard-error on every dispatch.
3278
- const fallbackValue = getFallbackSubagent() ?? "general-purpose";
3279
- const fallbackValues = [...new Set([...getAvailableTypes(), NO_FALLBACK])];
3280
- return [
3281
- {
3282
- id: "maxConcurrent",
3283
- label: "Max concurrency",
3284
- description: "Max concurrent background agents (Enter to type)",
3285
- currentValue: String(mc),
3286
- values: [String(mc)],
3287
- },
3288
- {
3289
- id: "maxConcurrentForeground",
3290
- label: "Max foreground concurrency",
3291
- description: "Max concurrent foreground (blocking) agents (0 = unlimited, Enter to type)",
3292
- currentValue: String(mcf),
3293
- values: [String(mcf)],
3294
- },
3295
- {
3296
- id: "defaultMaxTurns",
3297
- label: "Default max turns",
3298
- description: "Default max turns before wrap-up (0 = unlimited, Enter to type)",
3299
- currentValue: String(dmt),
3300
- values: [String(dmt)],
3301
- },
3302
- {
3303
- id: "graceTurns",
3304
- label: "Grace turns",
3305
- description: "Grace turns after wrap-up steer (Enter to type)",
3306
- currentValue: String(gt),
3307
- values: [String(gt)],
3308
- },
3309
- {
3310
- id: "maxSubagentDepth",
3311
- label: "Nested depth",
3312
- description: "Hard cap on nested delegation — main is 0, its subagents 1 (0/1 = nesting off, Enter to type)",
3313
- currentValue: String(msd),
3314
- values: [String(msd)],
3315
- },
3316
- {
3317
- id: "joinMode",
3318
- label: "Join mode",
3319
- description: "Default join mode for background agents",
3320
- currentValue: getDefaultJoinMode(),
3321
- values: ["smart", "async", "group"],
3322
- },
3323
- {
3324
- id: "backgroundByDefault",
3325
- label: "Background by default",
3326
- description: "An Agent call that doesn't say runs detached (off = blocks the turn and returns inline)",
3327
- currentValue: getBackgroundByDefault() ? "on" : "off",
3328
- values: ["on", "off"],
3329
- },
3330
- {
3331
- id: "schedulingEnabled",
3332
- label: "Scheduling",
3333
- description: "Schedule subagent feature (off removes `schedule` param from Agent tool spec on next pi session)",
3334
- currentValue: isSchedulingEnabled() ? "on" : "off",
3335
- values: ["on", "off"],
3336
- },
3337
- {
3338
- id: "workflowsEnabled",
3339
- label: "Workflows",
3340
- description: "Scripted workflows, on unless another extension provides a workflow tool "
3341
- + "(off keeps the SubagentWorkflow tool out of the tool spec; applies on next pi session)",
3342
- currentValue: isWorkflowsEnabled() ? "on" : "off",
3343
- values: ["on", "off"],
3344
- },
3345
- {
3346
- id: "scopeModels",
3347
- label: "Scope models",
3348
- description: "Validate subagent models against scoped models (/scoped-models)",
3349
- currentValue: isScopeModelsEnabled() ? "on" : "off",
3350
- values: ["on", "off"],
3351
- },
3352
- {
3353
- id: "strictAgentFiles",
3354
- label: "Strict agent files",
3355
- description: "Fail startup on an unreadable/unparseable agent .md instead of skipping it with a warning",
3356
- currentValue: strictAgentFiles ? "on" : "off",
3357
- values: ["on", "off"],
3358
- },
3359
- {
3360
- id: "disableDefaultAgents",
3361
- label: "Disable defaults",
3362
- description: "Hide built-in agents (general-purpose, Explore, Plan) — custom agents are unaffected",
3363
- currentValue: isDefaultsDisabled() ? "on" : "off",
3364
- values: ["on", "off"],
3365
- },
3366
- {
3367
- id: "fallbackSubagent",
3368
- label: "Fallback agent",
3369
- description: `Agent used when subagent_type is unknown, disabled, or ambiguous; "${NO_FALLBACK}" rejects the call instead (strict dispatch)`,
3370
- currentValue: fallbackValue,
3371
- values: fallbackValues,
3372
- },
3373
- {
3374
- id: "outputTranscript",
3375
- label: "Output transcript",
3376
- description: "Write each subagent's .output transcript by default. A custom agent's output_transcript frontmatter overrides this.",
3377
- currentValue: getOutputTranscriptDefault() ? "on" : "off",
3378
- values: ["on", "off"],
3379
- },
3380
- {
3381
- id: "worktreeIsolation",
3382
- label: "Worktree isolation",
3383
- description: "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.",
3384
- currentValue: isWorktreeIsolationEnabled() ? "on" : "off",
3385
- values: ["on", "off"],
3386
- },
3387
- {
3388
- id: "reportUsage",
3389
- label: "Report usage to session",
3390
- description: "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.",
3391
- currentValue: isReportUsageEnabled() ? "on" : "off",
3392
- values: ["on", "off"],
3393
- },
3394
- {
3395
- id: "showCost",
3396
- label: "Show cost",
3397
- description: "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.",
3398
- currentValue: isShowCostEnabled() ? "on" : "off",
3399
- values: ["on", "off"],
3400
- },
3401
- {
3402
- id: "showModel",
3403
- label: "Show model",
3404
- description: "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.",
3405
- currentValue: isShowModelEnabled() ? "on" : "off",
3406
- values: ["on", "off"],
3407
- },
3408
- {
3409
- id: "viewerMarkdown",
3410
- label: "Viewer markdown",
3411
- description: "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+).",
3412
- currentValue: getViewerMarkdown(),
3413
- values: ["off", "assistant", "all"],
3414
- },
3415
- {
3416
- id: "agentMentions",
3417
- label: "Agent mentions",
3418
- 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.",
3419
- currentValue: getAgentMentionMode(),
3420
- values: ["model", "direct", "off"],
3421
- },
3422
- {
3423
- id: "rememberAgents",
3424
- label: "Remember agents",
3425
- description: "Persist subagent sessions so `@handle` can resume one long after it finished (they also appear in /resume)",
3426
- currentValue: getRememberAgents() ? "on" : "off",
3427
- values: ["on", "off"],
3428
- },
3429
- {
3430
- id: "widgetMode",
3431
- label: "Widget",
3432
- description: "Above-editor agent widget: all = every agent; background = hide foreground (they already render inline); off = hide the widget.",
3433
- currentValue: getWidgetMode(),
3434
- values: ["all", "background", "off"],
3435
- },
3436
- {
3437
- id: "toolDescriptionMode",
3438
- label: "Tool description",
3439
- description: "Agent tool description sent to the LLM: full (rich, default), compact (~75% fewer tokens, for small/local models), or custom (.pi/agent-tool-description.md with {{placeholders}})",
3440
- currentValue: getToolDescriptionMode(),
3441
- values: ["full", "compact", "custom"],
3442
- },
3443
- ];
3444
- }
3445
- function applyValue(id, value) {
3446
- if (id === "maxConcurrent") {
3447
- const n = parseInt(value, 10);
3448
- if (n >= 1) {
3449
- manager.setMaxConcurrent(n);
3450
- notifyApplied(ctx, `Max concurrency set to ${n}`);
3451
- }
3452
- }
3453
- else if (id === "maxConcurrentForeground") {
3454
- // 0 is meaningful here, unlike maxConcurrent above: it means unlimited.
3455
- const n = parseInt(value, 10);
3456
- if (n >= 0) {
3457
- manager.setMaxConcurrentForeground(n);
3458
- notifyApplied(ctx, n === 0
3459
- ? "Max foreground concurrency set to unlimited"
3460
- : `Max foreground concurrency set to ${n}`);
3461
- }
3462
- }
3463
- else if (id === "defaultMaxTurns") {
3464
- const n = parseInt(value, 10);
3465
- if (n === 0) {
3466
- setDefaultMaxTurns(undefined);
3467
- notifyApplied(ctx, "Default max turns set to unlimited");
3468
- }
3469
- else if (n >= 1) {
3470
- setDefaultMaxTurns(n);
3471
- notifyApplied(ctx, `Default max turns set to ${n}`);
3472
- }
3473
- }
3474
- else if (id === "graceTurns") {
3475
- const n = parseInt(value, 10);
3476
- if (n >= 1) {
3477
- setGraceTurns(n);
3478
- notifyApplied(ctx, `Grace turns set to ${n}`);
3479
- }
3480
- }
3481
- else if (id === "maxSubagentDepth") {
3482
- const n = parseInt(value, 10);
3483
- if (n >= 0) {
3484
- setMaxSubagentDepth(n);
3485
- notifyApplied(ctx, n <= 1
3486
- ? "Nested delegation disabled"
3487
- : `Nested depth set to ${n}. Applies to agents started from now on.`);
3488
- }
3489
- }
3490
- else if (id === "joinMode") {
3491
- setDefaultJoinMode(value);
3492
- notifyApplied(ctx, `Default join mode set to ${value}`);
3493
- }
3494
- else if (id === "backgroundByDefault") {
3495
- const enabled = value === "on";
3496
- setBackgroundByDefault(enabled);
3497
- notifyApplied(ctx, enabled
3498
- ? "Agent calls run in the background unless they pass run_in_background: false"
3499
- : "Agent calls block and return inline unless they pass run_in_background: true");
3500
- }
3501
- else if (id === "schedulingEnabled") {
3502
- const enabled = value === "on";
3503
- if (enabled === isSchedulingEnabled()) {
3504
- ctx.ui.notify(`Scheduling already ${enabled ? "enabled" : "disabled"}.`, "info");
3505
- }
3506
- else {
3507
- setSchedulingEnabled(enabled);
3508
- if (!enabled)
3509
- scheduler.stop(); // immediate kill — outstanding fires stop ticking
3510
- notifyApplied(ctx, `Scheduling ${enabled ? "enabled" : "disabled"}. Tool spec change takes effect on next pi session.`);
3511
- }
3512
- }
3513
- else if (id === "workflowsEnabled") {
3514
- const enabled = value === "on";
3515
- if (enabled === isWorkflowsEnabled()) {
3516
- ctx.ui.notify(`Workflows already ${enabled ? "enabled" : "disabled"}.`, "info");
3517
- }
3518
- else {
3519
- setWorkflowsEnabled(enabled);
3520
- // Runs already in flight keep going: the switch governs whether the
3521
- // tool is offered, and killing live agents on a settings toggle would
3522
- // lose work the user never asked to discard.
3523
- notifyApplied(ctx, `Workflows ${enabled ? "enabled" : "disabled"}. Tool spec change takes effect on next pi session.`);
3524
- }
3525
- }
3526
- else if (id === "scopeModels") {
3527
- const enabled = value === "on";
3528
- setScopeModelsEnabled(enabled);
3529
- notifyApplied(ctx, `Scope models ${enabled ? "enabled" : "disabled"}`);
3530
- }
3531
- else if (id === "strictAgentFiles") {
3532
- const enabled = value === "on";
3533
- strictAgentFiles = enabled;
3534
- notifyApplied(ctx, `Strict agent files ${enabled ? "enabled" : "disabled"}. Takes effect on next pi session.`);
3535
- }
3536
- else if (id === "disableDefaultAgents") {
3537
- const enabled = value === "on";
3538
- setDisableDefaultAgents(enabled);
3539
- notifyApplied(ctx, `Default agents ${enabled ? "disabled" : "enabled"}. Tool spec change takes effect on next pi session.`);
3540
- }
3541
- else if (id === "fallbackSubagent") {
3542
- setFallbackSubagent(value);
3543
- notifyApplied(ctx, value === NO_FALLBACK
3544
- ? "Unknown or disabled agent types will now be rejected"
3545
- : `Unknown agent types will fall back to ${value}`);
3546
- }
3547
- else if (id === "outputTranscript") {
3548
- const enabled = value === "on";
3549
- setOutputTranscriptDefault(enabled);
3550
- notifyApplied(ctx, `Output transcript ${enabled ? "enabled" : "disabled"} by default`);
3551
- }
3552
- else if (id === "worktreeIsolation") {
3553
- const enabled = value === "on";
3554
- setWorktreeIsolationEnabled(enabled);
3555
- // The refusal is live, but the tool schema is built at registration, so
3556
- // the isolation parameter only appears/disappears next session.
3557
- notifyApplied(ctx, `Worktree isolation ${enabled ? "enabled" : "disabled"}. Tool parameter updates on next pi session.`);
3558
- }
3559
- else if (id === "toolDescriptionMode") {
3560
- setToolDescriptionMode(value);
3561
- notifyApplied(ctx, `Tool description set to ${value}. Takes effect on next pi session.`);
3562
- }
3563
- else if (id === "reportUsage") {
3564
- const enabled = value === "on";
3565
- setReportUsage(enabled);
3566
- notifyApplied(ctx, enabled
3567
- ? "Subagent usage now counted in this session's totals"
3568
- : "Subagent usage no longer counted in this session's totals");
3569
- }
3570
- else if (id === "showCost") {
3571
- const enabled = value === "on";
3572
- setShowCost(enabled);
3573
- notifyApplied(ctx, `Cost display ${enabled ? "enabled" : "disabled"}`);
3574
- }
3575
- else if (id === "showModel") {
3576
- const enabled = value === "on";
3577
- setShowModel(enabled);
3578
- notifyApplied(ctx, `Model display ${enabled ? "enabled" : "disabled"}`);
3579
- }
3580
- else if (id === "viewerMarkdown") {
3581
- setViewerMarkdown(value);
3582
- notifyApplied(ctx, `Viewer markdown set to ${value}`);
3583
- }
3584
- else if (id === "agentMentions") {
3585
- const mode = value;
3586
- setAgentMentionMode(mode);
3587
- notifyApplied(ctx, mode === "off"
3588
- ? "Agent mentions disabled"
3589
- : mode === "model"
3590
- ? "Agent mentions on — a conversation clone starts a mentioned agent off-screen"
3591
- : "Agent mentions on — a mentioned agent starts here, with no model call");
3592
- }
3593
- else if (id === "rememberAgents") {
3594
- const enabled = value === "on";
3595
- setRememberAgents(enabled);
3596
- notifyApplied(ctx, `Remember agents ${enabled ? "enabled" : "disabled"}`);
3597
- }
3598
- else if (id === "widgetMode") {
3599
- setWidgetMode(value);
3600
- notifyApplied(ctx, `Widget set to ${value}`);
3601
- }
3602
- }
3603
- let list;
3604
- // Track current selection index directly (SettingsList doesn't expose it).
3605
- // Updated on arrow keys so Enter knows which field is selected immediately.
3606
- let currentIndex = 0;
3607
- const result = await ctx.ui.custom((_tui, _theme, _kb, done) => {
3608
- const items = buildItems();
3609
- list = new SettingsList(items, items.length + 2, getSettingsListTheme(), (id, newValue) => {
3610
- applyValue(id, newValue);
3611
- }, () => done(undefined));
3612
- const container = new Container();
3613
- container.addChild(new Text("⚙ Subagent Settings", 0, 0));
3614
- container.addChild(new Spacer(1));
3615
- container.addChild(list);
3616
- return {
3617
- render: (w) => container.render(w),
3618
- invalidate: () => container.invalidate(),
3619
- handleInput: (data) => {
3620
- // Track navigation so Enter knows the current field
3621
- if (matchesKey(data, "up")) {
3622
- currentIndex = Math.max(0, currentIndex - 1);
3623
- }
3624
- else if (matchesKey(data, "down")) {
3625
- currentIndex = Math.min(items.length - 1, currentIndex + 1);
3626
- }
3627
- // Enter on numeric field → close and prompt for typed input
3628
- if (matchesKey(data, Key.enter) && NUMERIC_IDS.has(items[currentIndex].id)) {
3629
- done(items[currentIndex].id);
3630
- return;
3631
- }
3632
- list.handleInput?.(data);
3633
- },
3634
- };
3635
- });
3636
- // If a numeric field ID was returned, prompt for typed input
3637
- if (result && NUMERIC_IDS.has(result)) {
3638
- const current = result === "maxConcurrent"
3639
- ? String(manager.getMaxConcurrent())
3640
- : result === "maxConcurrentForeground"
3641
- ? String(manager.getMaxConcurrentForeground())
3642
- : result === "defaultMaxTurns"
3643
- ? String(getDefaultMaxTurns() ?? 0)
3644
- : result === "maxSubagentDepth"
3645
- ? String(getMaxSubagentDepth())
3646
- : String(getGraceTurns());
3647
- const label = result === "maxConcurrent"
3648
- ? "Max concurrency (1+)"
3649
- : result === "maxConcurrentForeground"
3650
- ? "Max foreground concurrency (0 = unlimited)"
3651
- : result === "defaultMaxTurns"
3652
- ? "Default max turns (0 = unlimited)"
3653
- : result === "maxSubagentDepth"
3654
- ? "Nested depth (0/1 = nesting off)"
3655
- : "Grace turns (1+)";
3656
- // Loop until user enters a valid integer or cancels (Esc / null).
3657
- // Silently trims whitespace; rejects non-numeric input by re-prompting.
3658
- let input = await ctx.ui.input(label, current);
3659
- while (input != null) {
3660
- const trimmed = input.trim();
3661
- const n = Number(trimmed);
3662
- if (trimmed !== "" && Number.isInteger(n)) {
3663
- applyValue(result, String(n));
3664
- await showSettings(ctx);
3665
- return;
3666
- }
3667
- // Invalid — re-prompt with the user's last entry so they can edit it
3668
- input = await ctx.ui.input(label, trimmed);
3669
- }
3670
- }
3671
- }
3672
- function notifyApplied(ctx, successMsg) {
3673
- const { message, level } = saveAndEmitChanged(snapshotSettings(), successMsg, (event, payload) => pi.events.emit(event, payload));
3674
- ctx.ui.notify(message, level);
3675
- }
3676
- pi.registerCommand("agents", {
3677
- description: "Manage agents",
3678
- handler: async (_args, ctx) => { await showAgentsMenu(ctx); },
3679
- });
3680
- /** Dependencies shared by `/agents → Workflows` and its inspector. */
3681
- const workflowMenuDeps = {
3682
- tasks: workflowTasks,
3683
- getRecord: id => manager.getRecord(id),
3684
- viewAgentConversation,
3685
- };
3686
- }
3687
- //# sourceMappingURL=index.js.map