@pi-unipi/unipi 3.0.0-alpha.2 → 3.0.0-alpha.21

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 +292 -0
  2. package/README.md +8 -11
  3. package/docs/prefix-cache-architecture.md +14 -0
  4. package/package.json +22 -24
  5. package/packages/ask-user/README.md +55 -107
  6. package/packages/ask-user/ask-ui.ts +306 -732
  7. package/packages/ask-user/clipboard.ts +50 -0
  8. package/packages/ask-user/config.ts +74 -78
  9. package/packages/ask-user/package.json +2 -2
  10. package/packages/ask-user/questions.ts +151 -0
  11. package/packages/ask-user/skills/ask-user/SKILL.md +59 -133
  12. package/packages/ask-user/tools.ts +198 -432
  13. package/packages/autocomplete/package.json +1 -1
  14. package/packages/autocomplete/src/constants.ts +38 -43
  15. package/packages/autocomplete/src/sorting.ts +8 -0
  16. package/packages/background-tasks/package.json +2 -2
  17. package/packages/background-tasks/src/config.ts +0 -73
  18. package/packages/background-tasks/src/index.ts +20 -16
  19. package/packages/background-tasks/src/registry.ts +1 -171
  20. package/packages/background-tasks/src/types.ts +0 -32
  21. package/packages/btw/README.md +13 -56
  22. package/packages/btw/extensions/btw.ts +403 -1796
  23. package/packages/btw/package.json +5 -2
  24. package/packages/btw/skills/btw/SKILL.md +7 -143
  25. package/packages/compactor/README.md +56 -287
  26. package/packages/compactor/package.json +5 -9
  27. package/packages/compactor/skills/compactor/SKILL.md +13 -20
  28. package/packages/compactor/src/card.ts +90 -0
  29. package/packages/compactor/src/commands/index.ts +73 -130
  30. package/packages/compactor/src/compaction/compact-args.ts +5 -3
  31. package/packages/compactor/src/compaction/filter-noise.ts +1 -1
  32. package/packages/compactor/src/compaction/hooks.ts +487 -311
  33. package/packages/compactor/src/compaction/source.ts +178 -0
  34. package/packages/compactor/src/compaction/summarize.ts +586 -52
  35. package/packages/compactor/src/config/manager.ts +130 -127
  36. package/packages/compactor/src/config/schema.ts +32 -48
  37. package/packages/compactor/src/index.ts +40 -431
  38. package/packages/compactor/src/info-screen.ts +44 -190
  39. package/packages/compactor/src/session/recall-blocks.ts +1 -1
  40. package/packages/compactor/src/stats.ts +76 -0
  41. package/packages/compactor/src/tools/context-budget.ts +17 -34
  42. package/packages/compactor/src/tools/ctx-doctor.ts +45 -36
  43. package/packages/compactor/src/tools/register.ts +29 -269
  44. package/packages/compactor/src/types.ts +47 -186
  45. package/packages/core/README.md +25 -0
  46. package/packages/core/child-agent.ts +1 -0
  47. package/packages/core/command-echo.ts +63 -0
  48. package/packages/core/compaction-context.ts +41 -0
  49. package/packages/core/constants.ts +8 -38
  50. package/packages/core/events.ts +0 -53
  51. package/packages/core/fusion-status.ts +4 -0
  52. package/packages/core/global.d.ts +10 -0
  53. package/packages/core/harness-messages.ts +392 -0
  54. package/packages/core/index.ts +14 -0
  55. package/packages/core/kanboard-status.ts +49 -0
  56. package/packages/core/long-horizon-owner-status.ts +46 -0
  57. package/packages/core/model-cache.ts +81 -7
  58. package/packages/core/package.json +6 -3
  59. package/packages/core/src/attach/detect.ts +163 -0
  60. package/packages/core/src/child-agent/child-args.ts +31 -0
  61. package/packages/core/src/child-agent/delivery.ts +41 -0
  62. package/packages/core/src/child-agent/index.ts +5 -0
  63. package/packages/core/src/child-agent/readers.ts +203 -0
  64. package/packages/core/src/child-agent/runtime.ts +584 -0
  65. package/packages/core/src/evidence.ts +78 -0
  66. package/packages/core/src/hints/browser.ts +278 -0
  67. package/packages/core/src/hints/crab-data.ts +19 -0
  68. package/packages/core/src/hints/index.ts +865 -0
  69. package/packages/core/src/hints/lines.ts +607 -0
  70. package/packages/core/src/hints/store.ts +120 -0
  71. package/packages/core/src/jev/chatter.ts +24 -0
  72. package/packages/core/src/jev/settings.ts +177 -14
  73. package/packages/core/src/package-colors.ts +2 -0
  74. package/packages/core/src/settings/catalog.ts +39 -15
  75. package/packages/core/src/settings/engine.ts +48 -2
  76. package/packages/core/src/settings/hub.ts +83 -20
  77. package/packages/core/src/settings/open.ts +49 -0
  78. package/packages/core/src/settings/schema.ts +27 -7
  79. package/packages/core/src/tui/hub-kit.ts +18 -5
  80. package/packages/core/src/tui/kit.ts +198 -0
  81. package/packages/core/src/tui/progress.ts +73 -0
  82. package/packages/core/src/turn/arbiter.ts +290 -0
  83. package/packages/footer/README.md +4 -6
  84. package/packages/footer/package.json +3 -3
  85. package/packages/footer/src/glance-editor.ts +19 -11
  86. package/packages/footer/src/help.ts +1 -1
  87. package/packages/footer/src/index.ts +36 -3
  88. package/packages/footer/src/presets.ts +3 -4
  89. package/packages/footer/src/rendering/icons.ts +0 -20
  90. package/packages/footer/src/segments/compactor.ts +52 -142
  91. package/packages/footer/src/segments/status-ext.ts +0 -1
  92. package/packages/fusion/README.md +14 -4
  93. package/packages/fusion/package.json +4 -3
  94. package/packages/fusion/src/child-args.ts +1 -0
  95. package/packages/fusion/src/index.ts +161 -8
  96. package/packages/fusion/src/pi-settings.ts +57 -0
  97. package/packages/fusion/src/picker.ts +186 -51
  98. package/packages/fusion/src/preset.ts +56 -2
  99. package/packages/fusion/src/prompts.ts +1 -1
  100. package/packages/fusion/src/sidekick-runtime.ts +1 -420
  101. package/packages/fusion/src/tools.ts +107 -128
  102. package/packages/fusion/src/transcript.ts +27 -85
  103. package/packages/info-screen/package.json +2 -2
  104. package/packages/input-shortcuts/README.md +18 -30
  105. package/packages/input-shortcuts/package.json +2 -2
  106. package/packages/input-shortcuts/src/burst.ts +88 -0
  107. package/packages/input-shortcuts/src/chord-overlay.ts +20 -70
  108. package/packages/input-shortcuts/src/cleared-input.ts +97 -0
  109. package/packages/input-shortcuts/src/index.ts +99 -193
  110. package/packages/input-shortcuts/src/last-response.ts +40 -0
  111. package/packages/input-shortcuts/src/registers.ts +8 -23
  112. package/packages/input-shortcuts/src/types.ts +4 -16
  113. package/packages/input-shortcuts/src/undo-redo.ts +59 -47
  114. package/packages/kanboard/README.md +51 -47
  115. package/packages/kanboard/index.ts +293 -55
  116. package/packages/kanboard/package.json +8 -8
  117. package/packages/kanboard/skills/kanboard/SKILL.md +61 -27
  118. package/packages/kanboard/src/badges.ts +93 -0
  119. package/packages/kanboard/src/bin.ts +4 -2
  120. package/packages/kanboard/src/commands.ts +218 -84
  121. package/packages/kanboard/src/debug.ts +20 -0
  122. package/packages/kanboard/src/guard.ts +267 -109
  123. package/packages/kanboard/src/monitor.ts +330 -0
  124. package/packages/kanboard/src/notice-buffer.ts +74 -0
  125. package/packages/kanboard/src/progress.ts +27 -0
  126. package/packages/kanboard/src/reminders.ts +234 -0
  127. package/packages/kanboard/src/settings.ts +69 -15
  128. package/packages/kanboard/src/shapes.ts +5 -0
  129. package/packages/long-horizon/index.ts +190 -13
  130. package/packages/long-horizon/package.json +3 -3
  131. package/packages/long-horizon/src/commands.ts +103 -7
  132. package/packages/long-horizon/src/compaction-brief.ts +35 -0
  133. package/packages/long-horizon/src/engine/continuation.ts +84 -15
  134. package/packages/long-horizon/src/engine/goal-state.ts +64 -13
  135. package/packages/long-horizon/src/engine/nudge-stash.ts +71 -0
  136. package/packages/long-horizon/src/engine/ralph.ts +3 -2
  137. package/packages/long-horizon/src/engine/runaway.ts +68 -11
  138. package/packages/long-horizon/src/engine/verifier.ts +19 -0
  139. package/packages/long-horizon/src/gate.ts +37 -12
  140. package/packages/long-horizon/src/judge/resolve.ts +1 -0
  141. package/packages/long-horizon/src/owner.ts +35 -2
  142. package/packages/long-horizon/src/progress.ts +87 -0
  143. package/packages/long-horizon/src/prompts/goal.ts +1 -1
  144. package/packages/long-horizon/src/runtime.ts +172 -18
  145. package/packages/long-horizon/src/settings.ts +54 -39
  146. package/packages/long-horizon/src/tools/goal.ts +6 -0
  147. package/packages/long-horizon/src/tools/graph.ts +1 -1
  148. package/packages/long-horizon/src/tools/swarm.ts +3 -3
  149. package/packages/mcp/package.json +2 -2
  150. package/packages/memory/commands.ts +7 -2
  151. package/packages/memory/index.ts +210 -32
  152. package/packages/memory/package.json +3 -3
  153. package/packages/memory/save-session.ts +264 -0
  154. package/packages/memory/settings.ts +17 -0
  155. package/packages/memory/tools.ts +253 -203
  156. package/packages/notify/package.json +2 -2
  157. package/packages/skill-registry/README.md +53 -16
  158. package/packages/skill-registry/index.ts +230 -0
  159. package/packages/skill-registry/package.json +19 -8
  160. package/packages/skill-registry/src/editor.ts +206 -0
  161. package/packages/skill-registry/src/judge.ts +289 -0
  162. package/packages/skill-registry/src/registry.ts +87 -0
  163. package/packages/skill-registry/src/settings.ts +259 -0
  164. package/packages/skill-registry/src/vault.ts +80 -0
  165. package/packages/subagents/README.md +39 -119
  166. package/packages/subagents/package.json +6 -13
  167. package/packages/subagents/skills/subagents/SKILL.md +18 -42
  168. package/packages/subagents/src/agents.ts +213 -0
  169. package/packages/subagents/src/cards.ts +182 -0
  170. package/packages/subagents/src/index.ts +609 -1084
  171. package/packages/subagents/src/manager.ts +429 -0
  172. package/packages/subagents/src/profiles.ts +130 -0
  173. package/packages/subagents/src/transcript.ts +154 -0
  174. package/packages/subagents/src/ui.ts +389 -0
  175. package/packages/unipi/bundled.js +39756 -45569
  176. package/packages/unipi/index.ts +55 -10
  177. package/packages/updater/package.json +2 -2
  178. package/packages/utility/README.md +76 -69
  179. package/packages/utility/package.json +2 -2
  180. package/packages/utility/skills/image/SKILL.md +37 -0
  181. package/packages/utility/skills/utility/SKILL.md +17 -60
  182. package/packages/utility/src/answer/extract.ts +128 -0
  183. package/packages/utility/src/answer/index.ts +213 -0
  184. package/packages/utility/src/answer/reply.ts +133 -0
  185. package/packages/utility/src/answer/web.ts +167 -0
  186. package/packages/utility/src/attach/index.ts +205 -0
  187. package/packages/utility/src/commands.ts +38 -142
  188. package/packages/utility/src/diagnostics/engine.ts +50 -5
  189. package/packages/utility/src/image/index.ts +33 -0
  190. package/packages/utility/src/image/models.ts +221 -0
  191. package/packages/{image/src → utility/src/image}/recognize.ts +2 -2
  192. package/packages/utility/src/image/settings.ts +178 -0
  193. package/packages/{image/src/image-source.ts → utility/src/image/source.ts} +1 -1
  194. package/packages/utility/src/image/tools.ts +168 -0
  195. package/packages/utility/src/image/transports.ts +186 -0
  196. package/packages/utility/src/index.ts +68 -389
  197. package/packages/utility/src/lifecycle/cleanup.ts +116 -311
  198. package/packages/utility/src/rename/gate.ts +102 -0
  199. package/packages/utility/src/rename/index.ts +191 -0
  200. package/packages/utility/src/rename/session.ts +170 -0
  201. package/packages/utility/src/render/delegated.ts +285 -0
  202. package/packages/utility/src/render/harness.ts +683 -0
  203. package/packages/utility/src/render/parse.ts +156 -0
  204. package/packages/utility/src/render/pi-settings.ts +38 -0
  205. package/packages/utility/src/render/reply-bg.ts +307 -0
  206. package/packages/utility/src/render/simple.ts +724 -0
  207. package/packages/utility/src/render/spacing.ts +235 -0
  208. package/packages/utility/src/render/styled.ts +251 -0
  209. package/packages/utility/src/render/tools.ts +353 -0
  210. package/packages/utility/src/settings.ts +166 -233
  211. package/packages/utility/src/types.ts +1 -222
  212. package/packages/watchdog/index.ts +17 -6
  213. package/packages/watchdog/package.json +3 -3
  214. package/packages/watchdog/src/config.ts +3 -2
  215. package/packages/web-api/package.json +2 -2
  216. package/packages/workflow/README.md +3 -0
  217. package/packages/workflow/index.ts +0 -2
  218. package/packages/workflow/package.json +2 -2
  219. package/packages/workflow/src/permission/decide.ts +6 -4
  220. package/packages/workflow/src/permission/index.ts +17 -7
  221. package/packages/workflow/src/permission/settings.ts +39 -4
  222. package/packages/workflow/src/plan/index.ts +19 -4
  223. package/packages/background-tasks/extensions/delegate-child.ts +0 -1
  224. package/packages/background-tasks/src/context-parent-snapshot.ts +0 -142
  225. package/packages/background-tasks/src/context-token-budget.ts +0 -903
  226. package/packages/background-tasks/src/context-visible-conversation-v2.ts +0 -551
  227. package/packages/background-tasks/src/delegate/artifacts.ts +0 -487
  228. package/packages/background-tasks/src/delegate/budget.ts +0 -415
  229. package/packages/background-tasks/src/delegate/hook-contract-evidence.json +0 -18
  230. package/packages/background-tasks/src/delegate/hook-contract.ts +0 -154
  231. package/packages/background-tasks/src/delegate/launch.ts +0 -484
  232. package/packages/background-tasks/src/delegate/result-package.ts +0 -459
  233. package/packages/background-tasks/src/delegate/runner.ts +0 -430
  234. package/packages/background-tasks/src/delegate/seed.ts +0 -423
  235. package/packages/background-tasks/src/delegate/types.ts +0 -323
  236. package/packages/background-tasks/src/delegate-child-extension.ts +0 -978
  237. package/packages/background-tasks/src/delegate-extension.ts +0 -654
  238. package/packages/background-tasks/src/fixtures/delegate-context-incident.json +0 -17
  239. package/packages/compactor/skills/compactor-detail/SKILL.md +0 -116
  240. package/packages/compactor/skills/compactor-doctor/SKILL.md +0 -71
  241. package/packages/compactor/skills/compactor-stats/SKILL.md +0 -51
  242. package/packages/compactor/src/compaction/build-sections.ts +0 -76
  243. package/packages/compactor/src/compaction/extract/goals.ts +0 -36
  244. package/packages/compactor/src/compaction/extract/preferences.ts +0 -40
  245. package/packages/compactor/src/compaction/format.ts +0 -53
  246. package/packages/compactor/src/compaction/merge.ts +0 -143
  247. package/packages/compactor/src/config/presets.ts +0 -160
  248. package/packages/compactor/src/executor/executor.ts +0 -233
  249. package/packages/compactor/src/executor/runtime.ts +0 -115
  250. package/packages/compactor/src/security/evaluator.ts +0 -89
  251. package/packages/compactor/src/security/policy.ts +0 -96
  252. package/packages/compactor/src/security/scanner.ts +0 -65
  253. package/packages/compactor/src/session/auto-inject.ts +0 -60
  254. package/packages/compactor/src/session/db.ts +0 -325
  255. package/packages/compactor/src/session/extract.ts +0 -144
  256. package/packages/compactor/src/session/resume-inject.ts +0 -64
  257. package/packages/compactor/src/session/snapshot.ts +0 -327
  258. package/packages/compactor/src/tools/ctx-batch-execute.ts +0 -41
  259. package/packages/compactor/src/tools/ctx-execute-file.ts +0 -23
  260. package/packages/compactor/src/tools/ctx-execute.ts +0 -23
  261. package/packages/compactor/src/tools/ctx-stats.ts +0 -96
  262. package/packages/image/README.md +0 -99
  263. package/packages/image/package.json +0 -61
  264. package/packages/image/skills/image/SKILL.md +0 -76
  265. package/packages/image/src/generate.ts +0 -256
  266. package/packages/image/src/index.ts +0 -135
  267. package/packages/image/src/models.ts +0 -519
  268. package/packages/image/src/openai-images-api.ts +0 -282
  269. package/packages/image/src/register-providers.ts +0 -209
  270. package/packages/image/src/settings.ts +0 -173
  271. package/packages/image/src/tools.ts +0 -326
  272. package/packages/input-shortcuts/src/clipboard.ts +0 -119
  273. package/packages/kanboard/src/runner.ts +0 -817
  274. package/packages/skill-registry/skills/auto/SKILL.md +0 -465
  275. package/packages/skill-registry/skills/brainstorm/SKILL.md +0 -219
  276. package/packages/skill-registry/skills/chore-create/SKILL.md +0 -315
  277. package/packages/skill-registry/skills/chore-execute/SKILL.md +0 -314
  278. package/packages/skill-registry/skills/consolidate/SKILL.md +0 -142
  279. package/packages/skill-registry/skills/consultant/SKILL.md +0 -97
  280. package/packages/skill-registry/skills/debug/SKILL.md +0 -228
  281. package/packages/skill-registry/skills/document/SKILL.md +0 -120
  282. package/packages/skill-registry/skills/fix/SKILL.md +0 -212
  283. package/packages/skill-registry/skills/gather-context/SKILL.md +0 -145
  284. package/packages/skill-registry/skills/plan/SKILL.md +0 -231
  285. package/packages/skill-registry/skills/quick-fix/SKILL.md +0 -135
  286. package/packages/skill-registry/skills/quick-work/SKILL.md +0 -110
  287. package/packages/skill-registry/skills/research/SKILL.md +0 -251
  288. package/packages/skill-registry/skills/review-work/SKILL.md +0 -187
  289. package/packages/skill-registry/skills/scan-issues/SKILL.md +0 -195
  290. package/packages/skill-registry/skills/work/SKILL.md +0 -218
  291. package/packages/skill-registry/skills/worktree-create/SKILL.md +0 -69
  292. package/packages/skill-registry/skills/worktree-list/SKILL.md +0 -78
  293. package/packages/skill-registry/skills/worktree-merge/SKILL.md +0 -89
  294. package/packages/subagents/agents/delegate.md +0 -14
  295. package/packages/subagents/agents/oracle.md +0 -78
  296. package/packages/subagents/agents/researcher.md +0 -52
  297. package/packages/subagents/agents/reviewer.md +0 -80
  298. package/packages/subagents/agents/scout.md +0 -50
  299. package/packages/subagents/agents/worker.md +0 -59
  300. package/packages/subagents/prompts/council.md +0 -48
  301. package/packages/subagents/prompts/gather-context-and-clarify.md +0 -13
  302. package/packages/subagents/prompts/parallel-cleanup.md +0 -59
  303. package/packages/subagents/prompts/parallel-research.md +0 -50
  304. package/packages/subagents/prompts/parallel-review.md +0 -54
  305. package/packages/subagents/prompts/review-loop.md +0 -43
  306. package/packages/subagents/skills/council-mode/SKILL.md +0 -230
  307. package/packages/subagents/skills/subagents/references/constraints-and-recipes.md +0 -259
  308. package/packages/subagents/skills/subagents/references/execution-controls.md +0 -454
  309. package/packages/subagents/skills/subagents/references/management-authoring-rpc.md +0 -161
  310. package/packages/subagents/skills/subagents/references/multi-lane-orchestration.md +0 -39
  311. package/packages/subagents/skills/subagents/references/prompting-and-roles.md +0 -267
  312. package/packages/subagents/src/acceptance.ts +0 -331
  313. package/packages/subagents/src/agent-manager.ts +0 -405
  314. package/packages/subagents/src/agent-memory.ts +0 -300
  315. package/packages/subagents/src/agent-overrides.ts +0 -183
  316. package/packages/subagents/src/agent-runner.ts +0 -271
  317. package/packages/subagents/src/async-runner.ts +0 -422
  318. package/packages/subagents/src/authority-policy.ts +0 -47
  319. package/packages/subagents/src/budgets.ts +0 -285
  320. package/packages/subagents/src/child-safety.ts +0 -140
  321. package/packages/subagents/src/config.ts +0 -249
  322. package/packages/subagents/src/conversation-viewer.ts +0 -281
  323. package/packages/subagents/src/core-compat.ts +0 -94
  324. package/packages/subagents/src/custom-agents.ts +0 -343
  325. package/packages/subagents/src/file-system-retry.ts +0 -50
  326. package/packages/subagents/src/fleet-data.ts +0 -105
  327. package/packages/subagents/src/fleet-view.ts +0 -322
  328. package/packages/subagents/src/foreground-detach.ts +0 -59
  329. package/packages/subagents/src/fork-context.ts +0 -285
  330. package/packages/subagents/src/global.d.ts +0 -7
  331. package/packages/subagents/src/guide.ts +0 -129
  332. package/packages/subagents/src/mission-state.ts +0 -133
  333. package/packages/subagents/src/mission-store.ts +0 -436
  334. package/packages/subagents/src/output-limits.ts +0 -142
  335. package/packages/subagents/src/parity-types.ts +0 -315
  336. package/packages/subagents/src/pi-args.ts +0 -167
  337. package/packages/subagents/src/result-files.ts +0 -232
  338. package/packages/subagents/src/result-watcher.ts +0 -221
  339. package/packages/subagents/src/retained-children.ts +0 -176
  340. package/packages/subagents/src/run-fanout-budget.ts +0 -356
  341. package/packages/subagents/src/scheduled-runs.ts +0 -349
  342. package/packages/subagents/src/schemas.ts +0 -263
  343. package/packages/subagents/src/slash-commands.ts +0 -80
  344. package/packages/subagents/src/supervisor-channel.ts +0 -249
  345. package/packages/subagents/src/tool-handler.ts +0 -1144
  346. package/packages/subagents/src/types.ts +0 -238
  347. package/packages/subagents/src/widget.ts +0 -457
  348. package/packages/subagents/src/workflow-script.ts +0 -737
  349. package/packages/subagents/src/workflow-worker.ts +0 -384
  350. package/packages/subagents/src/worktree.ts +0 -614
  351. package/packages/utility/src/analytics/collector.ts +0 -293
  352. package/packages/utility/src/info-screen.ts +0 -80
  353. package/packages/utility/src/lifecycle/process.ts +0 -162
  354. package/packages/utility/src/prefix-cache.ts +0 -263
  355. package/packages/utility/src/skill-discovery.ts +0 -542
  356. package/packages/utility/src/tools/env.ts +0 -113
  357. package/packages/utility/src/tui/name-badge-state.ts +0 -315
  358. package/packages/utility/src/tui/name-badge.ts +0 -109
  359. /package/packages/{subagents/src → core/src/child-agent}/pi-spawn.ts +0 -0
@@ -1,1153 +1,678 @@
1
1
  /**
2
- * @pi-unipi/subagents — Extension entry
2
+ * @pi-unipi/subagents — Devin-model subagents for unipi.
3
3
  *
4
- * Tools: spawn_helper, get_helper_result
5
- * Features: renderCall/renderResult, message renderer, conversation viewer
6
- * ESC propagation: all children abort on parent ESC
4
+ * Tools: `run_subagent` (foreground wait / background notify / resume) and a
5
+ * reader registration for the shared core `read_subagent` tool.
6
+ * Profiles: built-ins `subagent_explore` / `subagent_general` plus custom
7
+ * markdown agents from ~/.unipi/config/agents and <workspace>/.unipi/config/agents.
8
+ * Config `subagents`: enabled, defaultModel, defaultThinking, maxConcurrent —
9
+ * re-read every turn, so settings and agent files apply without a restart.
10
+ *
11
+ * TUI (Devin parity): spawn card with a live tail, `Subagent "…" completed`
12
+ * lines, the `N subagents (k running) · ↓ select` strip, the dock (↵ view,
13
+ * f foreground, x cancel), Ctrl+B to background a foreground subagent.
7
14
  */
8
15
 
9
- import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
- import { Text } from "@earendil-works/pi-tui";
16
+ import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
11
17
  import { Type } from "typebox";
12
- import { existsSync, readdirSync, writeFileSync } from "node:fs";
13
- import { join } from "node:path";
14
- import { homedir } from "node:os";
15
- import { MODULES, UNIPI_EVENTS, emitEvent, type UnipiBadgeGenerateRequestEvent } from "@pi-unipi/core";
16
- import { boundHelperOutput, withHerdrBlocked } from "./core-compat.js";
17
- import { AgentManager } from "./agent-manager.js";
18
- import { initConfig, loadRawGlobalConfig, loadRawWorkspaceConfig } from "./config.js";
19
- import { type AgentActivity, type NotificationDetails, BUILTIN_TYPES } from "./types.js";
20
- import { loadBuiltinFileAgents } from "./custom-agents.js";
21
- import { ConversationViewer } from "./conversation-viewer.js";
22
- import { AgentWidget, SPINNER, TOOL_DISPLAY, formatMs, formatTurns, describeActivity } from "./widget.js";
23
- import { handleSpawnHelper, type HandlerDeps } from "./tool-handler.js";
24
- import { SpawnHelperParams, GetHelperResultParams } from "./schemas.js";
25
- import { runAsyncSubagent, createAsyncRunDir, writeStatus, readStatus } from "./async-runner.js";
26
- import { createResultWatcher, cleanupAsyncRetention } from "./result-watcher.js";
27
- import { writeAsyncResultFile, readAsyncResultFile } from "./result-files.js";
28
- import { writePendingSubscription } from "./result-watcher.js";
29
- import { RESULTS_DIR, ASYNC_DIR, ensureDirs } from "./parity-types.js";
30
- import { createForkContextResolver } from "./fork-context.js";
31
- import { createWorktrees, cleanupWorktrees, diffWorktrees, type WorktreeSetup } from "./worktree.js";
32
- import { FleetView } from "./fleet-view.js";
33
- import { registerSlashCommands } from "./slash-commands.js";
18
+ import { Key, matchesKey, truncateToWidth, type TUI } from "@earendil-works/pi-tui";
19
+ import { renderDelegatedStep, type DelegatedStep } from "@pi-unipi/utility/src/render/delegated.js";
34
20
  import {
35
- parseDetachShortcut,
36
- formatDetachHint,
37
- matchesDetachInput,
38
- } from "./foreground-detach.js";
39
- import { coerceThinkingLevel } from "./agent-runner.js";
40
- import { childExtensionsArg } from "./pi-args.js";
41
-
42
- /** Get info registry from global */
43
- function getInfoRegistry() {
44
- return globalThis.__unipi_info_registry;
21
+ UNIPI_EVENTS, emitEvent, getSettings, isChildProcess, registerSettings, registerWaitSource, getSharedFusionStatus, SPINNER_MS,
22
+ } from "@pi-unipi/core";
23
+ import {
24
+ ensureReadSubagentTool, registerSubagentReader, setReadSubagentDemand,
25
+ createCompletionDelivery, type HandoffReport, type SidekickStep,
26
+ } from "@pi-unipi/core/child-agent.js";
27
+ import {
28
+ SubagentManager, backgroundRunningReason, canSpawn, getSharedSubagents, subscribeSubagents, recordStatusFor,
29
+ MAX_CONCURRENT, type SubagentRecord, type SubagentRun, type SubagentStatus,
30
+ } from "./manager.js";
31
+ import { loadProfiles, type AgentProfile } from "./profiles.js";
32
+ import { buildTranscript, itemsFromEvents } from "./transcript.js";
33
+ import {
34
+ SubagentDock, SubagentStrip, elapsed, plural, profileLabel, statusColor, statusGlyph, statLine, tailLines, STATUS_LABEL, type ThemeLike,
35
+ } from "./ui.js";
36
+ import { AGENTS_COMMAND, registerAgentsCommand } from "./agents.js";
37
+ import {
38
+ cardOutcome, renderCompletion, renderRunCall, renderRunResult, type CardContext, type CardDetails, type RunArgs,
39
+ } from "./cards.js";
40
+
41
+ export interface SubagentsConfig {
42
+ enabled: boolean;
43
+ defaultModel?: string;
44
+ defaultThinking?: string;
45
+ maxConcurrent?: number;
45
46
  }
46
47
 
47
- // ---- Formatting helpers (shared between renderers and inline text) ----
48
+ const DEFAULT_CONFIG: SubagentsConfig = { enabled: true, defaultThinking: "inherit", maxConcurrent: MAX_CONCURRENT };
49
+ const AUTO_MODEL = "auto (Fusion sidekick → your model)";
48
50
 
51
+ registerSettings({
52
+ namespace: "subagents",
53
+ label: "Subagents",
54
+ defaults: DEFAULT_CONFIG as unknown as Record<string, unknown>,
55
+ schema: [
56
+ {
57
+ title: "Subagents",
58
+ fields: [
59
+ { key: "enabled", type: "boolean", label: "Enabled", description: "Offer run_subagent / read_subagent to the agent. Applies from the next turn." },
60
+ {
61
+ key: "defaultModel", type: "model", label: "Default subagent model", capability: "text",
62
+ description: "Model for subagent_explore and custom agents without a model: line. subagent_general always runs on your model.",
63
+ emptyLabel: AUTO_MODEL, emptyOption: AUTO_MODEL,
64
+ },
65
+ {
66
+ key: "defaultThinking", type: "enum", label: "Default thinking level",
67
+ description: "For subagent_explore and custom agents without thinking:. subagent_general uses yours.",
68
+ options: [{ value: "inherit", label: "inherit (your level)" }, "off", "minimal", "low", "medium", "high", "xhigh"],
69
+ },
70
+ { key: "maxConcurrent", type: "number", label: "Max running at once", min: 1, max: 16, description: "Further run_subagent calls are refused until one finishes." },
71
+ ],
72
+ },
73
+ {
74
+ title: "Agents",
75
+ fields: [
76
+ { key: "manageAgents", type: "action", label: "Manage agents…", description: "List, create, edit, copy or delete custom agents (global or this project).", command: AGENTS_COMMAND },
77
+ ],
78
+ },
79
+ ],
80
+ });
49
81
 
50
- /** Tool name → human-readable action. */
51
-
52
- function formatTokens(count: number): string {
53
- if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M token`;
54
- if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k token`;
55
- return `${count} token`;
56
- }
57
-
58
- /** Format tokens safely from session. */
59
- function safeFormatTokens(session: any): string {
60
- if (!session) return "";
61
- try {
62
- const stats = session.getSessionStats();
63
- const total = stats.tokens?.total ?? 0;
64
- return formatTokens(total);
65
- } catch {
66
- return "";
67
- }
68
- }
69
-
70
- /** Get raw token count from session. */
71
- function safeTokenCount(session: any): number {
72
- if (!session) return 0;
82
+ export function loadConfig(cwd: string): SubagentsConfig {
73
83
  try {
74
- return session.getSessionStats().tokens?.total ?? 0;
84
+ const m = getSettings("subagents", cwd) as unknown as SubagentsConfig;
85
+ const n = Number(m.maxConcurrent);
86
+ return {
87
+ enabled: m.enabled !== false,
88
+ defaultModel: typeof m.defaultModel === "string" && m.defaultModel.trim() ? m.defaultModel.trim() : undefined,
89
+ defaultThinking: typeof m.defaultThinking === "string" && m.defaultThinking !== "inherit" && m.defaultThinking ? m.defaultThinking : undefined,
90
+ maxConcurrent: Number.isFinite(n) && n >= 1 ? Math.floor(n) : MAX_CONCURRENT,
91
+ };
75
92
  } catch {
76
- return 0;
93
+ return { enabled: true, maxConcurrent: MAX_CONCURRENT };
77
94
  }
78
95
  }
79
96
 
80
- /** Build result text */
81
- function textResult(msg: string, details?: any) {
82
- return { content: [{ type: "text" as const, text: msg }], details };
83
- }
84
-
85
- /** Escape XML for structured notifications. */
86
- function escapeXml(s: string): string {
87
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
88
- }
97
+ const RunSubagentParams = Type.Object({
98
+ title: Type.String({ description: "Short 3-8 word title shown to the user" }),
99
+ task: Type.String({
100
+ description: "Complete, self-contained instructions. The subagent does not see this conversation: include the goal, relevant paths, constraints, and what to report back.",
101
+ }),
102
+ profile: Type.String({ description: "" }), // rebuilt at load: profile list
103
+ is_background: Type.Optional(Type.Boolean({ description: "Run without waiting; report arrives via notification (default false)" })),
104
+ resume: Type.Optional(Type.String({ description: "agent_id of an earlier subagent to continue (keeps its context); always runs in the foreground" })),
105
+ });
89
106
 
90
- /** Human-readable status label. */
91
- function getStatusLabel(status: string, error?: string): string {
92
- switch (status) {
93
- case "error": return `Error: ${error ?? "unknown"}`;
94
- case "aborted": return "Aborted (max turns exceeded)";
95
- case "stopped": return "Stopped";
96
- default: return "Done";
97
- }
98
- }
107
+ const RUN_DESCRIPTION =
108
+ "Launch an independent subagent for a self-contained task. It has its own context and does not see this conversation, so put everything it needs in `task`. Foreground (default) waits and returns the subagent's report. With is_background:true it returns immediately and you receive a <subagent_completion_notification> when it finishes; use read_subagent to wait for it — never poll in a loop. Background subagents cannot ask for approval: tool calls that would need approval are denied. Use resume:<agent_id> to continue an earlier subagent with a follow-up task.";
99
109
 
100
- export default function (pi: ExtensionAPI) {
101
- // Initialize config
102
- const config = initConfig(process.cwd());
103
- if (!config.enabled) return;
110
+ const STRIP_KEY = "subagents-strip";
111
+ const FG_KEY = "subagents-foreground";
112
+ const WORKING = "Subagent running · Ctrl+B to run in background";
104
113
 
105
- // Compute paths at factory time
106
- const homeDir = homedir();
107
- const cwd = process.cwd();
108
- const globalAgentsDir = join(homeDir, ".unipi", "config", "agents");
109
- const workspaceAgentsDir = join(cwd, ".unipi", "config", "agents");
110
114
 
111
- // Activity tracking for widget
112
- const agentActivity = new Map<string, AgentActivity>();
115
+ function leadSessionId(ctx: ExtensionContext): string {
116
+ const m = ctx.sessionManager as { getSessionId?: () => string | undefined } | undefined;
117
+ return m?.getSessionId?.() ?? "default";
118
+ }
113
119
 
114
- /**
115
- * Set once `session_shutdown` fires — after which `pi` must not be touched.
116
- *
117
- * Pi disposes the session as soon as the shutdown handlers resolve, and
118
- * `AgentSession.dispose()` invalidates the extension runtime. Every
119
- * `assertActive`-gated method (`sendMessage`, `setSessionName`,
120
- * `appendEntry`, `setModel`, …) then throws "This extension ctx is stale
121
- * after session replacement or reload".
122
- *
123
- * Background agents outlive that moment: `abortAll()` only signals their
124
- * AbortController, so the in-flight promise settles a microtask *later* and
125
- * fires this completion callback against a dead runtime — an unhandled
126
- * throw that crashed the process on exit.
127
- *
128
- * Scoped to the extension factory rather than module scope on purpose:
129
- * `session_shutdown` also fires for `/new`, `/fork` and `/resume` (reasons
130
- * "new" / "fork" / "resume"), and pi re-invokes the extension factory for
131
- * the replacement session. A fresh closure therefore starts with
132
- * `sessionEnded = false`, so the guard can never latch permanently.
133
- * Verified: `/new` emits `shutdown reason=new` then re-runs the factory.
134
- *
135
- * `pi.events` is NOT gated, so cross-module events still fire.
136
- */
137
- let sessionEnded = false;
120
+ /** Model order: profile.model → (general: parent) → config.defaultModel →
121
+ * Fusion sidekickKey → parent. Exported for tests. */
122
+ export function resolveSubagentModel(
123
+ profile: AgentProfile,
124
+ ctx: Pick<ExtensionContext, "model">,
125
+ config: SubagentsConfig,
126
+ ): string {
127
+ if (profile.model !== undefined) return profile.model;
128
+ const cur = ctx.model as { provider?: string; id?: string } | undefined;
129
+ const parent = cur ? `${String(cur.provider)}/${String(cur.id)}` : "";
130
+ if (profile.id === "subagent_general") return parent;
131
+ if (config.defaultModel !== undefined) return config.defaultModel;
132
+ const sidekickKey = getSharedFusionStatus()?.sidekickKey;
133
+ if (sidekickKey !== undefined) return sidekickKey;
134
+ return parent;
135
+ }
138
136
 
139
- // Create manager with completion callback
140
- const manager = new AgentManager(
141
- (record) => {
142
- agentActivity.delete(record.id);
137
+ /** subagent_general rides the parent's level; defaultThinking covers
138
+ * explore/custom agents without their own `thinking`. Exported for tests. */
139
+ export function resolveSubagentThinking(
140
+ profile: AgentProfile,
141
+ ctx: Pick<ExtensionContext, "thinkingLevel">,
142
+ config: SubagentsConfig,
143
+ ): string {
144
+ const own = profile.id === "subagent_general" ? undefined : config.defaultThinking;
145
+ return profile.thinking ?? own ?? ctx.thinkingLevel ?? "medium";
146
+ }
143
147
 
144
- // After shutdown the UI is gone and the runtime is stale — nothing here
145
- // is deliverable, and touching `pi` would throw.
146
- if (sessionEnded) return;
148
+ export { cardOutcome };
147
149
 
148
- widget.markFinished(record.id);
149
- widget.update();
150
+ /**
151
+ * UI-only completed-step entry: payload + guarded append, identical for
152
+ * foreground and background runs. The final report is intentionally never
153
+ * streamed here — the lead reports it itself. Exported for tests.
154
+ */
155
+ export function appendSubagentStep(
156
+ appendEntry: (type: string, data: Record<string, unknown>) => void,
157
+ record: Pick<SubagentRecord, "id" | "title" | "profile" | "startedAt">,
158
+ step: SidekickStep,
159
+ ): void {
160
+ try {
161
+ appendEntry("subagent-step", {
162
+ ...step,
163
+ agentId: record.id,
164
+ id: record.id,
165
+ title: record.title,
166
+ profile: record.profile,
167
+ group: `subagent:${record.id}:${record.startedAt}`,
168
+ label: `${profileLabel(record.profile)} subagent · ${record.title}`,
169
+ });
170
+ } catch {
171
+ /* entry rendering never affects the run */
172
+ }
173
+ }
150
174
 
151
- // Build notification details
152
- const details = buildNotificationDetails(record, agentActivity.get(record.id));
175
+ export default function subagents(pi: ExtensionAPI, deps?: { manager?: SubagentManager }): void {
176
+ const manager = deps?.manager ?? new SubagentManager();
177
+ const delivery = createCompletionDelivery<HandoffReport>((report) => deliverCompletion(report));
153
178
 
154
- // Badge generation: extract name from agent result and set directly.
155
- // Mark resultConsumed BEFORE the notification check so the main agent
156
- // never sees this subagent.
157
- if (record.description === "Generate session name" && record.result && record.status === "completed") {
158
- const name = record.result.split("\n")[0]?.trim().slice(0, 50) ?? "";
159
- if (name && !name.startsWith("Error") && !name.includes("error")) {
160
- try {
161
- pi.setSessionName(name);
162
- } catch { /* best effort */ }
163
- }
164
- record.resultConsumed = true;
165
- }
179
+ // Turn-arbiter wait source (lead only): a background subagent is still
180
+ // working while the lead settles — its completion will arrive as an event
181
+ // turn, so a nudge now would race it.
182
+ if (!isChildProcess()) {
183
+ try {
184
+ registerWaitSource("subagents", () => backgroundRunningReason(getSharedSubagents()));
185
+ } catch {
186
+ // Registration must never block module load.
187
+ }
188
+ }
166
189
 
190
+ let profiles: AgentProfile[] = [];
191
+ let config: SubagentsConfig = { ...DEFAULT_CONFIG };
192
+ let enabled = false;
193
+ let uiCtx: ExtensionContext | undefined;
194
+ const warned = new Set<string>();
195
+
196
+ // UI state
197
+ let stripTui: TUI | undefined;
198
+ let stripInstalled = false;
199
+ let fgInstalled = false;
200
+ let dockOpen = false;
201
+ let workingShown = false;
202
+ let unsubInput: (() => void) | undefined;
203
+ /** Foreground waiters (run_subagent / read_subagent block) → move to bg. */
204
+ const fgWaits = new Map<string, () => void>();
205
+ /** Background agents the user foregrounded from the dock (f). */
206
+ const watched = new Set<string>();
207
+
208
+ function deliverCompletion(report: HandoffReport): void {
209
+ const rec = manager.record(report.id);
210
+ if (rec?.cancelledBy === "session") return; // the session is gone
211
+ const status = rec?.status ?? recordStatusFor(report);
212
+ const head = status === "cancelled"
213
+ ? rec?.cancelledBy === "user" ? "Cancelled by the user." : "Cancelled."
214
+ : status === "failed" ? `Failed${report.error ? `: ${report.error}` : "."}` : "";
215
+ const body = [head, report.text].filter((s) => s && s.trim()).join("\n\n");
216
+ try {
217
+ pi.sendMessage(
218
+ {
219
+ customType: "subagent-completion",
220
+ content: `<subagent_completion_notification agent_id="${report.id}" status="${status}">\n${body}\n</subagent_completion_notification>`,
221
+ display: true,
222
+ details: {
223
+ owner: "subagents", id: report.id, title: rec?.title ?? report.id, profile: rec?.profile, model: rec?.model, status,
224
+ cancelledBy: rec?.cancelledBy, toolCalls: report.toolCalls, durationMs: report.durationMs, usage: report.usage, report: report.text.slice(0, 20_000),
225
+ },
226
+ } as never,
227
+ { deliverAs: "followUp", triggerTurn: true } as never,
228
+ );
229
+ } catch {
230
+ /* session replaced while it finished */
231
+ }
232
+ }
167
233
 
168
- // Send styled notification via message renderer
169
- const status = getStatusLabel(record.status, record.error);
170
- const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
171
- const resultPreview = record.result
172
- ? record.result.length > 500
173
- ? record.result.slice(0, 500) + "…"
174
- : record.result
175
- : "No output.";
234
+ /** Re-read config + agent files; returns the load warnings. Safe at load. */
235
+ function reload(cwd: string): string[] {
236
+ config = loadConfig(cwd);
237
+ enabled = config.enabled;
238
+ const loaded = loadProfiles(cwd);
239
+ profiles = loaded.profiles;
240
+ return loaded.warnings;
241
+ }
176
242
 
177
- const notificationXml = [
178
- `<task-notification>`,
179
- `<task-id>${record.id}</task-id>`,
180
- `<status>${escapeXml(status)}</status>`,
181
- `<summary>Agent "${escapeXml(record.description)}" ${record.status}</summary>`,
182
- `<result>${escapeXml(resultPreview)}</result>`,
183
- `<usage><total_tokens>${details.totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses><duration_ms>${durationMs}</duration_ms></usage>`,
184
- `</task-notification>`,
185
- ].join("\n");
243
+ /** reload + tool sync + warnings (runtime only — not during loading). */
244
+ function refresh(cwd: string, ctx?: ExtensionContext): void {
245
+ // Warnings surface once per distinct message per process.
246
+ for (const w of reload(cwd)) {
247
+ if (warned.has(w)) continue;
248
+ warned.add(w);
249
+ (ctx ?? uiCtx)?.ui?.notify?.(`subagents: ${w}`, "warning");
250
+ }
251
+ setReadSubagentDemand(pi, "subagents", enabled);
252
+ syncRunSubagent();
253
+ }
186
254
 
187
- if (!record.resultConsumed) {
188
- // Defence in depth: `sessionEnded` covers the ordinary shutdown path,
189
- // but a session can also be replaced mid-flight. Delivering a
190
- // notification is best-effort — it must never take the process down.
191
- try {
192
- pi.sendMessage<NotificationDetails>(
193
- {
194
- customType: "subagent-notification",
195
- content: notificationXml,
196
- display: true,
197
- details,
198
- },
199
- { deliverAs: "followUp", triggerTurn: true },
200
- );
201
- } catch {
202
- // Runtime went stale between the guard and here — nothing to notify.
203
- }
204
- }
255
+ function syncRunSubagent(): void {
256
+ const want = enabled && canSpawn();
257
+ const current = pi.getActiveTools();
258
+ const has = current.includes("run_subagent");
259
+ if (has !== want) pi.setActiveTools(want ? [...current, "run_subagent"] : current.filter((t) => t !== "run_subagent"));
260
+ }
205
261
 
206
- pi.events.emit("subagents:completed", {
207
- id: record.id,
208
- type: record.type,
209
- description: record.description,
210
- status: record.status,
211
- result: record.result,
212
- error: record.error,
213
- });
214
- },
215
- config.maxConcurrent,
216
- (record) => {
217
- pi.events.emit("subagents:started", {
218
- id: record.id,
219
- type: record.type,
220
- description: record.description,
221
- });
222
- },
223
- config.types,
224
- process.cwd(),
225
- {
226
- user: loadRawGlobalConfig()?.subagents,
227
- project: loadRawWorkspaceConfig(process.cwd())?.subagents,
228
- },
229
- );
262
+ // Profiles load before registration so the tool schema's profile list is
263
+ // complete; later additions reach the model through the prompt section.
264
+ reload(process.cwd());
230
265
 
231
- // ---- Async process runner (Phase 3) ----
232
- ensureDirs();
233
- const activeAsyncRuns = new Map<string, AbortController>();
234
- const asyncSessionId = `unipi-${process.pid}`;
266
+ const transcriptOf = (rec: SubagentRecord) =>
267
+ buildTranscript({ sessionFile: rec.sessionFile, task: rec.task, running: rec.status === "running", events: manager.events(rec.id) });
268
+ const liveItems = (id: string) => itemsFromEvents(manager.events(id) ?? []);
235
269
 
236
- const runAsyncDep: NonNullable<HandlerDeps["runAsync"]> = async (launch) => {
237
- const agent = manager.getAgentConfig(manager.resolveAlias(launch.agentName));
238
- if (!agent) throw new Error(`Unknown agent "${launch.agentName}".`);
239
- if (launch.resumeSessionFile && !existsSync(launch.resumeSessionFile)) {
240
- throw new Error(`Resume session file is missing: ${launch.resumeSessionFile}`);
241
- }
270
+ // ── UI sync ───────────────────────────────────────────────────────────────
242
271
 
243
- // Worktree isolation: managed worktree per child; cleaned up after the run
244
- // (diffs preserved to the run dir as handoff artifacts).
245
- let worktreeSetup: WorktreeSetup | undefined;
246
- let childCwd = process.cwd();
247
- if (launch.worktree === true) {
248
- try {
249
- worktreeSetup = createWorktrees(process.cwd(), `async-${Date.now().toString(36)}`, 1);
250
- childCwd = worktreeSetup.worktrees[0]!.agentCwd;
251
- } catch (worktreeError) {
252
- throw new Error(
253
- `Worktree isolation failed: ${worktreeError instanceof Error ? worktreeError.message : String(worktreeError)}`,
254
- );
272
+ function syncUi(): void {
273
+ const ctx = uiCtx;
274
+ if (!ctx?.hasUI) return;
275
+ try {
276
+ for (const id of watched) {
277
+ if (manager.run(id) === undefined) watched.delete(id);
255
278
  }
256
- }
257
- const runDir = createAsyncRunDir(launch.agentName, asyncSessionId);
258
- const runId = runDir.split("/").pop()!;
259
- const controller = new AbortController();
260
- activeAsyncRuns.set(runId, controller);
261
-
262
- // Fork context: branch a child session from the parent conversation
263
- // (sanitized thinking blocks, thinking forced off when sanitized).
264
- let forkSessionFile: string | undefined;
265
- let forceThinkingOff: boolean | undefined;
266
- if (launch.context === "fork") {
267
- try {
268
- const sessionManager = (sessionCtx as unknown as {
269
- sessionManager?: {
270
- getSessionFile(): string | undefined;
271
- getLeafId(): string | null;
272
- openSession?: Parameters<typeof createForkContextResolver>[0]["openSession"];
273
- };
274
- })?.sessionManager;
275
- if (!sessionManager) {
276
- throw new Error("Forked context requires a persisted parent session (session manager unavailable).");
277
- }
278
- const resolver = createForkContextResolver(sessionManager, "fork");
279
- forkSessionFile = resolver.sessionFileForIndex(0);
280
- forceThinkingOff = resolver.thinkingOverrideForIndex(0) === "off";
281
- if (!forkSessionFile) {
282
- throw new Error("Forked context failed to produce a branched session file.");
283
- }
284
- writeStatus(runDir, { context: "fork", forkSessionFile });
285
- } catch (forkError) {
286
- // Reference rule: explicit fork never silently downgrades.
287
- activeAsyncRuns.delete(runId);
288
- throw forkError;
279
+ const wantStrip = getSharedSubagents().length > 0;
280
+ if (wantStrip && !stripInstalled) {
281
+ stripInstalled = true;
282
+ ctx.ui.setWidget(STRIP_KEY, (tui, theme) => {
283
+ stripTui = tui;
284
+ return new SubagentStrip(tui, theme, getSharedSubagents, (rec) => ({ toolCalls: manager.toolCalls(rec.id), usage: manager.usage(rec.id) }));
285
+ }, { placement: "belowEditor" });
286
+ } else if (!wantStrip && stripInstalled) {
287
+ stripInstalled = false;
288
+ stripTui = undefined;
289
+ ctx.ui.setWidget(STRIP_KEY, undefined);
290
+ } else {
291
+ stripTui?.requestRender();
289
292
  }
290
- }
291
-
292
- // Fire-and-track: the promise writes the durable result file + notifies on
293
- // completion; the tool call returns immediately with the run id.
294
- void (async () => {
295
- try {
296
- const result = await runAsyncSubagent(
297
- {
298
- agent,
299
- task: launch.task,
300
- cwd: childCwd,
301
- model: launch.model,
302
- thinking: launch.thinking,
303
- tools: agent.builtinToolNames,
304
- extensions: childExtensionsArg(agent.extensions),
305
- timeoutMs: launch.timeoutMs,
306
- parentSessionId: asyncSessionId,
307
- config,
308
- ...(launch.resumeSessionFile ? { sessionFile: launch.resumeSessionFile } : {}),
309
- ...(forkSessionFile ? { forkSessionFile } : {}),
310
- ...(forceThinkingOff ? { forceThinkingOff } : {}),
311
- },
312
- runDir,
313
- controller.signal,
314
- );
315
- writeAsyncResultFile(RESULTS_DIR, {
316
- runId,
317
- sessionId: asyncSessionId,
318
- ...(result.output !== undefined ? { output: result.output } : {}),
319
- ...(result.error ? { error: result.error } : {}),
320
- success: result.status === "completed",
321
- state: result.status,
322
- timedOut: result.status === "timedOut",
323
- durationMs: result.durationMs,
324
- }, { asyncDir: runDir });
325
- } catch (error) {
326
- writeStatus(runDir, {
327
- status: "failed",
328
- error: error instanceof Error ? error.message : String(error),
329
- });
330
- writeAsyncResultFile(RESULTS_DIR, {
331
- runId,
332
- sessionId: asyncSessionId,
333
- error: error instanceof Error ? error.message : String(error),
334
- success: false,
335
- state: "failed",
336
- }, { asyncDir: runDir });
337
- } finally {
338
- activeAsyncRuns.delete(runId);
339
- if (worktreeSetup) {
340
- try {
341
- const diffs = diffWorktrees(worktreeSetup, [launch.agentName], runDir);
342
- writeFileSync(
343
- join(runDir, "handoff.json"),
344
- JSON.stringify({ patches: diffs.map((d) => d.patchPath) }),
345
- { mode: 0o600 },
346
- );
347
- cleanupWorktrees(worktreeSetup, {
348
- kind: "preserve",
349
- capturedDiffs: diffs,
350
- handoffManifestPath: join(runDir, "handoff.json"),
351
- });
352
- } catch {
353
- // Worktree cleanup is best-effort; preserved trees surface in the report.
354
- }
355
- }
293
+ const wantFg = watched.size > 0;
294
+ if (wantFg && !fgInstalled) {
295
+ fgInstalled = true;
296
+ ctx.ui.setWidget(FG_KEY, (tui, theme) => {
297
+ const timer = setInterval(() => tui.requestRender(), SPINNER_MS);
298
+ timer.unref?.();
299
+ return { invalidate() {}, render: (w: number) => renderWatched(w, theme), dispose: () => clearInterval(timer) };
300
+ }, { placement: "aboveEditor" });
301
+ } else if (!wantFg && fgInstalled) {
302
+ fgInstalled = false;
303
+ ctx.ui.setWidget(FG_KEY, undefined);
356
304
  }
357
- })();
358
-
359
- return { runId, status: "running" };
360
- };
361
-
362
- // Result watcher: deliver async completions as follow-up notifications.
363
- const watcher = createResultWatcher({
364
- resultsDir: RESULTS_DIR,
365
- sessionId: asyncSessionId,
366
- resultScanLogging: config.resultScanLogging ?? "activity",
367
- notifier: (notification) => {
368
- if (sessionEnded) return;
369
- try {
370
- pi.sendMessage(
371
- {
372
- customType: "unipi-response",
373
- content:
374
- `<task-notification>\n` +
375
- `<task-id>${notification.runId}</task-id>\n` +
376
- `<status>${notification.success ? "completed" : notification.state ?? "failed"}</status>\n` +
377
- `<summary>Background agent "${notification.agent ?? "agent"}" ${notification.state ?? (notification.success ? "completed" : "failed")}</summary>\n` +
378
- (notification.error
379
- ? `<error>${notification.error}</error>\n`
380
- : `<result>${(notification.output ?? "").slice(0, 2000)}</result>\n`) +
381
- `</task-notification>`,
382
- display: false,
383
- },
384
- { deliverAs: "followUp", triggerTurn: true },
385
- );
386
- } catch {
387
- // Runtime went stale — nothing to notify.
305
+ const wantWorking = fgWaits.size > 0 || watched.size > 0;
306
+ if (wantWorking !== workingShown) {
307
+ workingShown = wantWorking;
308
+ ctx.ui.setWorkingMessage(wantWorking ? WORKING : undefined);
388
309
  }
389
- },
390
- });
310
+ } catch {
311
+ /* UI is best-effort; the runs are unaffected */
312
+ }
313
+ }
314
+ const unsubRegistry = subscribeSubagents(syncUi);
315
+
316
+ function renderWatched(width: number, theme: ThemeLike): string[] {
317
+ const out: string[] = [];
318
+ for (const id of watched) {
319
+ const rec = manager.record(id);
320
+ if (rec === undefined) continue;
321
+ // Same Devin card as a foreground run_subagent.
322
+ const head = `${statusGlyph(rec.status, theme)} ${theme.bold(`${profileLabel(rec.profile)} subagent`)} ${rec.title}`;
323
+ const foot = theme.fg("dim", `└ ${statLine(rec, manager.toolCalls(id), manager.usage(id))} · ctrl+b background`);
324
+ out.push(truncateToWidth(head, width), ...tailLines(liveItems(id), width, theme, 4), truncateToWidth(` ${foot}`, width));
325
+ }
326
+ return out;
327
+ }
391
328
 
392
- // Periodic retention cleanup (hourly, unref'd).
393
- const retentionTimer = setInterval(() => {
394
- cleanupAsyncRetention(ASYNC_DIR, RESULTS_DIR);
395
- }, 60 * 60 * 1000);
396
- retentionTimer.unref?.();
329
+ function foregroundAgent(id: string): string | undefined {
330
+ const run = manager.run(id);
331
+ if (run === undefined) return "Already finished — ask the agent to resume it.";
332
+ if (fgWaits.has(id) || watched.has(id)) return undefined;
333
+ if (uiCtx) run.runtime.attachUi(uiCtx.ui as never);
334
+ manager.setBackground(id, false);
335
+ watched.add(id);
336
+ syncUi();
337
+ return undefined;
338
+ }
397
339
 
398
- // Scheduled runs: poll for due schedules every minute when enabled.
399
- let schedulePoller: { stopPolling(): void } | undefined;
400
- if (config.scheduledRuns?.enabled !== false && deps_runAsyncAvailable()) {
401
- import("./scheduled-runs.js").then(({ ScheduledRunManager }) => {
402
- const manager = new ScheduledRunManager(process.cwd(), {
403
- storeRoot: config.scheduledRuns?.storeRoot,
404
- maxPending: config.scheduledRuns?.maxPending,
405
- launch: async (record) => {
406
- const result = await runAsyncDep({
407
- agentName: record.agent,
408
- task: record.task,
409
- description: `schedule: ${record.name}`,
410
- context: "fresh",
411
- timeoutMs: record.timeoutMs,
412
- });
413
- return result.runId;
414
- },
415
- });
416
- manager.startPolling();
417
- schedulePoller = manager;
418
- }).catch(() => {});
340
+ /** Ctrl+B: every foreground subagent goes to the background. */
341
+ function backgroundAll(): boolean {
342
+ let any = false;
343
+ for (const move of [...fgWaits.values()]) {
344
+ move();
345
+ any = true;
346
+ }
347
+ for (const id of watched) {
348
+ manager.run(id)?.runtime.detachUi();
349
+ manager.setBackground(id, true);
350
+ any = true;
351
+ }
352
+ watched.clear();
353
+ syncUi();
354
+ return any;
419
355
  }
420
- function deps_runAsyncAvailable(): boolean {
421
- return true; // runAsyncDep is defined below in the closure
356
+
357
+ function cancelAgent(id: string): string | undefined {
358
+ return manager.cancel(id, "user") ? undefined : "Not running.";
422
359
  }
423
360
 
424
- // ---- Parity handler wiring (spawn_helper surface) ----
425
- // Session-wide cumulative spawn accounting (maxSubagentSpawnsPerSession)
426
- let sessionSpawnsUsed = 0;
427
- const sessionSpawnCap = config.maxSubagentSpawnsPerSession && config.maxSubagentSpawnsPerSession > 0
428
- ? config.maxSubagentSpawnsPerSession
429
- : undefined;
361
+ async function openDock(ctx: ExtensionContext, initialId?: string): Promise<void> {
362
+ if (dockOpen || !ctx.hasUI) return;
363
+ dockOpen = true;
364
+ try {
365
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => new SubagentDock(tui, theme, {
366
+ records: getSharedSubagents,
367
+ transcript: transcriptOf,
368
+ toolCalls: (rec) => manager.toolCalls(rec.id),
369
+ usage: (rec) => manager.usage(rec.id),
370
+ subscribe: subscribeSubagents,
371
+ foreground: foregroundAgent,
372
+ cancel: cancelAgent,
373
+ }, () => done(), initialId));
374
+ } finally {
375
+ dockOpen = false;
376
+ }
377
+ }
430
378
 
431
- const handlerDeps: HandlerDeps = {
432
- pi,
433
- manager,
434
- config,
435
- spawnAccounting: {
436
- used: () => sessionSpawnsUsed,
437
- cap: () => sessionSpawnCap,
438
- consume: (count) => { sessionSpawnsUsed += count; },
439
- },
440
- runAsync: runAsyncDep,
441
- spawnBackground: (spawnCtx, agentName, childPrompt, options) => {
442
- const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(options.maxTurns);
443
- const origOnSession = bgCallbacks.onSessionCreated;
444
- bgCallbacks.onSessionCreated = (session: any) => {
445
- origOnSession(session);
446
- bgState.tokens = safeFormatTokens(session);
447
- widget.update();
448
- };
449
- const id = manager.spawn(pi, spawnCtx, agentName, childPrompt, {
450
- description: options.description ?? `${agentName} task`,
451
- maxTurns: options.maxTurns,
452
- modelInput: options.modelInput,
453
- modelRegistry: spawnCtx.modelRegistry,
454
- thinkingLevel: coerceThinkingLevel(options.thinkingLevel as never),
455
- isBackground: true,
456
- ...bgCallbacks,
457
- });
458
- agentActivity.set(id, bgState);
459
- widget.ensureTimer();
460
- widget.update();
461
- return id;
462
- },
463
- spawnForeground: async (spawnCtx, agentName, childPrompt, options) => {
464
- // Stream progress via the widget — reuse the activity tracker.
465
- let spinnerFrame = 0;
466
- const startedAt = Date.now();
467
- let fgId: string | undefined;
468
- const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(options.maxTurns);
379
+ /** The editor is focused, empty and not autocompleting (↓ may open the dock). */
380
+ function editorIdle(): boolean {
381
+ const tui = stripTui as { getFocusedComponent?: () => unknown } | undefined;
382
+ const f = tui?.getFocusedComponent?.() as { getText?: () => string; isShowingAutocomplete?: () => boolean } | null | undefined;
383
+ if (!f || typeof f.getText !== "function" || typeof f.isShowingAutocomplete !== "function") return false;
384
+ return f.getText() === "" && !f.isShowingAutocomplete();
385
+ }
469
386
 
470
- const streamUpdate = () => {
471
- onUpdateForForeground({
472
- status: "running",
473
- toolUses: fgState.toolUses,
474
- tokens: fgState.tokens,
475
- turnCount: fgState.turnCount,
476
- maxTurns: fgState.maxTurns,
477
- durationMs: Date.now() - startedAt,
478
- activity: describeActivity(fgState.activeTools, fgState.responseText),
479
- spinnerFrame: spinnerFrame % SPINNER.length,
480
- });
481
- };
482
- const origOnSession = fgCallbacks.onSessionCreated;
483
- fgCallbacks.onSessionCreated = (session: any) => {
484
- origOnSession(session);
485
- fgState.tokens = safeFormatTokens(session);
486
- for (const a of manager.listAgents()) {
487
- if (a.session === session) {
488
- fgId = a.id;
489
- agentActivity.set(a.id, fgState);
490
- widget.ensureTimer();
491
- break;
492
- }
493
- }
494
- };
495
- const spinnerInterval = setInterval(() => { spinnerFrame++; streamUpdate(); }, 80);
496
- // Detach: stop waiting without killing the child. The completion
497
- // notification arrives later via the normal onComplete path.
498
- activeForegroundDetach = () => {
499
- if (!fgId) return false;
500
- const record = manager.getRecord(fgId);
501
- if (!record || record.status !== "running") return false;
502
- record.resultConsumed = true; // suppress the duplicate notification
503
- return true;
504
- };
505
- try {
506
- const record = await manager.spawnAndWait(pi, spawnCtx, agentName, childPrompt, {
507
- description: options.description ?? `${agentName} task`,
508
- maxTurns: options.maxTurns,
509
- modelInput: options.modelInput,
510
- modelRegistry: spawnCtx.modelRegistry,
511
- thinkingLevel: coerceThinkingLevel(options.thinkingLevel as never),
512
- ...fgCallbacks,
513
- });
514
- const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
515
- const tokenText = safeFormatTokens(fgState.session);
516
- if (record.status === "error") {
517
- return { ok: false, output: record.error ?? "failed", error: record.error, toolUses: record.toolUses, durationMs };
518
- }
519
- const output = boundHelperOutput(record.result?.trim() || "No output.");
520
- record.resultArtifactPath = output.artifactPath;
521
- return { ok: true, output: output.text, toolUses: record.toolUses, durationMs };
522
- } finally {
523
- clearInterval(spinnerInterval);
524
- activeForegroundDetach = undefined;
525
- if (fgId) {
526
- agentActivity.delete(fgId);
527
- widget.markFinished(fgId);
528
- widget.update();
529
- }
530
- }
531
- },
532
- };
533
- // onUpdate stream for the current foreground execution (set per execute() call)
534
- let onUpdateForForeground: (details: Record<string, unknown>) => void = () => {};
387
+ function onTerminalInput(data: string): { consume?: boolean } | undefined {
388
+ if (matchesKey(data, "ctrl+b")) return backgroundAll() ? { consume: true } : undefined;
389
+ if (matchesKey(data, Key.down) && !dockOpen && stripInstalled && uiCtx && editorIdle()) {
390
+ void openDock(uiCtx);
391
+ return { consume: true };
392
+ }
393
+ return undefined;
394
+ }
535
395
 
536
- // ---- Foreground detach (foregroundDetachShortcut) ----
537
- const detachParts = parseDetachShortcut(config.foregroundDetachShortcut);
538
- let activeForegroundDetach: (() => boolean) | undefined;
396
+ // ── run_subagent ──────────────────────────────────────────────────────────
397
+
398
+ if (canSpawn()) {
399
+ const profileParam = profiles.map((p) => `${p.id} — ${p.description}`).join("; ");
400
+ pi.registerTool({
401
+ name: "run_subagent",
402
+ label: "Run Subagent",
403
+ description: RUN_DESCRIPTION,
404
+ parameters: Type.Object({
405
+ ...RunSubagentParams.properties,
406
+ profile: Type.String({ description: `Profile name. Available: ${profileParam}. Custom agents added later are listed in the Subagents section of the system prompt.` }),
407
+ }),
408
+ renderShell: "self",
409
+ renderCall: (args: RunArgs, theme, context) => renderRunCall(args, theme, context as unknown as CardContext),
410
+ renderResult: (result, opts, theme, context) =>
411
+ renderRunResult(result as never, opts, theme, context as unknown as CardContext, (context as { args?: RunArgs }).args ?? {}, {
412
+ tail: (id, w) => tailLines(liveItems(id), w, theme, 4),
413
+ toolCalls: (id) => manager.toolCalls(id),
414
+ startedAt: (id) => manager.record(id)?.startedAt,
415
+ usage: (id) => manager.usage(id),
416
+ record: (id) => manager.record(id),
417
+ }),
418
+ execute: runSubagent as never,
419
+ // Simple render style: live stats in the row meta, `└ Completed …` under it.
420
+ ...( {
421
+ simpleMeta: (details: CardDetails | undefined) => {
422
+ const rec = details?.id !== undefined ? manager.record(details.id) : undefined;
423
+ return rec === undefined ? undefined : ` · ${statLine(rec, manager.toolCalls(rec.id), manager.usage(rec.id))}`;
424
+ },
425
+ simpleResult: (result: { details?: CardDetails }, theme: ThemeLike) => [` ${cardOutcome(result.details, theme)}`],
426
+ } as object),
427
+ });
428
+ }
539
429
 
430
+ function textResult(text: string, details: CardDetails, isError = false) {
431
+ return { content: [{ type: "text" as const, text }], details, isError };
432
+ }
540
433
 
541
- // Build notification details for the message renderer
542
- function buildNotificationDetails(record: any, activity?: AgentActivity): NotificationDetails {
434
+ function doneDetails(rec: SubagentRecord, report: HandoffReport): CardDetails {
543
435
  return {
544
- id: record.id,
545
- description: record.description,
546
- status: record.status,
547
- toolUses: record.toolUses,
548
- turnCount: activity?.turnCount ?? 0,
549
- maxTurns: activity?.maxTurns,
550
- totalTokens: safeTokenCount(record.session),
551
- durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
552
- error: record.error,
553
- resultPreview: record.result
554
- ? record.result.length > 200
555
- ? record.result.slice(0, 200) + "…"
556
- : record.result
557
- : "No output.",
436
+ owner: "subagents", id: rec.id, title: rec.title, profile: rec.profile, model: rec.model, phase: "done",
437
+ status: rec.status === "running" ? recordStatusFor(report, rec.cancelledBy) : rec.status,
438
+ toolCalls: report.toolCalls, durationMs: report.durationMs, usage: report.usage, error: report.error, cancelledBy: rec.cancelledBy,
558
439
  };
559
440
  }
560
441
 
561
- // ---- Register custom notification renderer ----
562
- pi.registerMessageRenderer<NotificationDetails>(
563
- "subagent-notification",
564
- (message, { expanded }, theme) => {
565
- const d = message.details;
566
- if (!d) return undefined;
567
-
568
- function renderOne(d: NotificationDetails): string {
569
- const isError = d.status === "error" || d.status === "stopped" || d.status === "aborted";
570
- const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
571
- const statusText = isError
572
- ? d.status
573
- : d.status === "steered"
574
- ? "completed (steered)"
575
- : "completed";
576
-
577
- // Line 1: icon + agent description + status
578
- let line = `${icon} ${theme.bold(d.description)} ${theme.fg("dim", statusText)}`;
579
-
580
- // Line 2: stats
581
- const parts: string[] = [];
582
- if (d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns));
583
- if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
584
- if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens));
585
- if (d.durationMs > 0) parts.push(formatMs(d.durationMs));
586
- if (parts.length) {
587
- line += "\n " + parts.map((p) => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
588
- }
589
-
590
- // Line 3: result preview (collapsed) or full (expanded)
591
- if (expanded) {
592
- const lines = d.resultPreview.split("\n").slice(0, 30);
593
- for (const l of lines) line += "\n" + theme.fg("dim", ` ${l}`);
594
- } else {
595
- const preview = d.resultPreview.split("\n")[0]?.slice(0, 80) ?? "";
596
- line += "\n " + theme.fg("dim", `⎿ ${preview}`);
597
- }
598
-
599
- return line;
600
- }
601
-
602
- const all = [d, ...(d.others ?? [])];
603
- return new Text(all.map(renderOne).join("\n"), 0, 0);
604
- },
605
- );
606
-
607
- // Create widget
608
- const widget = new AgentWidget(manager, agentActivity);
609
-
610
- // ---- FleetView (persistent fleet panel; our AgentWidget slot system) ----
611
- const fleetView = new FleetView(manager, agentActivity, ASYNC_DIR, {
612
- placement: config.fleetView === false ? undefined : (config.fleetViewPlacement ?? "belowEditor"),
613
- sessionId: asyncSessionId,
614
- openInspector: async (entry) => {
615
- if (!sessionCtx?.ui) return;
616
- if (entry.source === "inprocess") {
617
- const record = manager.getRecord(entry.key.replace("inprocess:", ""));
618
- if (record?.session) {
619
- await withHerdrBlocked(
620
- pi,
621
- "fleet inspector",
622
- () => sessionCtx!.ui.custom<undefined>(
623
- (tui, theme, _keybindings, done) =>
624
- new ConversationViewer(tui, record.session!, {
625
- type: record.type,
626
- description: record.description,
627
- status: record.status,
628
- toolUses: record.toolUses,
629
- startedAt: record.startedAt,
630
- completedAt: record.completedAt,
631
- }, agentActivity.get(record.id), theme, done),
632
- { overlay: true, overlayOptions: { anchor: "center", width: "90%" } },
633
- ),
634
- );
635
- }
636
- return;
637
- }
638
- // Async run: show the transcript tail from the result payload / output.txt.
639
- const runId = entry.key.replace("async:", "");
640
- const payload = readAsyncResultFile(RESULTS_DIR, runId);
641
- const text = payload
642
- ? `${payload.success ? "completed" : payload.state ?? "failed"}\n\n${payload.output ?? payload.error ?? "(no output)"}`
643
- : "(run still active — transcript available after completion)";
644
- await withHerdrBlocked(
645
- pi,
646
- "fleet inspector",
647
- () => sessionCtx!.ui.custom<undefined>(
648
- (_tui, theme, _keybindings, done) => ({
649
- render: (width: number): string[] => [
650
- ...text.split("\n").slice(-30).map((line) => ` ${line}`),
651
- "",
652
- ` ${theme.fg("dim", "esc/q close")}`,
653
- ],
654
- handleInput: (data: string): void => {
655
- if (data === "\x1b" || data === "q") done(undefined);
656
- },
657
- invalidate: (): void => {},
658
- }),
659
- { overlay: true, overlayOptions: { anchor: "center", width: "90%" } },
660
- ),
661
- );
662
- },
663
- });
664
-
665
-
666
- // ---- Slash commands (/unipi:subagents-*) ----
667
- registerSlashCommands(pi, () => sessionCtx ?? undefined, {
668
- manager,
669
- config,
670
- asyncDirRoot: ASYNC_DIR,
671
- sessionId: asyncSessionId,
672
- });
673
-
674
- // Register info group at factory time (not session_start)
675
- const registry = getInfoRegistry();
676
- if (registry) {
677
- registry.registerGroup({
678
- id: "subagents",
679
- name: "Subagents",
680
- icon: "🤖",
681
- priority: 80,
682
- config: {
683
- showByDefault: true,
684
- stats: [
685
- { id: "maxConcurrent", label: "Max Concurrent", show: true },
686
- { id: "activeCount", label: "Active Agents", show: true },
687
- { id: "enabled", label: "Enabled", show: true },
688
- { id: "types", label: "Available Types", show: true },
689
- ],
690
- },
691
- dataProvider: async () => {
692
- const types = config.types || {};
693
- const codeBuiltins: string[] = [...BUILTIN_TYPES];
694
- const fileBuiltins = [...loadBuiltinFileAgents().keys()];
695
-
696
- const customTypes: string[] = [];
697
- for (const dir of [globalAgentsDir, workspaceAgentsDir]) {
698
- try {
699
- if (existsSync(dir)) {
700
- for (const file of readdirSync(dir)) {
701
- if (file.endsWith(".md") && !customTypes.includes(file.replace(".md", ""))) {
702
- customTypes.push(file.replace(".md", ""));
703
- }
704
- }
705
- }
706
- } catch { /* ignore */ }
707
- }
708
-
709
- const allTypes = manager.getKnownTypes();
710
- const typeList = allTypes.map((t) => {
711
- const isEnabled = manager.isTypeEnabled(t);
712
- const scope = codeBuiltins.includes(t) || fileBuiltins.includes(t)
713
- ? "builtin"
714
- : customTypes.includes(t)
715
- ? "custom"
716
- : "config";
717
- return `${t}(${scope})${isEnabled ? "" : " [disabled]"}`;
718
- }).join(", ");
719
-
720
- const activeAgents = manager.listAgents().filter((a) => a.status === "running").length;
721
-
722
- return {
723
- maxConcurrent: { value: String(manager.getMaxConcurrent()) },
724
- activeCount: { value: String(activeAgents) },
725
- enabled: { value: config.enabled ? "yes" : "no" },
726
- types: {
727
- value: allTypes.length > 0 ? allTypes[0] : "none",
728
- detail: allTypes.length > 1 ? typeList : undefined,
729
- },
730
- };
731
- },
732
- });
442
+ function reportText(rec: SubagentRecord, report: HandoffReport, status: SubagentStatus): string {
443
+ const head = status === "cancelled" ? `Subagent ${rec.id} was cancelled${rec.cancelledBy === "user" ? " by the user" : ""}. Partial output:\n\n` : "";
444
+ return `${head}${report.text}\n\n--- subagent ${rec.id} · ${status} · ${plural(report.toolCalls, "tool call")} · ${(report.durationMs / 1000).toFixed(1)}s`;
733
445
  }
734
446
 
735
- // Store session context for badge generation
736
- let sessionCtx: import("@earendil-works/pi-coding-agent").ExtensionContext | null = null;
737
-
738
- // Session start: emit MODULE_READY + capture context
739
- pi.on("session_start", async (_event, ctx) => {
740
- sessionCtx = ctx;
741
- // FleetView keyboard activation (↓/← to inspect active work).
447
+ /**
448
+ * Wait for a run while the lead's UI can answer its approvals. Ends on the
449
+ * report, Esc (aborted), Ctrl+B (moved), a pending user message
450
+ * (interrupted) or the timeout.
451
+ */
452
+ async function waitForeground(
453
+ run: SubagentRun,
454
+ signal: AbortSignal | undefined,
455
+ ctx: ExtensionContext,
456
+ onUpdate: ((u: unknown) => void) | undefined,
457
+ timeoutMs: number,
458
+ ): Promise<{ report?: HandoffReport; interrupted?: boolean; aborted?: boolean; moved?: boolean; error?: string }> {
459
+ const id = run.record.id;
460
+ let move!: () => void;
461
+ const moved = new Promise<"moved">((r) => {
462
+ move = () => r("moved");
463
+ });
464
+ run.runtime.attachUi(ctx.ui as never);
465
+ delivery.attach(id);
466
+ fgWaits.set(id, move);
467
+ manager.setBackground(id, false);
468
+ syncUi();
469
+ const started = Date.now();
742
470
  try {
743
- if (typeof (ctx.ui as unknown as { onTerminalInput?: unknown }).onTerminalInput === "function") {
744
- (ctx.ui as unknown as {
745
- onTerminalInput(handler: (data: string) => { consume?: boolean } | undefined): () => void;
746
- }).onTerminalInput((data) => {
747
- // Detach shortcut: detach the active foreground run.
748
- if (detachParts && matchesDetachInput(data, detachParts) && activeForegroundDetach) {
749
- if (activeForegroundDetach()) {
750
- try {
751
- ctx.ui.notify("Foreground run detached — it continues in the background.", "info");
752
- } catch { /* best effort */ }
753
- return { consume: true };
754
- }
755
- }
756
- return fleetView.handleKey(data, () => {
757
- try {
758
- return ctx.ui.getEditorText() !== "";
759
- } catch {
760
- return false;
761
- }
762
- });
471
+ while (true) {
472
+ if (signal?.aborted) return { aborted: true };
473
+ if (ctx.hasPendingMessages?.()) return { interrupted: true };
474
+ const remaining = timeoutMs - (Date.now() - started);
475
+ if (remaining <= 0) return {};
476
+ const tick = new Promise<undefined>((r) => setTimeout(() => r(undefined), Math.min(400, remaining)));
477
+ const outcome = await Promise.race([run.done.then((r) => ({ report: r }), (e) => ({ error: String(e) })), moved, tick]);
478
+ if (outcome === "moved") return { moved: true };
479
+ if (outcome !== undefined) return outcome;
480
+ onUpdate?.({
481
+ content: [{ type: "text" as const, text: `Subagent "${run.record.title}" working · ${plural(manager.toolCalls(id), "tool call")}` }],
482
+ details: { owner: "subagents", id, title: run.record.title, profile: run.record.profile, model: run.record.model, status: "running", startedAt: run.record.startedAt } satisfies CardDetails,
763
483
  });
764
484
  }
765
- } catch {
766
- // Terminal input hooking is optional.
485
+ } finally {
486
+ fgWaits.delete(id);
487
+ run.runtime.detachUi();
488
+ syncUi();
489
+ }
490
+ }
491
+ async function runSubagent(
492
+ _toolCallId: string,
493
+ params: { title: string; task: string; profile: string; is_background?: boolean; resume?: string },
494
+ signal: AbortSignal | undefined,
495
+ onUpdate: ((u: unknown) => void) | undefined,
496
+ ctx: ExtensionContext,
497
+ ) {
498
+ const cwd = ctx.cwd ?? process.cwd();
499
+ const resumeId = params.resume?.trim() || undefined;
500
+ const profileName = resumeId !== undefined ? (manager.record(resumeId)?.profile ?? params.profile) : params.profile;
501
+ const profile = profiles.find((p) => p.id === profileName);
502
+ if (!profile) {
503
+ return textResult(`Unknown profile "${params.profile}". Available: ${profiles.map((p) => p.id).join(", ")}`, { owner: "subagents", status: "failed", title: params.title }, true);
767
504
  }
768
- fleetView.setUICtx(ctx.ui);
769
- emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
770
- name: MODULES.SUBAGENTS || "subagents",
771
- version: "0.2.0",
772
- commands: [],
773
- tools: ["spawn_helper", "get_helper_result"],
774
- });
775
- });
776
505
 
777
- // Listen for badge generation requests — spawn background agent
778
- pi.events.on(UNIPI_EVENTS.BADGE_GENERATE_REQUEST, async (data) => {
779
- const event = data as UnipiBadgeGenerateRequestEvent;
780
- if (!sessionCtx) return;
506
+ const start = manager.start({
507
+ title: params.title,
508
+ task: params.task,
509
+ profile,
510
+ model: resolveSubagentModel(profile, ctx, config),
511
+ thinking: resolveSubagentThinking(profile, ctx, config),
512
+ cwd,
513
+ leadSessionId: leadSessionId(ctx),
514
+ background: resumeId === undefined && params.is_background === true,
515
+ resume: resumeId,
516
+ maxConcurrent: config.maxConcurrent,
517
+ onStep: (record, step) => appendSubagentStep((type, data) => pi.appendEntry(type, data), record, step),
518
+ });
519
+ if ("error" in start) return textResult(start.error, { owner: "subagents", status: "failed", title: params.title, error: start.error }, true);
520
+ const { run } = start;
521
+ const rec = run.record;
522
+ const id = rec.id;
523
+ const base: CardDetails = { owner: "subagents", id, title: rec.title, profile: rec.profile, model: rec.model };
524
+
525
+ if (rec.background) {
526
+ delivery.detach(id, run.done);
527
+ return textResult(
528
+ `Subagent ${id} started in the background. You will receive a <subagent_completion_notification agent_id="${id}"> when it finishes; use read_subagent to wait.`,
529
+ { ...base, status: "running", phase: "started" },
530
+ );
531
+ }
781
532
 
782
- const summary = event?.conversationSummary ?? "";
783
- const prompt = summary
784
- ? `Based on this conversation, generate a concise session title (MAX 5 WORDS). Reply with ONLY the title. No quotes, no explanation, no punctuation.\n\nConversation:\n${summary}`
785
- : `Generate a concise session title (MAX 5 WORDS) for this session. Reply with ONLY the title. No quotes, no explanation, no punctuation.`;
533
+ const waited = await waitForeground(run, signal, ctx, onUpdate, 2_700_000);
534
+ if (waited.report) {
535
+ delivery.consume(id);
536
+ const details = doneDetails(rec, waited.report);
537
+ return textResult(reportText(rec, waited.report, details.status!), details, details.status !== "completed");
538
+ }
539
+ if (waited.aborted) {
540
+ // Esc stopped the turn: the tool result reports the cancel; a completion
541
+ // notice would wake the lead right after the user stopped it.
542
+ delivery.consume(id);
543
+ manager.cancel(id, "user");
544
+ return textResult(`The user cancelled subagent "${rec.title}" (${id}). Don't restart it unless asked; it can be resumed with resume:${id}.`, { ...base, status: "cancelled", phase: "done", cancelledBy: "user", toolCalls: manager.toolCalls(id), durationMs: Date.now() - rec.startedAt }, true);
545
+ }
546
+ delivery.detach(id, run.done);
547
+ manager.setBackground(id, true);
548
+ if (waited.moved) {
549
+ return textResult(
550
+ `The user moved subagent "${rec.title}" (${id}) to the background so you don't wait for it. It keeps working; you will receive a <subagent_completion_notification agent_id="${id}"> when it finishes. Do not wait for it with read_subagent — continue with other work, or end your turn and let the notification wake you.`,
551
+ { ...base, status: "running", phase: "moved" },
552
+ );
553
+ }
554
+ if (waited.interrupted) {
555
+ return textResult(
556
+ `A user message arrived while subagent "${rec.title}" (${id}) was working. It continues in the background. Act on the user's message first; its completion notification will arrive when it finishes (or call read_subagent({agent_id:"${id}", block:true})).`,
557
+ { ...base, status: "running", phase: "moved" },
558
+ );
559
+ }
560
+ if (waited.error) return textResult(`Subagent ${id} failed: ${waited.error}`, { ...base, status: "failed", phase: "done", error: waited.error }, true);
561
+ return textResult(`Subagent ${id} is still running in the background; its completion notification will arrive when it finishes.`, { ...base, status: "running", phase: "moved" });
562
+ }
786
563
 
787
- // Try with configured model, fallback to inherit
788
- let modelInput: string | undefined = undefined;
789
- try {
790
- const fs = await import("node:fs");
791
- const path = await import("node:path");
792
- const configPath = path.resolve(process.cwd(), ".unipi/config/badge.json");
793
- if (fs.existsSync(configPath)) {
794
- const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
795
- if (typeof parsed.generationModel === "string" && parsed.generationModel !== "inherit") {
796
- modelInput = parsed.generationModel;
797
- }
564
+ // ── Reader registration for the shared read_subagent tool ────────────────
565
+ registerSubagentReader("subagents", {
566
+ owns: (id) => manager.record(id) !== undefined || manager.run(id) !== undefined,
567
+ latest: () => manager.latest(),
568
+ read: async (params, signal, onUpdate, ctx) => {
569
+ const id = params.agent_id ?? manager.latest()?.id;
570
+ const record = id !== undefined ? manager.record(id) : undefined;
571
+ if (id === undefined || record === undefined) {
572
+ return textResult(params.agent_id !== undefined ? `No subagent found for ${params.agent_id}.` : "No subagent has run yet.", { owner: "subagents" }, true);
798
573
  }
799
- } catch { /* ignore — inherit parent model */ }
800
- let resolvedModel: any = undefined;
801
-
802
- // Check if model is available
803
- if (modelInput && sessionCtx.modelRegistry) {
804
- const { resolveModel } = await import("./model-resolver.js");
805
- const result = resolveModel(modelInput, sessionCtx.modelRegistry);
806
- if (typeof result !== "string") {
807
- resolvedModel = result;
574
+ const base: CardDetails = { owner: "subagents", id, title: record.title, profile: record.profile, model: record.model };
575
+ const run = manager.run(id);
576
+ if (record.status !== "running" || run === undefined) {
577
+ const text = record.report !== undefined
578
+ ? `${record.status === "cancelled" ? `Cancelled${record.cancelledBy === "user" ? " by the user" : ""}. Partial output:\n\n` : ""}${record.report}\n\n--- subagent ${id} · ${record.status} · ${plural(record.toolCalls, "tool call")}`
579
+ : `Subagent ${id} ${record.status}${record.error !== undefined ? `: ${record.error}` : ""}`;
580
+ return textResult(text, { ...base, status: record.status, phase: "done", toolCalls: record.toolCalls, cancelledBy: record.cancelledBy }, record.status !== "completed");
581
+ }
582
+ if (params.block !== true) {
583
+ return textResult(`Subagent "${record.title}" (${id}) is running — ${plural(manager.toolCalls(id), "tool call")} so far.`, { ...base, status: "running" });
808
584
  }
809
- // If result is a string (error), resolvedModel stays undefined → inherit parent
585
+ const wasBackground = record.background;
586
+ const waited = await waitForeground(run, signal, ctx, onUpdate, Math.min(600, params.timeout ?? 30) * 1000);
587
+ if (waited.report) {
588
+ delivery.consume(id);
589
+ const details = doneDetails(record, waited.report);
590
+ return textResult(reportText(record, waited.report, details.status!), details, details.status !== "completed");
591
+ }
592
+ delivery.detach(id, run.done);
593
+ if (wasBackground) manager.setBackground(id, true);
594
+ if (waited.error) return textResult(`Subagent ${id} failed: ${waited.error}`, { ...base, status: "failed", error: waited.error }, true);
595
+ if (waited.aborted) return textResult(`Stopped waiting for subagent ${id}; it keeps running in the background.`, { ...base, status: "running" });
596
+ if (waited.moved) return textResult(`The user moved subagent ${id} to the background; stop waiting and continue — its completion notification will arrive.`, { ...base, status: "running" });
597
+ if (waited.interrupted) return textResult(`A user message arrived while waiting for subagent "${record.title}" (${id}); it keeps running. Act on the message first.`, { ...base, status: "running" });
598
+ return textResult(`Subagent ${id} is still running.`, { ...base, status: "running" });
599
+ },
600
+ });
601
+ ensureReadSubagentTool(pi);
602
+
603
+ // Background completion notice — badge: `DONE Explore title ···· 7s · 1 tool call`.
604
+ pi.registerMessageRenderer("subagent-completion", (message: { details?: CardDetails & { report?: string } }, opts: { expanded?: boolean }, theme) =>
605
+ renderCompletion(message.details, opts.expanded === true, theme));
606
+
607
+ // Completed subagent steps as a delegated panel (shared UNI-2 renderer;
608
+ // historical entries keep their cards, new ones stream below them).
609
+ pi.registerEntryRenderer("subagent-step", (entry: { data?: DelegatedStep & { group?: string; label?: string } }, opts: { expanded?: boolean }, theme) =>
610
+ renderDelegatedStep(
611
+ entry.data as DelegatedStep,
612
+ opts.expanded === true,
613
+ theme as never,
614
+ { group: entry.data?.group ?? "subagent", label: entry.data?.label },
615
+ ));
616
+
617
+ // ── Prompt section (only while enabled) ───────────────────────────────────
618
+ pi.on("before_agent_start", (event, ctx) => {
619
+ refresh(ctx.cwd ?? process.cwd(), ctx);
620
+ if (!enabled) {
621
+ delete event.systemPromptOptions.sections["subagents"];
622
+ return undefined;
810
623
  }
811
-
812
- manager.spawn(pi, sessionCtx, "name-gen", prompt, {
813
- description: "Generate session name",
814
- model: resolvedModel,
815
- isBackground: true,
816
- isolated: true,
817
- maxTurns: 1,
818
- });
624
+ const fusionActive = getSharedFusionStatus() !== undefined;
625
+ const out = [
626
+ "You can delegate self-contained subtasks to subagents with `run_subagent`. A subagent is an independent agent with its own context: it does not see this conversation, so put everything it needs in `task` — the goal, relevant paths, constraints, and exactly what to report back.",
627
+ "",
628
+ "Profiles:",
629
+ ...profiles.map((p) => `- ${p.id}: ${p.description}`),
630
+ "subagent_explore is read-only and runs on a cheaper model; subagent_general can make changes and runs on your own model, so it costs like a full extra session.",
631
+ "",
632
+ "- Use subagents for broad or parallelizable work: independent searches, investigations across several areas, or implementation slices that don't touch the same files. Skip them when a direct read or search answers the question.",
633
+ "- Launch independent subagents in the same response so they run in parallel, and keep their work disjoint.",
634
+ "- Foreground (the default) waits and returns the report. Set is_background:true only when you have other work to do meanwhile; you will receive a <subagent_completion_notification> when it finishes. Don't poll read_subagent in a loop.",
635
+ "- Background subagents cannot ask for approval: tool calls that would need approval are denied. Run approval-needing work in the foreground.",
636
+ "- The user can move a foreground subagent to the background (Ctrl+B) or cancel any subagent. When that happens, continue accordingly; don't restart a cancelled subagent unless asked.",
637
+ "- Resume a finished, failed, or cancelled subagent with resume:<agent_id> and a follow-up task; it keeps its context.",
638
+ "- The user does not see subagent output directly: summarize what matters from the report, and verify anything critical before relying on it.",
639
+ ];
640
+ if (fusionActive) out.push("While Fusion is active, do not use subagents other than the sidekick unless the user explicitly asks you to.");
641
+ event.systemPromptOptions.sections["subagents"] = out.join("\n");
642
+ return undefined;
819
643
  });
820
644
 
821
- // ESC propagation: abort all agents on session shutdown.
822
- // Set the guard FIRST: abortAll() settles in-flight promises, whose
823
- // completion callbacks would otherwise reach a runtime that pi is about to
824
- // invalidate.
825
- pi.on("session_shutdown", async () => {
826
- watcher.stop();
827
- schedulePoller?.stopPolling();
828
- sessionEnded = true;
829
- manager.abortAll();
830
- manager.dispose();
645
+ // ── Commands ──────────────────────────────────────────────────────────────
646
+ pi.registerCommand("unipi:subagents", {
647
+ description: "Open the subagent panel (also ↓ from an empty input)",
648
+ handler: async (_args, ctx) => {
649
+ if (getSharedSubagents().length === 0) {
650
+ ctx.ui.notify("No subagents in this session yet.", "info");
651
+ return;
652
+ }
653
+ await openDock(ctx);
654
+ },
831
655
  });
832
-
833
- // Wire UI context for widget + age finished agents on new turn
834
- pi.on("tool_execution_start", async (_event, ctx) => {
835
- widget.setUICtx(ctx.ui);
836
- widget.onTurnStart();
656
+ registerAgentsCommand(pi, { onChange: (cwd) => refresh(cwd) });
657
+
658
+ pi.on("session_start", (_event, ctx) => {
659
+ uiCtx = ctx;
660
+ stripInstalled = false;
661
+ fgInstalled = false;
662
+ workingShown = false;
663
+ manager.restore(ctx.cwd ?? process.cwd(), leadSessionId(ctx));
664
+ refresh(ctx.cwd ?? process.cwd(), ctx);
665
+ unsubInput?.();
666
+ unsubInput = ctx.hasUI ? ctx.ui.onTerminalInput(onTerminalInput) : undefined;
667
+ syncUi();
668
+ });
669
+ pi.on("session_shutdown", () => {
670
+ manager.shutdown();
671
+ unsubInput?.();
672
+ unsubInput = undefined;
673
+ unsubRegistry();
674
+ uiCtx = undefined;
837
675
  });
838
676
 
839
- // Create activity tracker
840
- function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
841
- const state: AgentActivity = {
842
- activeTools: new Map(),
843
- toolUses: 0,
844
- turnCount: 1,
845
- maxTurns,
846
- tokens: "",
847
- responseText: "",
848
- };
849
-
850
- const callbacks = {
851
- onToolActivity: (activity: { type: "start" | "end"; toolName: string }) => {
852
- if (activity.type === "start") {
853
- state.activeTools.set(activity.toolName + "_" + Date.now(), activity.toolName);
854
- } else {
855
- for (const [key, name] of state.activeTools) {
856
- if (name === activity.toolName) {
857
- state.activeTools.delete(key);
858
- break;
859
- }
860
- }
861
- state.toolUses++;
862
- }
863
- state.tokens = safeFormatTokens(state.session);
864
- onStreamUpdate?.();
865
- },
866
- onTextDelta: (_delta: string, fullText: string) => {
867
- state.responseText = fullText;
868
- onStreamUpdate?.();
869
- },
870
- onTurnEnd: (turnCount: number) => {
871
- state.turnCount = turnCount;
872
- onStreamUpdate?.();
873
- },
874
- onSessionCreated: (session: any) => {
875
- state.session = session;
876
- },
877
- };
878
-
879
- return { state, callbacks };
880
- }
881
-
882
- // ---- Agent tool ----
883
-
884
- const builtinTypes = BUILTIN_TYPES.join(", ");
885
- const enabledTypes = manager.getKnownTypes().filter((type) => manager.isTypeEnabled(type));
886
- const availableTypes = enabledTypes.join(", ") || "none";
887
-
888
- pi.registerTool(
889
- defineTool({
890
- name: "spawn_helper",
891
- label: "Spawn Helper",
892
- description: `Launch a sub-agent for parallel work.
893
-
894
- Available agent types: ${availableTypes}
895
- Custom types can be defined in:
896
- - ~/.unipi/config/agents/<name>.md (global)
897
- - <workspace>/.unipi/config/agents/<name>.md (project)
898
-
899
- Guidelines:
900
- - Use "explore" or "scout" for parallel file reads / fast codebase recon
901
- - Use "work" or "worker" for parallel file writes (transparent locking)
902
- - Use "reviewer" for code review of diffs, plans, or solutions
903
- - Use "researcher" for web research (needs web-api tools)
904
- - Use "oracle" for a second opinion on decisions before acting
905
- - Use "delegate" for a lightweight general delegate
906
- - Use run_in_background for work you don't need immediately
907
- - ESC kills all running agents immediately
908
- - Agents inherit the parent model by default`,
909
- parameters: SpawnHelperParams,
910
-
911
- // ---- Rich inline rendering ----
912
-
913
- renderCall(args, theme) {
914
- const displayName = args.type ? args.type : "Agent";
915
- const desc = args.description ?? "";
916
- return new Text(
917
- "▸ " + theme.fg("toolTitle", theme.bold(displayName)) + (desc ? " " + theme.fg("muted", desc) : ""),
918
- 0,
919
- 0,
920
- );
921
- },
922
-
923
- renderResult(result, { expanded, isPartial }, theme) {
924
- const details = result.details as any;
925
- if (!details) {
926
- const text = result.content[0]?.type === "text" ? result.content[0].text : "";
927
- return new Text(text, 0, 0);
928
- }
929
-
930
- // inlineToolDisplay: "summary" keeps one stable row per state.
931
- if (config.inlineToolDisplay === "summary") {
932
- const glyph = isPartial || details.status === "running"
933
- ? theme.fg("accent", SPINNER[details.spinnerFrame ?? 0])
934
- : details.status === "completed"
935
- ? theme.fg("success", "✓")
936
- : details.status === "background"
937
- ? theme.fg("dim", "■")
938
- : theme.fg("error", "✗");
939
- const label = details.status === "completed"
940
- ? "completed"
941
- : details.status === "background"
942
- ? "background"
943
- : details.status === "running"
944
- ? "running"
945
- : details.status;
946
- return new Text(theme.fg("muted", `${glyph} ${label}`), 0, 0);
947
- }
948
-
949
- // Stats helper
950
- const stats = (d: any) => {
951
- const parts: string[] = [];
952
- if (d.turnCount != null && d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns));
953
- if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
954
- if (d.tokens) parts.push(d.tokens);
955
- return parts.map((p) => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
956
- };
957
-
958
- // Running
959
- if (isPartial || details.status === "running") {
960
- const frame = SPINNER[details.spinnerFrame ?? 0];
961
- const s = stats(details);
962
- let line = theme.fg("accent", frame) + (s ? " " + s : "");
963
- line += "\n" + theme.fg("dim", ` ⎿ ${details.activity ?? "thinking…"}`);
964
- return new Text(line, 0, 0);
965
- }
966
-
967
- // Background launched
968
- if (details.status === "background") {
969
- return new Text(theme.fg("dim", ` ⎿ Running in background (ID: ${details.agentId})`), 0, 0);
970
- }
971
-
972
- // Completed
973
- if (details.status === "completed") {
974
- const duration = formatMs(details.durationMs);
975
- const s = stats(details);
976
- let line = theme.fg("success", "✓") + (s ? " " + s : "");
977
- line += " " + theme.fg("dim", "·") + " " + theme.fg("dim", duration);
978
-
979
- if (expanded) {
980
- const resultText = result.content[0]?.type === "text" ? result.content[0].text : "";
981
- if (resultText) {
982
- const rlines = resultText.split("\n").slice(0, 50);
983
- for (const l of rlines) {
984
- line += "\n" + theme.fg("dim", ` ${l}`);
985
- }
986
- }
987
- } else {
988
- line += "\n" + theme.fg("dim", " ⎿ Done");
989
- }
990
- return new Text(line, 0, 0);
991
- }
992
-
993
- // Error / Aborted / Stopped
994
- const isError = details.status === "error";
995
- const isStopped = details.status === "stopped";
996
- const s = stats(details);
997
- let line = (isStopped ? theme.fg("dim", "■") : theme.fg("error", "✗")) + (s ? " " + s : "");
998
-
999
- if (isError) {
1000
- line += "\n" + theme.fg("error", ` ⎿ Error: ${details.error ?? "unknown"}`);
1001
- } else if (isStopped) {
1002
- line += "\n" + theme.fg("dim", " ⎿ Stopped");
1003
- } else {
1004
- line += "\n" + theme.fg("warning", " ⎿ Aborted (max turns exceeded)");
1005
- }
1006
- return new Text(line, 0, 0);
1007
- },
1008
-
1009
- // ---- Execute ----
1010
-
1011
- execute: async (toolCallId, params, signal, onUpdate, ctx) => {
1012
- widget.setUICtx(ctx.ui);
1013
- fleetView.setUICtx(ctx.ui);
1014
-
1015
- // Route through the parity handler (actions, workflowScript, legacy
1016
- // single-child) — widget/notify plumbing lives in the deps adapters.
1017
- onUpdateForForeground = (details) => {
1018
- onUpdate?.({
1019
- content: [{ type: "text", text: `${details.toolUses ?? 0} tool uses...` }],
1020
- details,
1021
- });
1022
- };
1023
- try {
1024
- return (await handleSpawnHelper(handlerDeps, ctx, params as Record<string, unknown>, signal)) as never;
1025
- } finally {
1026
- onUpdateForForeground = () => {};
1027
- }
1028
- },
1029
- }),
1030
- );
1031
-
1032
- // ---- get_helper_result tool ----
1033
-
1034
- pi.registerTool(
1035
- defineTool({
1036
- name: "get_helper_result",
1037
- label: "Get Helper Result",
1038
- description: "Check status and retrieve results from a background agent or async run. Use view: true to open a live conversation overlay; nonBlocking: true to subscribe and be woken on completion.",
1039
- parameters: GetHelperResultParams,
1040
- execute: async (_toolCallId, rawParams, _signal, _onUpdate, ctx) => {
1041
- const params = rawParams as Record<string, unknown>;
1042
- const id = (params.id ?? params.agent_id) as string | undefined;
1043
- const nonBlocking = params.nonBlocking === true;
1044
- const waitAll = params.all === true;
1045
- const timeoutMs = typeof params.timeoutMs === "number" ? params.timeoutMs : undefined;
1046
-
1047
- // ---- Async process runs ----
1048
- const asyncPayload = id ? readAsyncResultFile(RESULTS_DIR, id) : undefined;
1049
- if (asyncPayload && !nonBlocking) {
1050
- const duration = asyncPayload.durationMs ? `${(asyncPayload.durationMs / 1000).toFixed(1)}s` : "";
1051
- return textResult(
1052
- `Run: ${asyncPayload.runId}\nAgent: ${asyncPayload.agent ?? "unknown"} | Status: ${asyncPayload.state ?? (asyncPayload.success ? "completed" : "failed")}\n\n${asyncPayload.output ?? asyncPayload.error ?? "(no output)"}`,
1053
- { status: asyncPayload.success ? "completed" : "error", runId: asyncPayload.runId },
1054
- );
1055
- }
1056
- if (nonBlocking && id) {
1057
- // Persist a wake subscription: when the result file appears, the
1058
- // watcher delivers a followUp notification that wakes this session.
1059
- if (readAsyncResultFile(RESULTS_DIR, id)) {
1060
- return textResult(`Run ${id} already completed.`, { status: "completed", runId: id });
1061
- }
1062
- writePendingSubscription(RESULTS_DIR, asyncSessionId, id);
1063
- return textResult(
1064
- `Subscribed to run ${id}. This session will be woken on completion or failure.`,
1065
- { status: "subscribed", runId: id },
1066
- );
1067
- }
1068
-
1069
- // ---- In-process records ----
1070
- const record = manager.getRecord(id as string);
1071
- if (!record) {
1072
- if (waitAll) {
1073
- // Wait for ALL active in-process agents.
1074
- const active = manager.listAgents().filter((a) => a.status === "running" || a.status === "queued");
1075
- if (active.length === 0) return textResult("No active agents.");
1076
- await Promise.race([
1077
- Promise.allSettled(active.map((a) => a.promise).filter(Boolean)),
1078
- new Promise((resolve) => setTimeout(resolve, timeoutMs ?? 1_800_000)),
1079
- ]);
1080
- return textResult(`All ${active.length} agent(s) settled (or wait timed out).`);
1081
- }
1082
- return textResult(`Helper not found: "${id}". It may have been cleaned up.`);
1083
- }
1084
-
1085
- // Open conversation viewer overlay if requested
1086
- if (params.view && record.session) {
1087
- const activity = agentActivity.get(record.id);
1088
- await withHerdrBlocked(
1089
- pi,
1090
- "helper viewer",
1091
- () => ctx.ui.custom<undefined>(
1092
- (tui, theme, _keybindings, done) => {
1093
- return new ConversationViewer(
1094
- tui,
1095
- record.session!,
1096
- {
1097
- type: record.type,
1098
- description: record.description,
1099
- status: record.status,
1100
- toolUses: record.toolUses,
1101
- startedAt: record.startedAt,
1102
- completedAt: record.completedAt,
1103
- },
1104
- activity,
1105
- theme,
1106
- done,
1107
- );
1108
- },
1109
- {
1110
- overlay: true,
1111
- overlayOptions: { anchor: "center", width: "90%" },
1112
- },
1113
- ),
1114
- );
1115
- }
1116
-
1117
- if (params.wait && record.status === "running" && record.promise) {
1118
- record.resultConsumed = true;
1119
- await record.promise;
1120
- }
1121
-
1122
- const duration = record.completedAt
1123
- ? `${((record.completedAt - record.startedAt) / 1000).toFixed(1)}s`
1124
- : "running";
1125
-
1126
- let output =
1127
- `Agent: ${record.id}\n` +
1128
- `Type: ${record.type} | Status: ${record.status} | Tool uses: ${record.toolUses} | Duration: ${duration}\n` +
1129
- `Description: ${record.description}\n\n`;
1130
-
1131
- if (record.status === "running") {
1132
- output += "Agent is still running. Use wait: true or check back later.";
1133
- } else if (record.status === "error") {
1134
- output += `Error: ${record.error}`;
1135
- } else {
1136
- const bounded = boundHelperOutput(
1137
- record.result?.trim() || "No output.",
1138
- 64 * 1024,
1139
- record.resultArtifactPath,
1140
- );
1141
- record.resultArtifactPath = bounded.artifactPath;
1142
- output += bounded.text;
1143
- }
1144
-
1145
- if (record.status !== "running" && record.status !== "queued") {
1146
- record.resultConsumed = true;
1147
- }
1148
-
1149
- return textResult(output);
1150
- },
1151
- }),
1152
- );
677
+ emitEvent(pi, UNIPI_EVENTS.MODULE_READY, { module: "subagents" });
1153
678
  }