one-code-extension 0.4.1 → 0.6.0

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 (439) hide show
  1. package/README.md +16 -5
  2. package/agents/general-purpose.md +2 -2
  3. package/docs/guide/README.md +2 -1
  4. package/docs/guide/bring-your-claude-code-setup.md +60 -9
  5. package/docs/guide/configuration.md +48 -14
  6. package/docs/guide/differences-from-claude-code.md +40 -28
  7. package/docs/guide/doctor.md +20 -13
  8. package/docs/guide/hooks.md +18 -3
  9. package/docs/guide/installation.md +26 -4
  10. package/docs/guide/permissions-modes-and-auto-mode.md +225 -49
  11. package/docs/guide/providers-and-models.md +137 -54
  12. package/docs/guide/reference.md +27 -15
  13. package/docs/guide/sessions-and-context.md +69 -16
  14. package/docs/guide/skills-plugins-and-mcp.md +57 -20
  15. package/docs/guide/subagents-and-workflows.md +95 -29
  16. package/docs/guide/tasks-and-background-work.md +101 -26
  17. package/docs/guide/terminal-interface.md +24 -12
  18. package/docs/guide/tools.md +46 -5
  19. package/docs/guide/troubleshooting.md +13 -9
  20. package/docs/guide/windows.md +58 -31
  21. package/extensions/artifacts/gallery.ts +227 -0
  22. package/extensions/artifacts/index.ts +288 -0
  23. package/extensions/artifacts/store.ts +570 -0
  24. package/extensions/artifacts/viewer.ts +199 -0
  25. package/extensions/ask-user/description.ts +67 -0
  26. package/extensions/ask-user/dialogs.ts +80 -0
  27. package/extensions/ask-user/index.ts +37 -33
  28. package/extensions/ask-user/questions.ts +32 -7
  29. package/extensions/ask-user/widget.ts +45 -25
  30. package/extensions/auto-mode/actions.ts +6 -0
  31. package/extensions/auto-mode/cache.ts +88 -0
  32. package/extensions/auto-mode/classifier-prompt.ts +5 -4
  33. package/extensions/auto-mode/classifier.ts +290 -65
  34. package/extensions/auto-mode/config.ts +72 -20
  35. package/extensions/auto-mode/decision-log.ts +2 -0
  36. package/extensions/auto-mode/defaults.ts +1 -1
  37. package/extensions/auto-mode/denials.ts +124 -0
  38. package/extensions/auto-mode/git-checkout-programs.ts +187 -0
  39. package/extensions/auto-mode/git-status-meta.ts +159 -0
  40. package/extensions/auto-mode/history.ts +189 -0
  41. package/extensions/auto-mode/instructions.ts +58 -52
  42. package/extensions/auto-mode/model-select.ts +159 -134
  43. package/extensions/auto-mode/paths.ts +57 -5
  44. package/extensions/auto-mode/pause.ts +57 -2
  45. package/extensions/auto-mode/prompt.ts +37 -21
  46. package/extensions/auto-mode/read-only-options.ts +724 -0
  47. package/extensions/auto-mode/resolved-paths-meta.ts +133 -0
  48. package/extensions/auto-mode/rules.ts +18 -1
  49. package/extensions/auto-mode/safety-floor.ts +361 -18
  50. package/extensions/auto-mode/setup-run.ts +20 -14
  51. package/extensions/auto-mode/shell-analysis.ts +764 -490
  52. package/extensions/auto-mode/shell-parse.ts +885 -0
  53. package/extensions/auto-mode/system-note.ts +33 -0
  54. package/extensions/auto-mode/transcript.ts +80 -51
  55. package/extensions/background/cron.ts +642 -0
  56. package/extensions/background/index.ts +614 -248
  57. package/extensions/background/loop-fire.ts +173 -0
  58. package/extensions/background/loop-preamble.ts +59 -0
  59. package/extensions/background/loop-skill.ts +163 -0
  60. package/extensions/background/monitor-batch.ts +53 -0
  61. package/extensions/background/noop-fold.ts +128 -0
  62. package/extensions/background/switch-guard.ts +56 -0
  63. package/extensions/background/wakeup.ts +178 -55
  64. package/extensions/bash/background.ts +53 -17
  65. package/extensions/bash/child.ts +7 -0
  66. package/extensions/bash/description.ts +188 -0
  67. package/extensions/bash/guards.ts +15 -67
  68. package/extensions/bash/index.ts +66 -35
  69. package/extensions/branding/compaction-keep.ts +63 -0
  70. package/extensions/branding/declined.ts +22 -0
  71. package/extensions/branding/index.ts +133 -9
  72. package/extensions/branding/prompt-editor.ts +53 -6
  73. package/extensions/branding/queued-edit.ts +31 -0
  74. package/extensions/branding/replaced-builtins.ts +68 -0
  75. package/extensions/branding/startup.ts +33 -71
  76. package/extensions/btw/index.ts +250 -78
  77. package/extensions/btw/panel.ts +228 -100
  78. package/extensions/btw/prompt.ts +41 -4
  79. package/extensions/cache-marks/index.ts +42 -0
  80. package/extensions/claude-compat/index.ts +7 -11
  81. package/extensions/claude-context/index.ts +436 -63
  82. package/extensions/compaction/cut.ts +157 -0
  83. package/extensions/compaction/index.ts +219 -75
  84. package/extensions/compaction/kept-thinking.ts +62 -0
  85. package/extensions/compaction/prompt.ts +15 -12
  86. package/extensions/context-budget/budget.ts +16 -2
  87. package/extensions/context-budget/index.ts +35 -15
  88. package/extensions/context-management/index.ts +36 -28
  89. package/extensions/doctor/build.ts +59 -8
  90. package/extensions/doctor/builtins.ts +84 -0
  91. package/extensions/doctor/cli.ts +7 -1
  92. package/extensions/doctor/compat.ts +88 -23
  93. package/extensions/doctor/dependencies.ts +41 -5
  94. package/extensions/doctor/fix-prompt.ts +9 -2
  95. package/extensions/doctor/index.ts +102 -58
  96. package/extensions/doctor/models.ts +102 -63
  97. package/extensions/doctor/presets.ts +42 -27
  98. package/extensions/doctor/report.ts +19 -2
  99. package/extensions/doctor/viewer.ts +49 -27
  100. package/extensions/effort/index.ts +141 -25
  101. package/extensions/effort/settings.ts +21 -0
  102. package/extensions/effort/slider.ts +24 -20
  103. package/extensions/exit/index.ts +32 -1
  104. package/extensions/file-tracker/index.ts +420 -42
  105. package/extensions/file-tracker/replay.ts +137 -24
  106. package/extensions/file-tracker/restore.ts +66 -0
  107. package/extensions/file-tracker/results.ts +66 -0
  108. package/extensions/file-tracker/shell-reads.ts +211 -0
  109. package/extensions/file-tracker/tracker.ts +45 -15
  110. package/extensions/footer/footer-line.ts +45 -24
  111. package/extensions/footer/index.ts +47 -2
  112. package/extensions/footer/pr.ts +34 -5
  113. package/extensions/hooks/compaction.ts +7 -0
  114. package/extensions/hooks/executor.ts +32 -30
  115. package/extensions/hooks/index.ts +209 -58
  116. package/extensions/hooks/matcher.ts +3 -0
  117. package/extensions/hooks/prompt-context.ts +59 -0
  118. package/extensions/hooks/protocol.ts +41 -3
  119. package/extensions/hooks/settings.ts +50 -19
  120. package/extensions/hooks/subagent-bridge.ts +2 -0
  121. package/extensions/hooks/trust.ts +26 -12
  122. package/extensions/idle-compact/index.ts +119 -0
  123. package/extensions/idle-compact/policy.ts +143 -0
  124. package/extensions/init/index.ts +11 -10
  125. package/extensions/init/prompt.ts +25 -32
  126. package/extensions/interrupted/index.ts +13 -1
  127. package/extensions/kept-thinking/index.ts +27 -0
  128. package/extensions/ledger/index.ts +46 -0
  129. package/extensions/ledger/wire.ts +154 -0
  130. package/extensions/lib/agent-cron.ts +146 -0
  131. package/extensions/lib/agent-loader.ts +18 -3
  132. package/extensions/lib/agent-view.ts +85 -0
  133. package/extensions/lib/anthropic-auth.ts +98 -0
  134. package/extensions/lib/anthropic-payload.ts +82 -1
  135. package/extensions/lib/anthropic-server-call.ts +230 -0
  136. package/extensions/lib/anthropic-server-tools.ts +485 -0
  137. package/extensions/lib/app-launch.mjs +94 -0
  138. package/extensions/lib/argument-hints.ts +190 -0
  139. package/extensions/lib/atomic-write.ts +6 -2
  140. package/extensions/lib/auto-memory.ts +75 -0
  141. package/extensions/lib/bash-parser.ts +84 -0
  142. package/extensions/lib/btw-fork.ts +93 -0
  143. package/extensions/lib/child-extensions.ts +52 -0
  144. package/extensions/lib/child-writes.ts +45 -0
  145. package/extensions/lib/claude-context.ts +515 -100
  146. package/extensions/lib/claude-external-includes.ts +68 -0
  147. package/extensions/lib/claude-id.ts +22 -0
  148. package/extensions/lib/claude-rules.ts +323 -0
  149. package/extensions/lib/claude-settings.ts +76 -4
  150. package/extensions/lib/compaction-boundary.ts +36 -0
  151. package/extensions/lib/compaction-keep.mjs +83 -0
  152. package/extensions/lib/config-mode.ts +112 -0
  153. package/extensions/lib/consent-dialogs.ts +63 -0
  154. package/extensions/lib/consent-preview.ts +5 -1
  155. package/extensions/lib/consent-stores.ts +48 -0
  156. package/extensions/lib/context-facts.ts +97 -0
  157. package/extensions/lib/covered-messages.ts +23 -0
  158. package/extensions/lib/deferred.ts +359 -25
  159. package/extensions/lib/dock-rows.ts +60 -0
  160. package/extensions/lib/empty-tool-result.ts +27 -0
  161. package/extensions/lib/environment-block.ts +76 -0
  162. package/extensions/lib/fetch-timeout.ts +24 -6
  163. package/extensions/{system-prompt → lib}/git-status.ts +51 -13
  164. package/extensions/lib/git.ts +75 -13
  165. package/extensions/lib/grammars/LICENSE-tree-sitter-bash +21 -0
  166. package/extensions/lib/grammars/README.md +2 -0
  167. package/extensions/lib/grammars/tree-sitter-bash.wasm +0 -0
  168. package/extensions/lib/guide-docs.ts +45 -0
  169. package/extensions/lib/headless-output.ts +46 -0
  170. package/extensions/lib/hook-gate.ts +7 -7
  171. package/extensions/lib/interrupt.ts +20 -4
  172. package/extensions/lib/key-input.ts +455 -0
  173. package/extensions/lib/keys.ts +14 -1
  174. package/extensions/lib/ledger/fold-memo.ts +49 -0
  175. package/extensions/lib/ledger/fold.ts +265 -0
  176. package/extensions/lib/ledger/layout.ts +72 -0
  177. package/extensions/lib/ledger/plan.ts +102 -0
  178. package/extensions/lib/ledger/records.ts +276 -0
  179. package/extensions/lib/ledger/state.ts +47 -0
  180. package/extensions/lib/ledger/validate.ts +141 -0
  181. package/extensions/lib/ledger/wire.ts +632 -0
  182. package/extensions/lib/local-command.ts +71 -19
  183. package/extensions/lib/mcp-overrides.ts +1 -1
  184. package/extensions/lib/mcp-share.ts +46 -0
  185. package/extensions/lib/memory-content.ts +146 -0
  186. package/extensions/lib/memory-index.ts +18 -0
  187. package/extensions/lib/memory.ts +134 -123
  188. package/extensions/lib/model-catalog/huggingface.json +178 -0
  189. package/extensions/lib/model-catalog/models-dev.json +18533 -0
  190. package/extensions/lib/model-catalog/openrouter.json +5050 -0
  191. package/extensions/lib/model-catalog-data.ts +513 -0
  192. package/extensions/lib/model-catalog.ts +462 -0
  193. package/extensions/lib/model-choice-warnings.ts +137 -0
  194. package/extensions/lib/model-pairings.ts +152 -0
  195. package/extensions/{auto-mode → lib}/model-picker.ts +26 -43
  196. package/extensions/lib/model-policy.ts +112 -15
  197. package/extensions/lib/model-tier.ts +283 -355
  198. package/extensions/lib/model-unusable.ts +1 -1
  199. package/extensions/lib/newer-model.ts +100 -0
  200. package/extensions/lib/notices.ts +87 -0
  201. package/extensions/lib/notifications.ts +357 -73
  202. package/extensions/lib/one-code-settings.ts +74 -8
  203. package/extensions/lib/open-browser.ts +63 -0
  204. package/extensions/lib/openrouter-generation.ts +94 -0
  205. package/extensions/lib/operation-deadline.ts +30 -0
  206. package/extensions/lib/original-command.ts +4 -4
  207. package/extensions/lib/package-version.ts +1 -1
  208. package/extensions/lib/panel-focus.ts +134 -0
  209. package/extensions/lib/paths.ts +17 -2
  210. package/extensions/lib/permission-gate.ts +72 -6
  211. package/extensions/lib/persisted-output.ts +60 -8
  212. package/extensions/lib/pi-ai-estimate.ts +19 -3
  213. package/extensions/lib/pi-version.ts +38 -2
  214. package/extensions/lib/plan-mode-channels.ts +42 -5
  215. package/extensions/lib/plugins.ts +26 -17
  216. package/extensions/lib/powershell-parse-script.ts +208 -0
  217. package/extensions/lib/powershell-parser.ts +344 -0
  218. package/extensions/lib/previous-mark.ts +101 -0
  219. package/extensions/lib/process-state.ts +48 -0
  220. package/extensions/lib/process-tree.ts +132 -8
  221. package/extensions/lib/prompt-options.ts +82 -0
  222. package/extensions/lib/queued-delivery.ts +44 -0
  223. package/extensions/lib/reminders.ts +370 -358
  224. package/extensions/lib/replaced-builtins.mjs +199 -0
  225. package/extensions/lib/replay-call.ts +70 -0
  226. package/extensions/lib/request-replay.ts +547 -0
  227. package/extensions/lib/scratchpad.ts +81 -14
  228. package/extensions/lib/send-now-channel.ts +7 -0
  229. package/extensions/lib/session-model-tier.ts +51 -0
  230. package/extensions/lib/session-work.ts +64 -0
  231. package/extensions/lib/settings-channels.ts +4 -4
  232. package/extensions/lib/shell-exit-semantics.ts +170 -0
  233. package/extensions/lib/shell-quote.ts +11 -0
  234. package/extensions/lib/shell-result.ts +70 -0
  235. package/extensions/lib/shell-spawn.ts +70 -6
  236. package/extensions/lib/shell-tool.ts +75 -25
  237. package/extensions/lib/side-call-cache.ts +124 -0
  238. package/extensions/lib/side-call-run.ts +96 -0
  239. package/extensions/lib/side-call-usage.ts +38 -0
  240. package/extensions/lib/side-call.ts +24 -1
  241. package/extensions/lib/single-flight.ts +18 -0
  242. package/extensions/lib/skill-body.ts +42 -0
  243. package/extensions/lib/skill-scan.ts +63 -24
  244. package/extensions/lib/spill-file.ts +140 -0
  245. package/extensions/lib/subagent-handback.ts +112 -0
  246. package/extensions/lib/system-role.ts +156 -0
  247. package/extensions/lib/terminal-text.ts +114 -0
  248. package/extensions/lib/text-width.ts +47 -16
  249. package/extensions/lib/timer-ops.ts +20 -0
  250. package/extensions/lib/tool-additions.ts +129 -0
  251. package/extensions/lib/tool-call-corruption.ts +134 -0
  252. package/extensions/lib/tool-path.ts +66 -0
  253. package/extensions/lib/tool-variants.ts +69 -0
  254. package/extensions/lib/tui-render.ts +255 -27
  255. package/extensions/lib/turn-budget-layout.ts +159 -0
  256. package/extensions/lib/update-check.mjs +15 -2
  257. package/extensions/lib/usage-bus.ts +1 -1
  258. package/extensions/lib/user-denial.ts +38 -0
  259. package/extensions/lib/workspace-channel.ts +16 -0
  260. package/extensions/lib/worktree-channel.ts +31 -3
  261. package/extensions/lib/worktree-isolation.ts +40 -10
  262. package/extensions/lsp/client.ts +8 -9
  263. package/extensions/lsp/index.ts +76 -12
  264. package/extensions/lsp/keep-alive.ts +1 -1
  265. package/extensions/lsp/trust.ts +119 -0
  266. package/extensions/lsp/watcher.ts +1 -1
  267. package/extensions/mcp/announce.ts +90 -43
  268. package/extensions/mcp/client.ts +161 -48
  269. package/extensions/mcp/config.ts +132 -8
  270. package/extensions/mcp/discovery.ts +19 -0
  271. package/extensions/mcp/index.ts +220 -121
  272. package/extensions/mcp/oauth/browser.ts +38 -17
  273. package/extensions/mcp/oauth/flow.ts +25 -17
  274. package/extensions/mcp/oauth/provider.ts +14 -8
  275. package/extensions/mcp/oauth/store.ts +34 -16
  276. package/extensions/mcp/panel/keys.ts +12 -11
  277. package/extensions/mcp/panel/render.ts +9 -6
  278. package/extensions/mcp/schema.ts +104 -5
  279. package/extensions/mcp/trust.ts +164 -73
  280. package/extensions/memory/entries.ts +23 -14
  281. package/extensions/memory/index.ts +134 -28
  282. package/extensions/memory/open-external.ts +33 -18
  283. package/extensions/memory/panel.ts +135 -26
  284. package/extensions/model-default/index.ts +86 -19
  285. package/extensions/newer-model/index.ts +31 -0
  286. package/extensions/notebook/description.ts +18 -0
  287. package/extensions/notebook/index.ts +6 -5
  288. package/extensions/notebook/notebook.ts +1 -1
  289. package/extensions/permissions/index.ts +1426 -398
  290. package/extensions/permissions/matcher.ts +425 -74
  291. package/extensions/permissions/modes.ts +11 -4
  292. package/extensions/permissions/panel/host.ts +130 -0
  293. package/extensions/permissions/panel/keys.ts +48 -0
  294. package/extensions/permissions/panel/render.ts +450 -0
  295. package/extensions/permissions/panel/state.ts +550 -0
  296. package/extensions/permissions/powershell-cmdlets.ts +198 -0
  297. package/extensions/permissions/powershell-paths.ts +197 -0
  298. package/extensions/permissions/powershell-rules.ts +207 -280
  299. package/extensions/permissions/powershell-tree.ts +557 -0
  300. package/extensions/permissions/project-trust.ts +27 -11
  301. package/extensions/permissions/protected-paths.ts +14 -2
  302. package/extensions/permissions/session-grant.ts +6 -0
  303. package/extensions/permissions/settings.ts +166 -31
  304. package/extensions/permissions/subagent-gate.ts +25 -0
  305. package/extensions/permissions/workspace.ts +78 -0
  306. package/extensions/plan-mode/index.ts +276 -92
  307. package/extensions/plan-mode/reminder.ts +89 -21
  308. package/extensions/plan-mode/run-state.ts +26 -0
  309. package/extensions/plan-mode/texts.ts +177 -0
  310. package/extensions/plan-mode/viewer.ts +92 -27
  311. package/extensions/plugins/counts.ts +3 -3
  312. package/extensions/plugins/index.ts +64 -46
  313. package/extensions/plugins/install/install.ts +16 -0
  314. package/extensions/plugins/marketplace/sync.ts +9 -3
  315. package/extensions/plugins/marketplace/types.ts +3 -1
  316. package/extensions/plugins/panel/keys.ts +21 -24
  317. package/extensions/plugins/panel/render.ts +12 -6
  318. package/extensions/plugins/shell-expand.ts +107 -0
  319. package/extensions/plugins/template.ts +32 -15
  320. package/extensions/powershell/child.ts +7 -0
  321. package/extensions/powershell/description.ts +38 -6
  322. package/extensions/powershell/guards.ts +1 -1
  323. package/extensions/powershell/index.ts +14 -7
  324. package/extensions/recap/index.ts +73 -71
  325. package/extensions/recap/prompt.ts +2 -2
  326. package/extensions/recap/scheduler.ts +3 -5
  327. package/extensions/search-tools/grep-merge.ts +39 -0
  328. package/extensions/search-tools/index.ts +0 -0
  329. package/extensions/send-now/chord.ts +62 -0
  330. package/extensions/send-now/index.ts +133 -0
  331. package/extensions/session-title/index.ts +3 -0
  332. package/extensions/session-title/title.ts +4 -1
  333. package/extensions/skill/announce.ts +30 -0
  334. package/extensions/skill/code-review.ts +97 -0
  335. package/extensions/skill/index.ts +469 -132
  336. package/extensions/skill/invoke.ts +118 -75
  337. package/extensions/skill/listing.ts +133 -9
  338. package/extensions/skill/panel/keys.ts +15 -16
  339. package/extensions/skill/panel/render.ts +5 -2
  340. package/extensions/skill/panel/state.ts +12 -1
  341. package/extensions/subagents/agent-args.ts +39 -0
  342. package/extensions/subagents/agent-description.ts +132 -0
  343. package/extensions/subagents/agents.ts +146 -31
  344. package/extensions/subagents/default-model.ts +2 -3
  345. package/extensions/subagents/delegation-steer.ts +4 -4
  346. package/extensions/subagents/fork-cache.ts +82 -0
  347. package/extensions/subagents/guide-agent.ts +189 -0
  348. package/extensions/subagents/hand-back-review.ts +3 -0
  349. package/extensions/subagents/index.ts +1594 -852
  350. package/extensions/subagents/live-runs.ts +26 -6
  351. package/extensions/subagents/model-select.ts +38 -25
  352. package/extensions/subagents/outcome.ts +5 -2
  353. package/extensions/subagents/panel-keys.ts +73 -31
  354. package/extensions/subagents/panel-render.ts +120 -96
  355. package/extensions/subagents/panel-widget.ts +60 -89
  356. package/extensions/subagents/parent-mode.ts +50 -0
  357. package/extensions/subagents/pending-claim.ts +18 -1
  358. package/extensions/subagents/runner.ts +135 -80
  359. package/extensions/subagents/runs.ts +44 -5
  360. package/extensions/subagents/send-to-main-tool.ts +1 -1
  361. package/extensions/subagents/session-turns.ts +32 -8
  362. package/extensions/subagents/shell-panel.ts +36 -178
  363. package/extensions/subagents/tasks-dialog.ts +170 -0
  364. package/extensions/subagents/viewport-mirror.ts +95 -0
  365. package/extensions/subagents/worktree.ts +57 -13
  366. package/extensions/system-prompt/environment.ts +19 -19
  367. package/extensions/system-prompt/idle-turn.ts +65 -0
  368. package/extensions/system-prompt/index.ts +165 -47
  369. package/extensions/system-prompt/template.ts +34 -51
  370. package/extensions/system-prompt/tiers/common.ts +23 -64
  371. package/extensions/system-prompt/tiers/frontier.ts +32 -11
  372. package/extensions/system-prompt/tiers/low.ts +67 -45
  373. package/extensions/system-prompt/tiers/mid.ts +96 -38
  374. package/extensions/system-reminder/index.ts +204 -70
  375. package/extensions/tasks/descriptions.ts +190 -0
  376. package/extensions/tasks/index.ts +120 -41
  377. package/extensions/tasks/store.ts +83 -16
  378. package/extensions/tool-call-corruption/child.ts +7 -0
  379. package/extensions/tool-call-corruption/index.ts +237 -0
  380. package/extensions/tool-search/index.ts +366 -144
  381. package/extensions/tool-style/index.ts +68 -10
  382. package/extensions/tool-style/write-description.ts +40 -0
  383. package/extensions/tool-style/write-render.ts +237 -0
  384. package/extensions/turn-duration/index.ts +7 -3
  385. package/extensions/turn-duration/line.ts +47 -4
  386. package/extensions/web/backends.ts +6 -9
  387. package/extensions/web/description.ts +50 -0
  388. package/extensions/web/index.ts +118 -53
  389. package/extensions/web/native-call.ts +15 -0
  390. package/extensions/web/native-results.ts +41 -0
  391. package/extensions/web-fetch/description.ts +42 -0
  392. package/extensions/web-fetch/extract.ts +53 -6
  393. package/extensions/web-fetch/index.ts +125 -76
  394. package/extensions/web-fetch/page-content.ts +19 -0
  395. package/extensions/web-fetch/response.ts +49 -0
  396. package/extensions/web-fetch/summarize.ts +9 -10
  397. package/extensions/workflow/agent-session.ts +108 -18
  398. package/extensions/workflow/args.ts +24 -0
  399. package/extensions/workflow/description.ts +67 -0
  400. package/extensions/workflow/globals.ts +17 -3
  401. package/extensions/workflow/index.ts +157 -162
  402. package/extensions/workflow/records.ts +1 -0
  403. package/extensions/workflow/run-manager.ts +33 -6
  404. package/extensions/workflow/saved-workflows.ts +3 -2
  405. package/extensions/workflow/types.ts +22 -1
  406. package/extensions/workflow/viewer.ts +54 -24
  407. package/extensions/workflow/widget.ts +20 -0
  408. package/extensions/worktree/descriptions.ts +91 -0
  409. package/extensions/worktree/guards.ts +316 -54
  410. package/extensions/worktree/index.ts +162 -44
  411. package/extensions/worktree/powershell-guards.ts +167 -0
  412. package/extensions/worktree/rewrite.ts +14 -16
  413. package/package.json +21 -7
  414. package/skills/artifact-design/SKILL.md +85 -0
  415. package/skills/code-review/SKILL.md +20 -20
  416. package/skills/code-review/cells/arguments-and-flags.md +19 -0
  417. package/skills/code-review/cells/high-3x5-recall-verify.md +137 -0
  418. package/skills/code-review/cells/high-8-inline.md +122 -0
  419. package/skills/code-review/cells/low-1-pass-max4.md +27 -0
  420. package/skills/code-review/cells/low-1-pass-min-files-4.md +28 -0
  421. package/skills/code-review/cells/max-5x5-verify-sweep.md +162 -0
  422. package/skills/code-review/cells/medium-3x5-verify.md +132 -0
  423. package/skills/code-review/cells/minimal-prompt.md +32 -0
  424. package/skills/code-review/cells/xhigh-10-inline-sweep.md +149 -0
  425. package/skills/code-review/cells/xhigh-5x5-verify-sweep.md +162 -0
  426. package/skills/fewer-permission-prompts/SKILL.md +1 -1
  427. package/skills/loop/SKILL.md +11 -0
  428. package/skills/security-review/SKILL.md +2 -5
  429. package/skills/simplify/SKILL.md +7 -5
  430. package/skills/workflow-authoring/SKILL.md +143 -0
  431. package/themes/onecode-light.json +1 -1
  432. package/themes/onecode.json +1 -1
  433. package/types/app.d.ts +58 -0
  434. package/types/community-packages.d.ts +26 -1
  435. package/extensions/auto-mode/recoverability.ts +0 -229
  436. package/extensions/lib/capability-index.ts +0 -466
  437. package/extensions/lib/model-facts.generated.json +0 -2675
  438. package/extensions/lib/model-facts.ts +0 -162
  439. package/extensions/tool-search/announce.ts +0 -42
@@ -16,17 +16,21 @@
16
16
  * children persist their sessions per run to make that possible).
17
17
  */
18
18
 
19
- import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
19
+ import { CHILD_WROTE_CHANNEL, type ChildWrote } from "../lib/child-writes.ts";
20
+ import { canShowCustomUi } from "../lib/headless-output.ts";
21
+ import { existsSync, mkdirSync, readdirSync, renameSync, writeFileSync, readFileSync } from "node:fs";
22
+ import { AGENT_CRON_CHANNEL, AGENT_CRON_FIRE_CHANNEL, type AgentCronFire, type AgentCronRequest, agentCronTools, agentOwnsCronJobs } from "../lib/agent-cron.ts";
20
23
  import os from "node:os";
21
24
  import { dirname, join } from "node:path";
25
+ import { createHash } from "node:crypto";
22
26
  import { fileURLToPath } from "node:url";
23
27
  import { StringEnum } from "@earendil-works/pi-ai";
24
- import type { Api, Model } from "@earendil-works/pi-ai";
25
- import { getAgentDir, type ExtensionAPI, type ExtensionContext, type ToolDefinition } from "@earendil-works/pi-coding-agent";
26
- import { Type } from "typebox";
28
+ import type { Api, Message, Model } from "@earendil-works/pi-ai";
29
+ import { type AgentToolResult, defineTool, getAgentDir, type ExtensionAPI, type ExtensionContext, type ToolDefinition } from "@earendil-works/pi-coding-agent";
30
+ import { type Static, Type } from "typebox";
27
31
  import { SUBAGENT_ACTIONS_CHANNEL, type SubagentActionsPayload } from "../auto-mode/actions.ts";
28
- import { type AgentDefinition, type AgentSource, agentDirs, discoverAgents } from "./agents.ts";
29
- import { modelIdentity, modelSpec, supportsImageInput } from "../lib/model-policy.ts";
32
+ import { type AgentDefinition, agentFileSources, agentSourcesStamp, BUNDLED_AGENTS_DIR, discoverAgents } from "./agents.ts";
33
+ import { findConfigured, modelIdentity, modelSpec, supportsImageInput } from "../lib/model-policy.ts";
30
34
  import { MODEL_UNUSABLE_CHANNEL, type ModelUnusableEvent, withoutUnusable } from "../lib/model-unusable.ts";
31
35
  import { applicableSubagentDefault, loadSubagentDefault, persistSubagentModel, type SubagentDefault } from "./default-model.ts";
32
36
  import {
@@ -35,27 +39,44 @@ import {
35
39
  type SubagentModelResolution,
36
40
  SUBAGENT_STATUS_CHANNEL,
37
41
  subagentModelNotes,
42
+ refusedModelNote,
38
43
  subagentModelsReminder,
39
44
  subagentStatusModel,
40
45
  } from "./model-select.ts";
41
- import { modelPickerComponent, pickerSpec, toPickerEntries, type PickerEntry } from "../auto-mode/model-picker.ts";
46
+ import { modelPickerComponent, pickerSpec, toPickerEntries, type PickerEntry } from "../lib/model-picker.ts";
47
+ import { chosenModelWarnings } from "../lib/model-choice-warnings.ts";
48
+ import { readSuggestNewerModels } from "../lib/one-code-settings.ts";
42
49
  import { defaultDiscoverRoots, discoverPlugins } from "../lib/plugins.ts";
43
- import { DEFER_CHANNEL } from "../lib/deferred.ts";
44
- import { MCP_TOOLS_CHANNEL, type McpToolsPayload } from "../lib/mcp-share.ts";
45
- import { resolveModelTier } from "../lib/model-tier.ts";
46
- import { pendingClaimReminder } from "./pending-claim.ts";
50
+ import { guideDocs } from "../lib/guide-docs.ts";
51
+ import { extensionVersion } from "../lib/package-version.ts";
52
+ import { scanSkills } from "../lib/skill-scan.ts";
53
+ import { type GuideInput, guideAgentDefinition, settingsSetup } from "./guide-agent.ts";
54
+ import { MCP_STATUS_CHANNEL, MCP_STATUS_REQUEST_CHANNEL, type McpServerStatus, type McpStatusEvent, type McpStatusKind } from "../lib/mcp-status.ts";
55
+
56
+ import { DEFER_CHANNEL, queryDeferred } from "../lib/deferred.ts";
57
+ import { requestSystemPrompt } from "../lib/prompt-options.ts";
58
+ import { captureMatches, LAST_REQUEST_CHANNEL, type RequestCapture } from "../lib/request-replay.ts";
59
+ import { BTW_FORK_CHANNEL, btwForkDescription, btwForkName, btwForkRecord, btwForkTaskId, type BtwForkRequest, type BtwForkResult } from "../lib/btw-fork.ts";
60
+ import { watchMcpTools } from "../lib/mcp-share.ts";
61
+ import { sessionModelTier } from "../lib/session-model-tier.ts";
62
+ import { type DescriptionForm, followDescriptionForm, registerVariantTool } from "../lib/tool-variants.ts";
63
+ import { agentDescription } from "./agent-description.ts";
64
+ import { prepareAgentArguments } from "./agent-args.ts";
65
+ import { projectConfigDirName } from "../lib/config-mode.ts";
66
+ import { pendingClaimReminder, runningAfterCompactionReminder } from "./pending-claim.ts";
47
67
  import { watchPermissionBridge } from "../permissions/subagent-gate.ts";
48
68
  import { watchHookBridge } from "../hooks/subagent-bridge.ts";
49
- import { CONTEXT_ORDER, REMINDER_CHANNEL } from "../lib/reminders.ts";
69
+ import { CONTEXT_ORDER, REMINDER_CHANNEL, RENDER_CHANNEL, type RenderRequest, type ReminderPayload, toldBlockFact } from "../lib/reminders.ts";
50
70
  import { type BackgroundTask, generateTaskId, TASK_REGISTER_CHANNEL } from "../background/registry.ts";
51
71
  import { type ChildAction } from "../auto-mode/actions.ts";
52
72
  import { type ChildOutcome, forkTaskMessage, OUTPUT_CAP, type RpcChildHandle } from "./outcome.ts";
53
- import { type AgentRunRecord, resolveRunName, RunRegistry } from "./runs.ts";
73
+ import { type AgentRunRecord, FORK_AGENT, freeRunName, resolveRunName, RunRegistry } from "./runs.ts";
54
74
  import { SubagentRuntime } from "./runner.ts";
55
75
  import { emptyUsage, formatStats, type UsageTotals } from "./usage.ts";
56
- import { cleanupWorktree, createWorktree, isGitRepo, type Worktree } from "./worktree.ts";
57
- import { findGitRoot } from "../lib/git.ts";
58
- import { registerWorktreeIsolation } from "../lib/worktree-isolation.ts";
76
+ import { cleanupWorktree, createWorktree, isGitRepo, keptWorktreeNote, type Worktree } from "./worktree.ts";
77
+ import { findProjectRoot } from "../lib/git.ts";
78
+ import { followEnteredWorktree, registerWorktreeIsolation } from "../lib/worktree-isolation.ts";
79
+ import { sessionWorkCwd } from "../lib/worktree-channel.ts";
59
80
  import {
60
81
  AGENT_NOTE,
61
82
  agentMessage,
@@ -80,14 +101,32 @@ import { recordUsage } from "../lib/usage-bus.ts";
80
101
  import { SUBAGENT_DEFAULT_CHANGED_CHANNEL } from "../lib/settings-channels.ts";
81
102
  import { SubagentWidget } from "./panel-widget.ts";
82
103
  import { type ProseRenderer, renderTranscript } from "./panel-render.ts";
83
- import { decodeStripKey, type StripKey } from "./panel-keys.ts";
84
- import { reduceShellKey } from "./shell-panel.ts";
104
+ import { decodeStripKey, editorYieldsDown, isStripEntryKey, type StripKey } from "./panel-keys.ts";
105
+ import { PERMISSION_STATUS_CHANNEL, type PermissionStatus } from "../permissions/modes.ts";
106
+ import { AGENT_VIEW_CHANNEL, type AgentViewAnnouncement, agentViewTarget, userMessageForAgent } from "../lib/agent-view.ts";
107
+ import { isKeyRelease, keyId, keyText } from "../lib/key-input.ts";
108
+ import { moveTasksSelection, renderShellDetail, renderTasksDialog, settleTasksView, shellDetailKey, tasksItems, type TasksView } from "./tasks-dialog.ts";
109
+ import { reduceShellKey, shellRows } from "./shell-panel.ts";
85
110
  import { trackShellTasks } from "../lib/shell-tasks.ts";
86
111
  import { createMarkdownProse } from "./prose.ts";
112
+ import { asScrollableViewport, ViewportMirror } from "./viewport-mirror.ts";
87
113
  import { registerLocalCommand } from "../lib/local-command.ts";
88
-
89
- /** The catalog shipped in this package: <package>/agents. */
90
- const BUNDLED_AGENTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "agents");
114
+ import { extendingCompletions } from "../lib/argument-hints.ts";
115
+ import { readStringsState, recordState } from "../lib/ledger/state.ts";
116
+ import { panelUi } from "../lib/panel-focus.ts";
117
+ import { dockRowsReporter, trackDockRows } from "../lib/dock-rows.ts";
118
+
119
+ /** How the guide's setup list names a server that is not connected. */
120
+ const MCP_STATUS_LABELS: Record<McpStatusKind, string | undefined> = {
121
+ connected: undefined,
122
+ failed: "failed to connect",
123
+ authNeeded: "needs authentication",
124
+ connecting: "connecting",
125
+ disabled: "disabled",
126
+ };
127
+
128
+ /** This package's root (the `one-code-extension` directory). */
129
+ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
91
130
 
92
131
  interface RunRequest {
93
132
  agent: string;
@@ -105,6 +144,8 @@ interface RunRequest {
105
144
  fallbackModel?: string;
106
145
  thinking?: string;
107
146
  worktree?: boolean;
147
+ /** Claude Code's `description`: the run's title (AgentRunRecord.description). */
148
+ description?: string;
108
149
  }
109
150
 
110
151
  /**
@@ -134,6 +175,8 @@ interface TaskResult {
134
175
  usage: UsageTotals;
135
176
  failed?: boolean;
136
177
  worktreePath?: string;
178
+ /** The kept worktree's branch, which holds any commits the agent made. */
179
+ worktreeBranch?: string;
137
180
  worktreeKept?: boolean;
138
181
  /** What the child did, for auto mode's return review. */
139
182
  actions?: ChildAction[];
@@ -143,12 +186,10 @@ const SubagentParams = Type.Object({
143
186
  subagent_type: Type.Optional(
144
187
  Type.String({
145
188
  description:
146
- 'The agent to run — a name from the "Available agents" system reminder, or "fork" to clone this conversation. Required unless action:"list". To run several in parallel, issue multiple Agent tool calls in one turn.',
189
+ 'The agent to run — a name from the "Available agents" system reminder (general-purpose when omitted), or "fork" to clone this conversation. To run several in parallel, issue multiple Agent tool calls in one turn.',
147
190
  }),
148
191
  ),
149
- task: Type.Optional(
150
- Type.String({ description: "The task — a complete, self-contained instruction (the agent cannot ask follow-ups). Required with subagent_type." }),
151
- ),
192
+ task: Type.String({ description: "The task — a complete, self-contained instruction (the agent cannot ask follow-ups)." }),
152
193
  // `prompt` is Claude Code's name for the task text and `description` its short
153
194
  // title. A CC-trained model writes `{subagent_type, description, prompt}`; pi
154
195
  // does not reject unknown keys, so without these the task silently became ""
@@ -186,7 +227,15 @@ const SubagentParams = Type.Object({
186
227
  ),
187
228
  });
188
229
 
189
- export const FORK_AGENT = "fork";
230
+
231
+ /** The Agent tool's schema inside a non-fork child: the nested run's options only. */
232
+ const NESTED_AGENT_PARAMS = Type.Object({
233
+ subagent_type: Type.Optional(Type.String({ description: "An agent name from the list in this tool's description (default: general-purpose)" })),
234
+ task: Type.String({ description: "The task — a complete, self-contained instruction" }),
235
+ prompt: Type.Optional(Type.String({ description: "Alias of `task` (Claude Code's name for it)." })),
236
+ description: Type.Optional(Type.String({ description: "A short (3-5 word) description of the task." })),
237
+ name: Type.Optional(Type.String({ description: "Name for this run (default: <agent>-<n>)" })),
238
+ });
190
239
 
191
240
  /**
192
241
  * The anti-fabrication sentence, carried by the tool RESULT of a background
@@ -218,10 +267,44 @@ interface Resident {
218
267
  * ahead of the report in its notification.
219
268
  */
220
269
  turnHandlers: Array<(outcome: ChildOutcome, review: HandBackVerdict | undefined, stopped: boolean) => void>;
270
+ /** Finished turns whose hand-back is still waiting on auto mode's review, so not yet delivered. */
271
+ reviewing: number;
272
+ }
273
+
274
+ /**
275
+ * Whether a resident has a report still to deliver: a turn is running, a turn's
276
+ * handler is queued, or a finished turn waits on its review. A resident that
277
+ * has delivered stays alive and idle for SendMessage; counting it as pending
278
+ * told a cheap-tier model to retract an answer the agent really gave
279
+ * (SUBAGENTS-WORKFLOWS-REVIEW-2026-09-26 M4).
280
+ */
281
+ function residentPending(resident: Resident): boolean {
282
+ return !resident.handle.exited() && (resident.handle.busy() || resident.turnHandlers.length > 0 || resident.reviewing > 0);
221
283
  }
222
284
 
285
+ /** The three capability blocks subagents owns on message 1. */
286
+ type SubagentBlockKey = "subagent-models" | "subagent-agents" | "subagent-delegation";
287
+ const BLOCK_ORDER: Record<SubagentBlockKey, number> = {
288
+ "subagent-models": CONTEXT_ORDER.subagentModels,
289
+ "subagent-agents": CONTEXT_ORDER.agents,
290
+ "subagent-delegation": CONTEXT_ORDER.delegation,
291
+ };
292
+ /** The `one-code:state` key of the models this account refused (lib/model-unusable.ts). */
293
+ const REFUSED_MODELS_STATE = "subagents:refused-models";
294
+
223
295
  export default function subagentsExtension(pi: ExtensionAPI) {
296
+ const requestTier = sessionModelTier(pi);
224
297
  const registry = new RunRegistry();
298
+ /**
299
+ * The session's permission mode as the permissions extension last announced
300
+ * it; auto (the shipped default) until the first announcement. It picks the
301
+ * hand-back's shape (notifyHandBack).
302
+ */
303
+ let permissionMode = "auto";
304
+ pi.events.on(PERMISSION_STATUS_CHANNEL, (data) => {
305
+ const mode = (data as Partial<PermissionStatus> | undefined)?.mode;
306
+ if (typeof mode === "string") permissionMode = mode;
307
+ });
225
308
  /**
226
309
  * Task ids with a blocking child (nested spawn or SendMessage resume) currently
227
310
  * running (SendMessage must wait for these). Keyed by task id, like `residents`:
@@ -231,6 +314,23 @@ export default function subagentsExtension(pi: ExtensionAPI) {
231
314
  const runningIds = new Set<string>();
232
315
  /** Task id → live resident. */
233
316
  const residents = new Map<string, Resident>();
317
+ /**
318
+ * Task id → the main session's tool call that started the run's current
319
+ * turn (its Agent call, or the latest SendMessage to it); a nested run
320
+ * answers with its parent's (`parentTaskOf`). The permission gate reads the
321
+ * main session only as far as that call (auto-mode/history.ts
322
+ * childHistoryCursor). Ids only, a few per run.
323
+ */
324
+ const mainCallFor = new Map<string, string>();
325
+ const parentTaskOf = new Map<string, string>();
326
+ const mainCallOf = (taskId: string): string | undefined => {
327
+ // Bounded: nesting is capped far below this, and a cycle must not hang the gate.
328
+ for (let id: string | undefined = taskId, depth = 0; id !== undefined && depth < 8; id = parentTaskOf.get(id), depth++) {
329
+ const call = mainCallFor.get(id);
330
+ if (call) return call;
331
+ }
332
+ return undefined;
333
+ };
234
334
  /**
235
335
  * Task ids of background runs spawned during the agent loop now in flight.
236
336
  * Cleared at `agent_start`; read at `agent_end` to notice a turn that ended
@@ -256,7 +356,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
256
356
  const stoppedTaskIds = new Set<string>();
257
357
  let lastCtx: ExtensionContext | undefined;
258
358
  const shellTasks = trackShellTasks(pi);
259
- const panel = new SubagentWidget(liveRuns, () => lastCtx, shellTasks);
359
+ const panel = new SubagentWidget(liveRuns, () => lastCtx, shellTasks, dockRowsReporter(pi.events, "subagents"));
360
+ // The other strips below the editor (workflow status rows), so the agent view leaves their rows free.
361
+ const dock = trackDockRows(pi.events);
260
362
 
261
363
  /**
262
364
  * Register a child in the live panel and return the wiring the spawn sites
@@ -273,6 +375,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
273
375
  liveRuns.register({
274
376
  taskId: record.taskId,
275
377
  name: record.name,
378
+ description: record.description,
276
379
  agentType: request.agent,
277
380
  model: request.model,
278
381
  thinking: request.thinking,
@@ -301,37 +404,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
301
404
 
302
405
  // Live MCP tool definitions published by the mcp extension; injected into
303
406
  // child sessions so subagents share the parent's open connections (no reconnect).
304
- // Empty when no MCP servers are configured, so this is a no-op for most sessions.
305
- // The parent's MCP connect runs in the background, so a spawn in the first
306
- // seconds of a session waits for the settled publish (capped, so a hung server
307
- // can't stall spawns) instead of baking in a still-connecting snapshot.
308
- const MCP_SETTLE_CAP_MS = 10_000;
309
- let mcpTools: ToolDefinition[] = [];
310
- let mcpSettled = false;
311
- let resolveMcpSettled: (() => void) | undefined;
312
- const mcpSettledPromise = new Promise<void>((resolve) => {
313
- resolveMcpSettled = resolve;
314
- });
315
- pi.events.on(MCP_TOOLS_CHANNEL, (data) => {
316
- const payload = data as McpToolsPayload | undefined;
317
- mcpTools = payload?.tools ?? [];
318
- if (payload?.settled) {
319
- mcpSettled = true;
320
- resolveMcpSettled?.();
321
- }
322
- });
323
- const awaitMcpTools = async (): Promise<ToolDefinition[]> => {
324
- if (!mcpSettled) {
325
- await Promise.race([
326
- mcpSettledPromise,
327
- new Promise<void>((resolve) => {
328
- const timer = setTimeout(resolve, MCP_SETTLE_CAP_MS);
329
- timer.unref?.();
330
- }),
331
- ]);
332
- }
333
- return mcpTools;
334
- };
407
+ const awaitMcpTools = watchMcpTools(pi.events);
335
408
 
336
409
  // The parent permissions extension's decision closure, used to gate a child's
337
410
  // tool calls through the real pipeline (mode inheritance, classifier, prompts
@@ -355,16 +428,30 @@ export default function subagentsExtension(pi: ExtensionAPI) {
355
428
  const rememberUnusable = (model: string) => {
356
429
  if (unusableModels.has(model)) return false;
357
430
  unusableModels.add(model);
431
+ // A refusal is a durable fact of the account, not UI prose: a resumed
432
+ // session must not pick the same model and announce it again.
433
+ recordState(pi, REFUSED_MODELS_STATE, [...unusableModels]);
358
434
  return true;
359
435
  };
360
436
  pi.events.on(MODEL_UNUSABLE_CHANNEL, (data) => {
361
437
  // The default may have been that model: recompute and republish the
362
438
  // reminder/banner so the next request already names the replacement.
363
439
  const event = data as ModelUnusableEvent;
364
- if (rememberUnusable(event.model) && lastCtx) emitModelStatus(lastCtx);
440
+ if (rememberUnusable(event.model) && lastCtx) emitModelStatus(lastCtx, lastCtx.model, event.model);
365
441
  });
366
442
 
367
- /** The in-process runner, built lazily on first run and shared across all runs. */
443
+ // After `enter_worktree` the session works in the worktree, but pi keeps
444
+ // ctx.cwd at the original checkout: spawns start where the session works.
445
+ const enteredWorktree = followEnteredWorktree(pi.events);
446
+ const workCwd = (ctx: ExtensionContext) => sessionWorkCwd(enteredWorktree(), ctx.cwd);
447
+
448
+ /**
449
+ * The in-process runner, built lazily on first run and shared across all runs.
450
+ * A failed build (a malformed models.json or auth.json) is forgotten, so the
451
+ * next spawn tries again instead of every later spawn failing on the same
452
+ * rejected promise; callers await it before they mark a run as running
453
+ * (SUBAGENTS-WORKFLOWS-REVIEW-2026-09-26 L8).
454
+ */
368
455
  let runtimePromise: Promise<SubagentRuntime> | undefined;
369
456
  const getRuntime = (ctx: ExtensionContext) =>
370
457
  (runtimePromise ??= SubagentRuntime.create(
@@ -386,19 +473,94 @@ export default function subagentsExtension(pi: ExtensionAPI) {
386
473
  pi.events.emit(MODEL_UNUSABLE_CHANNEL, { model, reason } satisfies ModelUnusableEvent);
387
474
  const live = liveUiCtx(lastCtx);
388
475
  live?.ui.notify(`Subagent model ${model} is not usable on this account (${reason}); automatic selection skips it from now on.`, "warning");
389
- if (lastCtx) emitModelStatus(lastCtx);
476
+ if (lastCtx) emitModelStatus(lastCtx, lastCtx.model, model);
477
+ },
478
+ (path, agent) => pi.events.emit(CHILD_WROTE_CHANNEL, { path, ...(agent ? { agent } : {}) } satisfies ChildWrote),
479
+ () => queryDeferred(pi.events),
480
+ // The mode a fork's calls are checked in, read live at each of its requests (parent-mode.ts).
481
+ () => permissionMode,
482
+ ).catch((error: unknown) => {
483
+ runtimePromise = undefined;
484
+ throw error;
485
+ }));
486
+ /** The runtime, or the startup error as a message a failed run can report. */
487
+ const runtimeOrError = (ctx: ExtensionContext): Promise<SubagentRuntime | string> =>
488
+ getRuntime(ctx)
489
+ .then((runtime) => shuttingDown ? "the session ended before the agent started" : runtime)
490
+ .catch((error: unknown) => `could not start the subagent runtime: ${error instanceof Error ? error.message : String(error)}`);
491
+
492
+ // The mcp extension answers a status request synchronously on the bus.
493
+ let mcpStatus: McpServerStatus[] | undefined;
494
+ pi.events.on(MCP_STATUS_CHANNEL, (data) => {
495
+ mcpStatus = (data as McpStatusEvent).servers;
496
+ });
497
+ /** The configured servers now; empty when the mcp extension is not loaded. */
498
+ const requestMcpStatus = (): McpServerStatus[] => {
499
+ mcpStatus = undefined;
500
+ pi.events.emit(MCP_STATUS_REQUEST_CHANNEL, {});
501
+ return (mcpStatus as McpServerStatus[] | undefined) ?? [];
502
+ };
503
+
504
+ /**
505
+ * What the code-defined `one-code-guide`'s prompt lists (guide-agent.ts): the
506
+ * user's custom skills and agents, enabled plugins, MCP servers, installed pi
507
+ * packages and extensions, and pi's settings keys. Read only when a spawn
508
+ * reads the guide's prompt.
509
+ */
510
+ const guideInput = (cwd: string, catalog: readonly AgentDefinition[], plugins: ReturnType<typeof discoverPlugins>): GuideInput => {
511
+ const agentDir = getAgentDir();
512
+ let settingsText: string | undefined;
513
+ try {
514
+ settingsText = readFileSync(join(agentDir, "settings.json"), "utf-8");
515
+ } catch {
516
+ // No settings file yet: no packages or keys to list.
517
+ }
518
+ let extensions: string[] = [];
519
+ try {
520
+ extensions = readdirSync(join(agentDir, "extensions")).filter((entry) => !entry.startsWith("."));
521
+ } catch {
522
+ // No user extensions directory.
523
+ }
524
+ // Every configured server, from the mcp extension's status snapshot: a
525
+ // failed, disabled or resource-only server registers no tools.
526
+ const mcpServers = requestMcpStatus().map((server) => ({ name: server.name, status: MCP_STATUS_LABELS[server.status] }));
527
+ return {
528
+ docs: guideDocs(),
529
+ install: {
530
+ shape: process.env.ONECODE_INSTALL_METHOD ? "app" : "extension",
531
+ method: process.env.ONECODE_INSTALL_METHOD,
532
+ agentDir,
533
+ version: process.env.CC_VERSION ?? extensionVersion(),
534
+ },
535
+ setup: {
536
+ skills: scanSkills(cwd, os.homedir(), agentDir, plugins.skills).map((skill) => skill.name),
537
+ agents: catalog.filter((agent) => agent.source !== "built-in" && !agent.source.startsWith(BUNDLED_AGENTS_DIR)).map((agent) => agent.name),
538
+ plugins: plugins.enabledPlugins.map((plugin) => plugin.name),
539
+ mcpServers,
540
+ extensions,
541
+ ...settingsSetup(settingsText, PACKAGE_ROOT),
390
542
  },
391
- ));
392
-
393
- const loadAgents = (cwd: string) => {
394
- // Plugin agents sit between bundled and user definitions, and are exposed
395
- // namespaced (`<plugin>:<agent>`) so two plugins can ship the same name.
396
- const sources: Array<string | AgentSource> = [
397
- BUNDLED_AGENTS_DIR,
398
- ...discoverPlugins(defaultDiscoverRoots(getAgentDir(), cwd)).agentDirs,
399
- ...agentDirs(cwd, os.homedir()),
400
- ];
401
- return discoverAgents(sources);
543
+ };
544
+ };
545
+
546
+ // Plugin agents sit between bundled and user definitions, and are exposed
547
+ // namespaced (`<plugin>:<agent>`) so two plugins can ship the same name.
548
+ const agentSources = (cwd: string) => {
549
+ const plugins = discoverPlugins(defaultDiscoverRoots(getAgentDir(), cwd));
550
+ return { plugins, fileSources: agentFileSources(cwd, os.homedir(), plugins.agentDirs) };
551
+ };
552
+ type AgentSources = ReturnType<typeof agentSources>;
553
+
554
+ /** `sources` when the caller already resolved them (the catalog stamp). */
555
+ const loadAgents = (cwd: string, sources: AgentSources = agentSources(cwd)) => {
556
+ // The code-defined guide ranks below every agent file, so one of the same
557
+ // name replaces it.
558
+ const { plugins, fileSources } = sources;
559
+ // The prompt is built from the catalog it sits in, when a spawn reads it.
560
+ let catalog: AgentDefinition[] = [];
561
+ const guide = guideAgentDefinition(() => guideInput(cwd, catalog, plugins));
562
+ catalog = discoverAgents([{ agents: [guide] }, ...fileSources]);
563
+ return catalog;
402
564
  };
403
565
 
404
566
  // Claude Code closes every catalog row with a tools clause — "(Tools: *)",
@@ -408,12 +570,15 @@ export default function subagentsExtension(pi: ExtensionAPI) {
408
570
  const toolsClause = (a: AgentDefinition): string => {
409
571
  if (a.tools && a.tools.length > 0) return ` (Tools: ${a.tools.join(", ")})`;
410
572
  if (a.excludeTools && a.excludeTools.length > 0) return ` (Tools: All tools except ${a.excludeTools.join(", ")})`;
411
- return " (Tools: *)";
573
+ // Claude Code writes "*" for its built-in agents and "All tools" for an agent file that lists none.
574
+ return a.source === "built-in" || a.source.startsWith(BUNDLED_AGENTS_DIR) ? " (Tools: *)" : " (Tools: All tools)";
412
575
  };
413
576
 
414
- const describeAgents = (cwd: string) => {
415
- const agents = loadAgents(cwd);
416
- const lines = agents.map((a) => `- ${a.name}: ${a.description || "(no description)"}${toolsClause(a)}`);
577
+ /** One catalog row per agent, the shape every catalog the model reads uses. */
578
+ const catalogLines = (agents: AgentDefinition[]): string[] => agents.map((a) => `- ${a.name}: ${a.description || "(no description)"}${toolsClause(a)}`);
579
+
580
+ const describeAgents = (cwd: string, sources?: AgentSources) => {
581
+ const lines = catalogLines(loadAgents(cwd, sources));
417
582
  lines.push(`- ${FORK_AGENT}: clone this conversation, with its full context, to work on a task in parallel`);
418
583
  return lines.join("\n");
419
584
  };
@@ -435,14 +600,14 @@ export default function subagentsExtension(pi: ExtensionAPI) {
435
600
  // A kept worktree can host a resumed run in a NEW process, where
436
601
  // createWorktree's registration no longer exists — re-register so the
437
602
  // git-isolation guard survives the restart. A removed worktree's entry
438
- // is inert (no session runs there any more). findGitRoot instead of
603
+ // is inert (no session runs there any more). findProjectRoot instead of
439
604
  // `git rev-parse` because session_start handlers must stay fast
440
605
  // (findings §15); a slightly-off sharedRoot only softens one message —
441
606
  // the worktree containment check itself uses the exact record.cwd.
442
607
  if (record.worktree && !worktreesSeen.has(record.cwd)) {
443
608
  worktreesSeen.add(record.cwd);
444
609
  if (!existsSync(record.cwd)) continue;
445
- sharedRoot ??= findGitRoot(ctx.cwd) ?? ctx.cwd;
610
+ sharedRoot ??= findProjectRoot(ctx.cwd) ?? ctx.cwd;
446
611
  registerWorktreeIsolation(record.cwd, sharedRoot);
447
612
  }
448
613
  }
@@ -486,63 +651,97 @@ export default function subagentsExtension(pi: ExtensionAPI) {
486
651
  return resolution;
487
652
  };
488
653
 
654
+ // The three blocks are live state: what an epoch's message 1 says. Inside an
655
+ // epoch message 1 is the record (extensions/ledger), so a refused model, a
656
+ // /model or a /subagent change rides the request's tail, decided at render
657
+ // time from the text the epoch last told, never a rewrite of the menu that
658
+ // would re-cache the conversation and drop its earlier thinking (findings §46).
659
+ /** The live text of each block; null when the block is absent. */
660
+ const liveBlocks: Record<SubagentBlockKey, string | null> = { "subagent-models": null, "subagent-agents": null, "subagent-delegation": null };
661
+ /** A model the account refused since the menu was last told: its narrow correction stands in for the whole menu. */
662
+ let refusal: { refused: string[]; replacement: Model<Api> | undefined } | undefined;
663
+ const publishBlock = (key: SubagentBlockKey, text: string | null) => {
664
+ liveBlocks[key] = text;
665
+ if (text === null) pi.events.emit(REMINDER_CHANNEL, { key, remove: true });
666
+ else pi.events.emit(REMINDER_CHANNEL, { text, key, placement: "first-prepend", order: BLOCK_ORDER[key], facts: [toldBlockFact(key, text)] });
667
+ };
668
+ pi.events.on(RENDER_CHANNEL, (data) => {
669
+ const request = data as RenderRequest;
670
+ if (request.epoch) {
671
+ refusal = undefined;
672
+ return;
673
+ }
674
+ for (const key of Object.keys(liveBlocks) as SubagentBlockKey[]) {
675
+ const text = liveBlocks[key];
676
+ // An absent block is told as its empty text; one never told needs no withdrawal.
677
+ const told = request.toldBlocks.get(key);
678
+ const fact = toldBlockFact(key, text ?? "");
679
+ if (told === fact.hash || (text === null && told === undefined)) continue;
680
+ const facts = [fact];
681
+ if (text === null) request.blocks.push({ text: "The subagent delegation guidance previously shown for this conversation is no longer active.", facts });
682
+ else if (key === "subagent-models" && refusal) request.blocks.push({ text: refusal.refused.map((model) => refusedModelNote(model, refusal?.replacement)).join("\n"), facts });
683
+ else request.blocks.push({ text, facts });
684
+ }
685
+ refusal = undefined;
686
+ });
687
+
489
688
  /**
490
689
  * The every-turn menu reminder and the banner's subagent-default status.
491
- * Every-turn because reminders are transient per-request injections — that
492
- * scope survives compaction by construction — and keyed so a model change
493
- * replaces it: the very next LLM call, even mid-turn, carries the update.
690
+ * `refused` names a model the account just refused: inside an epoch that
691
+ * change rides a narrow correction instead of the whole menu.
494
692
  */
495
- const emitModelStatus = (ctx: ExtensionContext, sessionModel = ctx.model) => {
693
+ const modelMenu = (ctx: ExtensionContext, sessionModel = ctx.model) => {
496
694
  const available = usableModels(ctx);
497
695
  const configured = applicableSubagentDefault(loadSubagentDefault(os.homedir()), sessionModel);
498
696
  const resolution = resolveAutoDefault(sessionModel, available, configured);
499
- for (const notice of resolution.notices) notifyModelOnce(ctx, notice);
500
- pi.events.emit(REMINDER_CHANNEL, {
501
- text: subagentModelsReminder({
502
- available,
503
- sessionModel,
504
- defaultModel: resolution.model,
505
- defaultSource: resolution.source,
506
- configured,
507
- requireImageInput: supportsImageInput(sessionModel),
508
- }),
509
- scope: "every-turn",
510
- key: "subagent-models",
511
- placement: "first-prepend",
512
- order: CONTEXT_ORDER.subagentModels,
697
+ const text = subagentModelsReminder({
698
+ available, sessionModel, defaultModel: resolution.model, defaultSource: resolution.source, configured,
699
+ requireImageInput: supportsImageInput(sessionModel),
513
700
  });
701
+ return { available, configured, resolution, text };
702
+ };
703
+ const emitModelStatus = (ctx: ExtensionContext, sessionModel = ctx.model, refused?: string) => {
704
+ const { available, configured, resolution, text } = modelMenu(ctx, sessionModel);
705
+ for (const notice of resolution.notices) notifyModelOnce(ctx, notice);
706
+ // The user's own subagentModel setting is honoured, so say what it costs:
707
+ // a newer model in its line, or a model far weaker than the session's.
708
+ if (resolution.source === "default" && resolution.model) {
709
+ for (const warning of chosenModelWarnings({ available, sessionModel, chosen: resolution.model, role: "subagent", suggestNewer: readSuggestNewerModels(os.homedir()) })) {
710
+ notifyModelOnce(ctx, `${warning.text} ${warning.fix}`);
711
+ }
712
+ }
713
+ // Every refusal since the menu was last told, each named, with the default as it is now.
714
+ if (refused !== undefined) refusal = { refused: [...(refusal?.refused ?? []).filter((model) => model !== refused), refused], replacement: resolution.model };
715
+ else if (refusal) refusal = { ...refusal, replacement: resolution.model };
716
+ publishBlock("subagent-models", text);
514
717
  pi.events.emit(SUBAGENT_STATUS_CHANNEL, subagentStatusModel(configured, resolution));
515
718
  };
516
719
 
720
+ /** The last catalog text and the stamp of the agent files it was built from. */
721
+ let catalogMemo: { stamp: string; text: string } | undefined;
517
722
  /**
518
723
  * The agent catalog as an every-turn reminder, as Claude Code does ("Available
519
724
  * agent types are listed in <system-reminder> messages") — without it the
520
- * model has to guess names or make a discovery call first. Keyed and
521
- * byte-stable within a session, so it costs nothing in prompt-cache terms.
725
+ * model has to guess names or make a discovery call first. Rebuilt only when
726
+ * the agent files changed: every run start asks, and stating the files is
727
+ * cheaper than reading and parsing each one. The sources are resolved once
728
+ * per check (the plugin discovery behind them is a process-wide memo), and a
729
+ * rebuild reads the same ones the stamp stated.
522
730
  */
523
- const emitAgentCatalog = (ctx: ExtensionContext) => {
524
- pi.events.emit(REMINDER_CHANNEL, {
525
- scope: "every-turn",
526
- key: "subagent-agents",
527
- text: `Available agent types for the Agent tool:\n${describeAgents(ctx.cwd)}\n\nWhen you launch multiple agents for independent work, send them in a single message with multiple tool uses so they run concurrently.`,
528
- placement: "first-prepend",
529
- order: CONTEXT_ORDER.agents,
530
- });
531
- };
532
-
533
- const emitDelegationSteer = (sessionModel = lastCtx?.model) => {
534
- if (resolveModelTier(sessionModel) === "tiny") {
535
- pi.events.emit(REMINDER_CHANNEL, {
536
- scope: "every-turn",
537
- key: "subagent-delegation",
538
- text: DELEGATION_STEER,
539
- placement: "first-prepend",
540
- order: CONTEXT_ORDER.delegation,
541
- });
542
- } else {
543
- pi.events.emit(REMINDER_CHANNEL, { key: "subagent-delegation", remove: true });
731
+ const agentCatalog = (ctx: ExtensionContext) => {
732
+ const sources = agentSources(ctx.cwd);
733
+ const stamp = `${ctx.cwd}\n${agentSourcesStamp(sources.fileSources)}`;
734
+ if (catalogMemo?.stamp !== stamp) {
735
+ catalogMemo = {
736
+ stamp,
737
+ text: `Available agent types for the Agent tool:\n${describeAgents(ctx.cwd, sources)}\n\nWhen you launch multiple agents for independent work, send them in a single message with multiple tool uses so they run concurrently.`,
738
+ };
544
739
  }
740
+ return catalogMemo.text;
545
741
  };
742
+ const emitAgentCatalog = (ctx: ExtensionContext) => publishBlock("subagent-agents", agentCatalog(ctx));
743
+
744
+ const emitDelegationSteer = () => publishBlock("subagent-delegation", requestTier() === "tiny" ? DELEGATION_STEER : null);
546
745
 
547
746
  let shuttingDown = false;
548
747
 
@@ -552,10 +751,15 @@ export default function subagentsExtension(pi: ExtensionAPI) {
552
751
  * would get the reminder on every delegating turn for nothing, and the
553
752
  * trigger fires on correct behaviour as readily as on the failure.
554
753
  */
555
- const backstopApplies = (ctx: ExtensionContext | undefined) => {
556
- const tier = resolveModelTier(ctx?.model);
754
+ const backstopApplies = () => {
755
+ const tier = requestTier();
557
756
  return tier === "cheap" || tier === "tiny";
558
757
  };
758
+ /** A run that is still working: a live child, or a resident with a turn pending. */
759
+ const stillRunning = (taskId: string): boolean => {
760
+ const resident = residents.get(taskId);
761
+ return (resident !== undefined && residentPending(resident)) || runningIds.has(taskId);
762
+ };
559
763
  pi.on("agent_start", () => {
560
764
  spawnedThisLoop.clear();
561
765
  parentBusy = true;
@@ -564,17 +768,14 @@ export default function subagentsExtension(pi: ExtensionAPI) {
564
768
  pi.on("agent_settled", () => {
565
769
  parentBusy = false;
566
770
  });
567
- pi.on("agent_end", (_event, ctx) => {
771
+ pi.on("agent_end", () => {
568
772
  const spawned = [...spawnedThisLoop];
569
773
  spawnedThisLoop.clear();
570
774
  // Tier check first: on a frontier session the backstop never fires, so the
571
775
  // lookups below would be discarded work on every turn that delegated.
572
- if (!backstopApplies(ctx)) return undefined;
776
+ if (!backstopApplies()) return undefined;
573
777
  const pending = spawned
574
- .filter((taskId) => {
575
- const resident = residents.get(taskId);
576
- return (resident !== undefined && !resident.handle.exited()) || runningIds.has(taskId);
577
- })
778
+ .filter(stillRunning)
578
779
  .map((taskId) => registry.resolve(taskId))
579
780
  .filter((record): record is AgentRunRecord => record !== undefined)
580
781
  .map((record) => ({ name: record.name, taskId: record.taskId }));
@@ -584,6 +785,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
584
785
  });
585
786
 
586
787
  pi.on("session_start", (_event, ctx) => {
788
+ // The account's refusals follow the branch: restore the selection floor
789
+ // before building the menu, or this process picks the refused model again.
790
+ for (const model of readStringsState(ctx, REFUSED_MODELS_STATE)) unusableModels.add(model);
587
791
  lastCtx = ctx;
588
792
  // A replaced session (/clear, /new, /resume) never reaches this instance
589
793
  // with the old one's agents: pi emits `session_shutdown` (reason "new" /
@@ -594,12 +798,12 @@ export default function subagentsExtension(pi: ExtensionAPI) {
594
798
  reconstructRuns(ctx);
595
799
  emitModelStatus(ctx);
596
800
  emitAgentCatalog(ctx);
597
- emitDelegationSteer(ctx.model);
801
+ emitDelegationSteer();
598
802
  registerPanelInputHook(ctx);
599
803
  });
600
804
  pi.on("model_select", (event, ctx) => {
601
805
  emitModelStatus(ctx, event.model);
602
- emitDelegationSteer(event.model);
806
+ emitDelegationSteer();
603
807
  });
604
808
  // Another extension wrote `subagentModel` on disk (`/doctor preset`): drop the
605
809
  // cached automatic default and republish the reminder + banner status, the
@@ -609,7 +813,55 @@ export default function subagentsExtension(pi: ExtensionAPI) {
609
813
  const live = liveUiCtx(lastCtx);
610
814
  if (live) emitModelStatus(live);
611
815
  });
612
- pi.on("session_tree", (_event, ctx) => reconstructRuns(ctx));
816
+ /**
817
+ * The main conversation's runs still going at the last compaction, and the
818
+ * compaction entry they belong to: named on the next request, as far as
819
+ * they are still going then.
820
+ */
821
+ let runningAtCompaction: { entryId: string | undefined; taskIds: string[] } | undefined;
822
+ // A compaction opens an epoch, which renders the three blocks from live state:
823
+ // refresh them, so a catalog that changed on disk since is current. The calls
824
+ // that started the background runs went into the summary, so the runs still
825
+ // going are named once on the new epoch's first request. Only the main
826
+ // conversation's own runs: a nested run reports to the agent that spawned it.
827
+ pi.on("session_compact", (event, ctx) => {
828
+ emitModelStatus(ctx);
829
+ emitAgentCatalog(ctx);
830
+ emitDelegationSteer();
831
+ const taskIds = [...registry.list()].filter((record) => (record.depth ?? 0) === 0 && stillRunning(record.taskId)).map((record) => record.taskId);
832
+ runningAtCompaction = taskIds.length > 0 ? { entryId: event?.compactionEntry?.id, taskIds } : undefined;
833
+ });
834
+ // Named when the request is built, not at the compaction: the next request can
835
+ // come much later (a manual /compact waits for the next prompt), and a run that
836
+ // finished meanwhile is not running. The run's own notification says it ended.
837
+ // A /tree to a branch without that compaction still shows the calls.
838
+ pi.on("turn_start", (_event, ctx) => {
839
+ const owed = runningAtCompaction;
840
+ if (!owed) return;
841
+ runningAtCompaction = undefined;
842
+ if (owed.entryId !== undefined && !ctx.sessionManager.getBranch().some((entry) => entry.id === owed.entryId)) return;
843
+ const going = owed.taskIds
844
+ .filter(stillRunning)
845
+ .map((taskId) => registry.resolve(taskId))
846
+ .filter((record): record is AgentRunRecord => record !== undefined)
847
+ .map((record) => ({ name: record.name, taskId: record.taskId }));
848
+ const text = runningAfterCompactionReminder(going);
849
+ if (text) pi.events.emit(REMINDER_CHANNEL, { text, key: "subagents-running-after-compaction", untilCompaction: true });
850
+ });
851
+ // The catalog is read from disk at each run's start, so an agent file added
852
+ // or deleted mid-session reaches the model: the render step announces a
853
+ // changed catalog, live minus what the epoch told (R3-M28).
854
+ pi.on("before_agent_start", (_event, ctx) => {
855
+ emitAgentCatalog(ctx);
856
+ });
857
+ // The selected branch's runs: the abandoned branch's finished ones are not
858
+ // addressable from it (SendMessage, list_agents), as on a resume. A run still
859
+ // going, a resident between turns and a nested run (recorded only in memory)
860
+ // stay reachable, and so do names held for spawns in flight.
861
+ pi.on("session_tree", (_event, ctx) => {
862
+ registry.retain((record) => liveRuns.get(record.taskId)?.status === "running" || residents.has(record.taskId) || parentTaskOf.has(record.taskId));
863
+ reconstructRuns(ctx);
864
+ });
613
865
  pi.on("session_shutdown", async () => {
614
866
  // Teardown — at process exit AND on /clear, /new, /resume (pi replaces the
615
867
  // session and re-runs this factory; afterwards every use of this
@@ -703,13 +955,29 @@ export default function subagentsExtension(pi: ExtensionAPI) {
703
955
  * back.
704
956
  */
705
957
  let view: { retarget(taskId: string): void; scrollBy(delta: number): void; close(): void } | undefined;
706
- /** Rows kept clear at the bottom: token counter + editor(3) + mode line + strip hint + strip rows + shell section + cwd/status(2) + one spare. */
958
+ /** Rows kept clear at the bottom: token counter + editor(3) + mode line + strip hint + strip rows + shell section + other strips + cwd/status(2) + one spare. */
707
959
  const viewReserve = () => {
708
960
  const shellLines = panel.shellLineCount();
709
- return panel.rowCount() + (shellLines ? shellLines + 1 : 0) + 9;
961
+ return panel.rowCount() + (shellLines ? shellLines + 1 : 0) + dock.others("subagents") + 9;
962
+ };
963
+
964
+ /**
965
+ * Tell the editor which agent it now addresses (lib/agent-view.ts), or that
966
+ * it is back on main. Viewing a run also puts it on `/tasks`' Completed list
967
+ * once it settles, as Claude Code keeps a viewed agent (findings §40).
968
+ */
969
+ const announceView = (taskId: string | undefined) => {
970
+ const run = taskId === undefined ? undefined : liveRuns.get(taskId);
971
+ if (run) liveRuns.markViewed(run.taskId);
972
+ const announcement: AgentViewAnnouncement = run ? { target: agentViewTarget(run, run.agentType === FORK_AGENT) } : {};
973
+ pi.events.emit(AGENT_VIEW_CHANNEL, announcement);
710
974
  };
711
975
 
712
976
  const openView = (ctx: ExtensionContext, taskId: string) => {
977
+ if (ctx.mode !== "tui") {
978
+ ctx.ui.notify("The live subagent panel requires TUI mode. Use /tasks for task status; task_output and task_stop inspect or stop a task.", "warning");
979
+ return;
980
+ }
713
981
  if (view) return view.retarget(taskId);
714
982
  let currentId = taskId;
715
983
  let scroll = 0;
@@ -717,12 +985,16 @@ export default function subagentsExtension(pi: ExtensionAPI) {
717
985
  let closed = false;
718
986
  let repaintFn: (() => void) | undefined;
719
987
  let doneFn: ((result: null) => void) | undefined;
988
+ /** Fullscreen only: turns pi's own viewport scrolling into view scrolling. */
989
+ let mirror: ViewportMirror | undefined;
720
990
  const entry = {
721
991
  retarget(id: string) {
722
992
  if (currentId === id) return;
723
993
  currentId = id;
724
994
  scroll = 0;
995
+ mirror?.park(0);
725
996
  panel.setView(id);
997
+ announceView(id);
726
998
  repaintFn?.();
727
999
  },
728
1000
  scrollBy(delta: number) {
@@ -733,22 +1005,26 @@ export default function subagentsExtension(pi: ExtensionAPI) {
733
1005
  if (closed) return;
734
1006
  closed = true;
735
1007
  if (view === entry) view = undefined;
1008
+ mirror?.restore();
1009
+ mirror = undefined;
736
1010
  panel.setView(undefined);
1011
+ announceView(undefined);
737
1012
  doneFn?.(null);
738
1013
  },
739
1014
  };
740
1015
  view = entry;
741
1016
  panel.setView(currentId);
1017
+ announceView(currentId);
742
1018
  void (async () => {
743
1019
  const prose = await getProse(ctx);
744
1020
  if (closed) return;
745
- await ctx.ui.custom<null>(
1021
+ await panelUi(pi.events, ctx.ui).custom<null>(
746
1022
  (tui, theme, _keybindings, done) => {
747
1023
  doneFn = done;
748
1024
  // close() raced ahead of the factory — dissolve as soon as we exist.
749
1025
  if (closed) queueMicrotask(() => done(null));
750
1026
  const paint = { fg: safeThemePaint(theme), bold: safeThemeBold(theme) };
751
- let cache: { width: number; lines: string[] } | undefined;
1027
+ let cache: { width: number; height: number; lines: string[] } | undefined;
752
1028
  const live = liveRepaint(
753
1029
  tui,
754
1030
  () => {
@@ -758,19 +1034,32 @@ export default function subagentsExtension(pi: ExtensionAPI) {
758
1034
  );
759
1035
  repaintFn = live.repaint;
760
1036
  const height = () => Math.max(8, tui.terminal.rows - viewReserve());
1037
+ const viewport = asScrollableViewport(tui);
1038
+ if (viewport && !closed) mirror = new ViewportMirror(viewport);
761
1039
  return {
762
1040
  render: (width: number) => {
763
- if (cache?.width === width) return cache.lines;
1041
+ // The page keys and the wheel scroll pi's main viewport, under
1042
+ // this overlay; take the move as ours and park main again.
1043
+ if (mirror) {
1044
+ const next = mirror.absorb(scroll, maxScroll);
1045
+ if (next !== scroll) {
1046
+ scroll = next;
1047
+ cache = undefined;
1048
+ }
1049
+ }
1050
+ // Keyed by height too: a resize or another strip changing size moves the room.
1051
+ const viewHeight = height();
1052
+ if (cache?.width === width && cache.height === viewHeight) return cache.lines;
764
1053
  const run = liveRuns.get(currentId);
765
1054
  let lines: string[];
766
1055
  if (run) {
767
- const transcript = renderTranscript({ run, width, height: height(), scroll, now: Date.now(), prose }, paint);
1056
+ const transcript = renderTranscript({ run, width, height: viewHeight, scroll, now: Date.now(), prose }, paint);
768
1057
  maxScroll = transcript.maxScroll;
769
1058
  lines = transcript.lines;
770
1059
  } else {
771
1060
  lines = [paint.fg("dim", "agent gone")];
772
1061
  }
773
- cache = { width, lines: lines.map((line) => truncateLine(line, width)) };
1062
+ cache = { width, height: viewHeight, lines: lines.map((line) => truncateLine(line, width)) };
774
1063
  return cache.lines;
775
1064
  },
776
1065
  invalidate: () => {
@@ -790,10 +1079,12 @@ export default function subagentsExtension(pi: ExtensionAPI) {
790
1079
  if (panelHookRegistered || !registerCtx.hasUI) return;
791
1080
  panelHookRegistered = true;
792
1081
  let chordArmed = false;
1082
+ // Leaving the strip hands focus back to the editor and keeps any open view,
1083
+ // as Claude Code's Esc does: the editor then addresses the viewed agent.
793
1084
  const leave = () => {
794
1085
  panel.setFocus(undefined);
795
- closeView();
796
1086
  };
1087
+ const editorEmpty = () => (panel.editorBaseline as { getText?(): string } | undefined)?.getText?.() === "";
797
1088
  type KeyResult = { consume: boolean } | undefined;
798
1089
  // Actions shared by both key modes (browse and read), declared once so the
799
1090
  // two switches below never drift. esc consumes only when idle — a streaming
@@ -814,16 +1105,15 @@ export default function subagentsExtension(pi: ExtensionAPI) {
814
1105
  return { consume: true };
815
1106
  };
816
1107
  /** Unrecognized key: ctrl+x keeps the stop-all chord armed, else drop focus
817
- * (closing any open view) and let the byte resume in the editor. */
1108
+ * (any open view stays) and let the byte resume in the editor. */
818
1109
  const closeAndPassthrough = (): KeyResult => {
819
1110
  if (chordArmed) return { consume: true };
820
1111
  leave();
821
1112
  return undefined;
822
1113
  };
823
- const DOWN_KEYS = new Set(["\x1b[B", "\x1bOB"]);
824
- /** Keys the agents branch owns; any other decode (typing, shell-only keys
825
- * like left/space) drops focus and passes through — stated positively so a
826
- * future StripKey addition is foreign here by default. */
1114
+ /** Keys the agents branch owns; any other decode (typing) drops focus and
1115
+ * passes through — stated positively so a future StripKey addition is
1116
+ * foreign here by default. */
827
1117
  const AGENT_KEYS = new Set<StripKey>(["up", "down", "open", "leave", "stop", "stopAll", "pageUp", "pageDown"]);
828
1118
  /** Row 0 exists only past the synthetic `main` row. */
829
1119
  const focusFirstAgentRow = (): boolean => {
@@ -831,54 +1121,46 @@ export default function subagentsExtension(pi: ExtensionAPI) {
831
1121
  panel.setFocus(0);
832
1122
  return true;
833
1123
  };
834
- /** The shell-manager stages (chip → list → details); agents keys untouched. */
1124
+ /** The focused shells chip; agents keys untouched. */
835
1125
  const handleShellKey = (data: string, ctx: ExtensionContext): KeyResult => {
836
- // Focus moved to a dialog/overlay — drop soft focus, let it have the key.
837
- if (!panel.editorFocused()) {
838
- panel.setShellFocus(undefined);
1126
+ // Focus moved to a dialog/overlay, or the chip cleared under us: drop
1127
+ // soft focus and let the key through.
1128
+ if (!panel.editorFocused() || !panel.shellChipAvailable()) {
1129
+ panel.setShellChipFocus(false);
839
1130
  return undefined;
840
1131
  }
841
- // Derive the row order once per keystroke; every step below reuses it.
842
- const ids = panel.shellIds();
843
- const focus = panel.anchoredShellFocus(ids);
844
- if (!focus) return undefined;
845
1132
  const decoded = decodeStripKey(data, false);
846
- // ctrl+x (the agents stop-all chord arm) means nothing in the shell
847
- // stages — swallow it rather than leaking the raw \x18 into the editor.
1133
+ // ctrl+x (the agents stop-all chord arm) means nothing on the chip:
1134
+ // swallow it rather than leaking the raw \x18 into the editor.
848
1135
  if (decoded.chordArmed) return { consume: true };
849
- const result = reduceShellKey(focus, decoded.key, ids);
850
- if (result.effect === "passthrough") {
851
- panel.setShellFocus(undefined);
852
- return undefined; // typing resumes in the editor, byte included
853
- }
854
- if (result.effect === "toAgents") {
855
- panel.setShellFocus(undefined);
1136
+ const effect = reduceShellKey(decoded.key);
1137
+ panel.setShellChipFocus(false);
1138
+ if (effect === "passthrough") return undefined; // typing resumes in the editor, byte included
1139
+ if (effect === "toAgents") {
856
1140
  if (focusFirstAgentRow()) return { consume: true };
857
1141
  // No agent rows below the chip: stay on it. Dropping focus here handed
858
1142
  // the NEXT key to the editor, where ↑ recalls the last prompt — the
859
1143
  // "sometimes ↓ recalls history" seen on the Windows VM (2026-09-19):
860
1144
  // ↓ chip, ↓ (nothing below, focus silently gone), ↑ → history.
861
- panel.setShellFocus({ stage: "chip" });
1145
+ panel.setShellChipFocus(true);
862
1146
  return { consume: true };
863
1147
  }
864
- if (result.effect === "stopSelected") {
865
- try {
866
- panel.selectedShellTask()?.stop();
867
- } catch {
868
- // A dead process's stop() throwing must not break the panel.
869
- }
870
- panel.setShellFocus(result.focus, ids);
1148
+ if (effect === "openTasks") {
1149
+ void openTasksDialog(ctx).catch((error: unknown) => {
1150
+ ctx.ui.notify(`Could not open the tasks dialog: ${error instanceof Error ? error.message : String(error)}`, "error");
1151
+ });
871
1152
  return { consume: true };
872
1153
  }
873
- panel.setShellFocus(result.focus, ids);
874
1154
  // While the model streams, esc must still interrupt it — consume only when idle.
875
- if (decoded.key === "leave" && result.focus === undefined) {
876
- return ctx.isIdle() ? { consume: true } : undefined;
877
- }
1155
+ if (decoded.key === "leave") return ctx.isIdle() ? { consume: true } : undefined;
878
1156
  return { consume: true };
879
1157
  };
880
1158
  try {
881
1159
  registerCtx.ui.onTerminalInput((data) => {
1160
+ // pi-tui calls input listeners before it filters key releases, and a
1161
+ // kitty terminal sends one after every press: a release is never a
1162
+ // key here (it used to read as typing and drop focus).
1163
+ if (isKeyRelease(data)) return undefined;
882
1164
  const ctx = lastCtx ?? registerCtx;
883
1165
  // Enter focus: down-arrow while the editor holds real focus. Unlike the
884
1166
  // workflow strip we do NOT require the editor to be idle — the whole
@@ -887,15 +1169,31 @@ export default function subagentsExtension(pi: ExtensionAPI) {
887
1169
  // O(rows) checks run only once a Down actually asks to focus, never on
888
1170
  // plain typing. Claude Code's order: the FIRST ↓ lands on the shells
889
1171
  // chip when shells are running; the next ↓ moves into the agent rows.
890
- if (panel.focusIndex === undefined && panel.shellFocus === undefined) {
891
- if (!DOWN_KEYS.has(data) || !panel.editorFocused()) return undefined;
1172
+ if (panel.focusIndex === undefined && !panel.shellChipFocused) {
1173
+ // Viewing an agent with the editor focused: the page keys scroll the
1174
+ // view, and Esc in an empty editor goes back to main.
1175
+ if (view && panel.editorFocused()) {
1176
+ const id = keyId(data);
1177
+ if (id === "pageUp" || id === "pageDown") {
1178
+ view.scrollBy(id === "pageUp" ? 10 : -10);
1179
+ return { consume: true };
1180
+ }
1181
+ if (id === "escape" && editorEmpty()) {
1182
+ closeView();
1183
+ return { consume: true };
1184
+ }
1185
+ }
1186
+ if (!isStripEntryKey(data) || !panel.editorFocused()) return undefined;
1187
+ // In a draft, ↓ first moves the cursor, walks recalled history or the
1188
+ // autocomplete list; the strip takes it only past the draft's last line.
1189
+ if (!editorYieldsDown(panel.editorBaseline)) return undefined;
892
1190
  if (panel.shellChipAvailable()) {
893
- panel.setShellFocus({ stage: "chip" });
1191
+ panel.setShellChipFocus(true);
894
1192
  return { consume: true };
895
1193
  }
896
1194
  return focusFirstAgentRow() ? { consume: true } : undefined;
897
1195
  }
898
- if (panel.shellFocus !== undefined) return handleShellKey(data, ctx);
1196
+ if (panel.shellChipFocused) return handleShellKey(data, ctx);
899
1197
  const focusIndex = panel.focusIndex;
900
1198
  if (focusIndex === undefined) return undefined; // unreachable: one of the two focuses was set
901
1199
  // The strip emptied under us (finished rows lingered out) — let go.
@@ -910,53 +1208,6 @@ export default function subagentsExtension(pi: ExtensionAPI) {
910
1208
  }
911
1209
  const decoded = decodeStripKey(data, chordArmed);
912
1210
  chordArmed = decoded.chordArmed;
913
- // Read mode: while a transcript view is open the arrows/page keys
914
- // scroll IT (the natural gesture — pi's main screen has no mouse
915
- // wheel, so PageUp/PageDown alone were the only path and few reach
916
- // for them), Tab retargets to the next agent, Enter/esc close, and
917
- // ← closes the transcript but keeps the panel focused on that agent
918
- // (setView parked the highlight there) so ↑/↓ can pick a different
919
- // agent and Enter reopens — Claude Code's back-to-selection gesture.
920
- if (view) {
921
- switch (decoded.key) {
922
- case "up":
923
- view.scrollBy(1);
924
- return { consume: true };
925
- case "down":
926
- view.scrollBy(-1);
927
- return { consume: true };
928
- case "pageUp":
929
- view.scrollBy(10);
930
- return { consume: true };
931
- case "pageDown":
932
- view.scrollBy(-10);
933
- return { consume: true };
934
- case "left":
935
- closeView();
936
- return { consume: true };
937
- case "switch": {
938
- // Retarget to the next agent, anchored to the VIEWED run in the
939
- // full row list (not the windowed selection) so it stays correct
940
- // once the viewed run scrolls past MAX_STRIP_ROWS; setView then
941
- // moves the strip highlight to it when it is in-window.
942
- const nextId = panel.nextAgentAfter(panel.viewedTaskId());
943
- if (nextId) openView(ctx, nextId);
944
- return { consume: true };
945
- }
946
- case "open": // Enter toggles the view shut, mirroring Enter-to-open.
947
- closeView();
948
- return { consume: true };
949
- case "leave":
950
- return doLeave(ctx);
951
- case "stop":
952
- // Stop the agent whose transcript is open, not the strip selection.
953
- return doStop(panel.viewedTaskId());
954
- case "stopAll":
955
- return doStopAll();
956
- default:
957
- return closeAndPassthrough(); // typing closes the view and resumes in the editor
958
- }
959
- }
960
1211
  if (!decoded.key || !AGENT_KEYS.has(decoded.key)) return closeAndPassthrough();
961
1212
  switch (decoded.key) {
962
1213
  case "up":
@@ -964,7 +1215,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
964
1215
  // Back up out of the agent rows: onto the shells chip when
965
1216
  // shells are running, otherwise out of the panel entirely.
966
1217
  leave();
967
- if (panel.shellChipAvailable()) panel.setShellFocus({ stage: "chip" });
1218
+ if (panel.shellChipAvailable()) panel.setShellChipFocus(true);
968
1219
  } else panel.setFocus(focusIndex - 1);
969
1220
  return { consume: true };
970
1221
  case "down":
@@ -987,8 +1238,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
987
1238
  return doStopAll();
988
1239
  case "pageUp":
989
1240
  case "pageDown":
990
- // No transcript open → nothing to scroll; swallow so the page
991
- // keys never leak into the editor while the strip holds focus.
1241
+ // Scroll an open view; with none open, swallow so the page keys
1242
+ // never leak into the editor while the strip holds focus.
1243
+ view?.scrollBy(decoded.key === "pageUp" ? 10 : -10);
992
1244
  return { consume: true };
993
1245
  }
994
1246
  });
@@ -999,10 +1251,16 @@ export default function subagentsExtension(pi: ExtensionAPI) {
999
1251
 
1000
1252
  // Compact transcript rendering for the notifications this extension injects
1001
1253
  // (full body on ctrl+o); the verbose framing stays model-only.
1254
+ // A message is addressed from the agent's task id; its headline names the
1255
+ // agent, from the live registry or the message's own details (a resumed
1256
+ // session has no live runs).
1002
1257
  for (const customType of ["subagent-message", "subagent-result"]) {
1003
- pi.registerMessageRenderer(customType, (message, { expanded }, theme) =>
1004
- notificationComponent(theme, customMessageText(message.content), expanded),
1005
- );
1258
+ pi.registerMessageRenderer(customType, (message, { expanded }, theme) => {
1259
+ const details = (message as { details?: { taskId?: unknown; name?: unknown } }).details;
1260
+ const agentName = (from: string): string | undefined =>
1261
+ liveRuns.get(from)?.name ?? (details?.taskId === from && typeof details.name === "string" ? details.name : undefined);
1262
+ return notificationComponent(theme, customMessageText(message.content), expanded, agentName);
1263
+ });
1006
1264
  }
1007
1265
 
1008
1266
  // A hand-back the model already read through task_output (block: true on the
@@ -1010,7 +1268,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1010
1268
  // so the message and its pointer would only repeat it (see decisions).
1011
1269
  const notifier = createTaskNotifier(pi, { withdrawOnDelivery: true });
1012
1270
  /** Every subagent notification; silent during shutdown (a teardown kill is not news). */
1013
- const notify: typeof notifier = (customType, text, details) => {
1271
+ const notify = (customType: string, text: string, details?: Record<string, unknown>) => {
1014
1272
  if (shuttingDown) return;
1015
1273
  notifier(customType, text, details);
1016
1274
  };
@@ -1066,15 +1324,17 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1066
1324
  record.sessionSearchDir ? join(record.sessionSearchDir, "output.log") : undefined;
1067
1325
 
1068
1326
  /**
1069
- * Claude Code's two-message hand-back for a resident agent's finished turn
1070
- * (its first report, an update, a reply): the report goes out as an
1071
- * `<agent-message>` from the agent — the preamble, every line indented, auto
1072
- * mode's review flag above it — and the agent's own `<task-notification>`
1073
- * follows, its `<result>` a pointer at that message, never the report again.
1074
- * A turn that failed or was stopped delivered no report, so it is one
1075
- * notification with the output (and any flag) inline in `<result>`. Both go
1076
- * through the same notifier, in this order, so a coalesced round keeps the
1077
- * report ahead of the pointer.
1327
+ * The hand-back for a resident agent's finished turn (its first report, an
1328
+ * update, a reply). Claude Code 2.1.283 sends one `<task-notification>` with
1329
+ * the report inline in `<result>` for a fork in every mode and for every agent
1330
+ * outside auto mode (findings §40). In auto mode a non-fork agent keeps the
1331
+ * two-message form: the report goes out as an `<agent-message>` from the
1332
+ * agent — the preamble, every line indented, auto mode's review flag above it
1333
+ * — and the agent's own `<task-notification>` follows, its `<result>` a
1334
+ * pointer at that message, never the report again. A turn that failed or was
1335
+ * stopped delivered no report, so it is always one notification with the
1336
+ * output (and any flag) inline. Both go through the same notifier, in this
1337
+ * order, so a coalesced round keeps the report ahead of the pointer.
1078
1338
  */
1079
1339
  const notifyHandBack = (opts: {
1080
1340
  /** The agent's persistent id: `from=` on the message, the SendMessage address. */
@@ -1097,6 +1357,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1097
1357
  }) => {
1098
1358
  const status: TaskStatus = opts.stopped ? "killed" : opts.outcome.failed ? "failed" : "completed";
1099
1359
  const warning = opts.review ? handBackWarning(opts.review) : undefined;
1360
+ const record = registry.resolve(opts.from);
1100
1361
  const details = { ...opts.details, taskId: opts.taskId, name: opts.name, failed: status === "failed", reviewed: opts.review !== undefined };
1101
1362
  const envelope = (result: string) =>
1102
1363
  taskNotification({
@@ -1105,12 +1366,12 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1105
1366
  toolUseId: opts.toolUseId,
1106
1367
  outputFile: opts.outputFile,
1107
1368
  status,
1108
- summary: agentSummary(opts.name, status),
1369
+ summary: agentSummary(record?.description ?? opts.name, status),
1109
1370
  note: AGENT_NOTE,
1110
1371
  result,
1111
1372
  usage: { subagentTokens: opts.outcome.usage.total, toolUses: opts.outcome.toolCalls, durationMs: Date.now() - opts.startedAt },
1112
1373
  });
1113
- if (status !== "completed") {
1374
+ if (status !== "completed" || record?.agent === FORK_AGENT || permissionMode !== "auto") {
1114
1375
  notify("subagent-result", envelope(withReview(opts.report, warning)), details);
1115
1376
  return;
1116
1377
  }
@@ -1120,6 +1381,68 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1120
1381
  notify("subagent-result", envelope(handBackPointer(opts.from, opts.review !== undefined)), details);
1121
1382
  };
1122
1383
 
1384
+ /**
1385
+ * The prompt a fork inherits: the one this session's next request carries.
1386
+ * pi's `getSystemPrompt()` is its own stock prompt between turns, before the
1387
+ * first prompt and in a turn opened from idle, so it is only the fallback
1388
+ * for a prompt pi builds itself (a named agent's).
1389
+ */
1390
+ const forkSystemPrompt = (ctx: ExtensionContext): string => requestSystemPrompt(pi.events, ctx) ?? ctx.getSystemPrompt();
1391
+
1392
+ /**
1393
+ * The parent's last provider request (the compaction extension publishes
1394
+ * each one). A fork's requests are built from it so the fork reads the
1395
+ * parent's prompt cache (fork-cache.ts; decisions/caching.md). None before
1396
+ * the first request, or for another model: that fork has no cache to read.
1397
+ */
1398
+ let lastRequest: RequestCapture | undefined;
1399
+ pi.events.on(LAST_REQUEST_CHANNEL, (data) => {
1400
+ lastRequest = data as RequestCapture | undefined;
1401
+ });
1402
+ const forkRequest = (ctx: ExtensionContext): RequestCapture | undefined =>
1403
+ lastRequest?.covers && captureMatches(lastRequest, ctx.model as { api?: string; provider?: string; id?: string } | undefined) ? lastRequest : undefined;
1404
+
1405
+ /**
1406
+ * The capture is persisted too, so a resumed fork still reads the cache its
1407
+ * earlier turns wrote. One file per distinct capture (a fan-out of forks
1408
+ * shares one), named by its hash, beside the fork session directories.
1409
+ */
1410
+ const FORK_REQUEST_FILE = "parent-request";
1411
+ const forkRequestsDir = (sessionSearchDir: string) => join(dirname(sessionSearchDir), "parent-requests");
1412
+ /** Each capture is serialized and hashed once, however many forks share it. */
1413
+ const persistedCaptures = new WeakMap<RequestCapture, string>();
1414
+ const persistForkRequest = (record: AgentRunRecord, capture: RequestCapture) => {
1415
+ if (!record.sessionSearchDir) return;
1416
+ try {
1417
+ const dir = forkRequestsDir(record.sessionSearchDir);
1418
+ let name = persistedCaptures.get(capture);
1419
+ if (!name || !existsSync(join(dir, name))) {
1420
+ const body = JSON.stringify(capture);
1421
+ name = `${createHash("sha256").update(body).digest("hex").slice(0, 24)}.json`;
1422
+ mkdirSync(dir, { recursive: true });
1423
+ if (!existsSync(join(dir, name))) {
1424
+ // Atomic: a partial file would be skipped as present by every later fork.
1425
+ const temp = join(dir, `${name}.${process.pid}.tmp`);
1426
+ writeFileSync(temp, body);
1427
+ renameSync(temp, join(dir, name));
1428
+ }
1429
+ persistedCaptures.set(capture, name);
1430
+ }
1431
+ writeFileSync(join(record.sessionSearchDir, FORK_REQUEST_FILE), name);
1432
+ } catch {
1433
+ // The run still reads the cache; only a later resume re-reads uncached.
1434
+ }
1435
+ };
1436
+ const readForkRequest = (record: AgentRunRecord): RequestCapture | undefined => {
1437
+ if (!record.sessionSearchDir) return undefined;
1438
+ try {
1439
+ const name = readFileSync(join(record.sessionSearchDir, FORK_REQUEST_FILE), "utf-8").trim();
1440
+ return JSON.parse(readFileSync(join(forkRequestsDir(record.sessionSearchDir), name), "utf-8")) as RequestCapture;
1441
+ } catch {
1442
+ return undefined;
1443
+ }
1444
+ };
1445
+
1123
1446
  /** A fork's system prompt is persisted beside its session so a later resume can restore it (review S6). */
1124
1447
  const FORK_PROMPT_FILE = "system-prompt.md";
1125
1448
  const persistForkPrompt = (record: AgentRunRecord, prompt: string) => {
@@ -1130,6 +1453,19 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1130
1453
  // The run still works; only a later resume loses the prompt (and says so).
1131
1454
  }
1132
1455
  };
1456
+ /**
1457
+ * A fork's inputs from this conversation (its system prompt and the parent's
1458
+ * last request, for the cache), saved beside its session so a SendMessage
1459
+ * can resume it later. Undefined for a named agent.
1460
+ */
1461
+ const forkInputs = (request: RunRequest, record: AgentRunRecord, ctx: ExtensionContext) => {
1462
+ if (!request.fork) return { parentSystemPrompt: undefined, parentRequest: undefined };
1463
+ const parentSystemPrompt = forkSystemPrompt(ctx);
1464
+ persistForkPrompt(record, parentSystemPrompt);
1465
+ const parentRequest = forkRequest(ctx);
1466
+ if (parentRequest) persistForkRequest(record, parentRequest);
1467
+ return { parentSystemPrompt, parentRequest };
1468
+ };
1133
1469
  const readForkPrompt = (record: AgentRunRecord): string | undefined => {
1134
1470
  if (!record.sessionSearchDir) return undefined;
1135
1471
  try {
@@ -1176,9 +1512,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1176
1512
  const childCatalog = (cwd: string): string => {
1177
1513
  let text = childCatalogCache.get(cwd);
1178
1514
  if (text === undefined) {
1179
- text = loadAgents(cwd)
1180
- .map((a) => `- ${a.name}: ${a.description || "(no description)"}${toolsClause(a)}`)
1181
- .join("\n");
1515
+ text = catalogLines(loadAgents(cwd)).join("\n");
1182
1516
  childCatalogCache.set(cwd, text);
1183
1517
  }
1184
1518
  return text;
@@ -1205,16 +1539,18 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1205
1539
  // accepted as `task`, `description` is tolerated, and the agent name is
1206
1540
  // matched case-insensitively — a nested subagent is just as likely to be
1207
1541
  // CC-trained.
1208
- parameters: Type.Object({
1209
- subagent_type: Type.String({ description: "An agent name from the list in this tool's description" }),
1210
- task: Type.Optional(Type.String({ description: "The task — a complete, self-contained instruction" })),
1211
- prompt: Type.Optional(Type.String({ description: "Alias of `task` (Claude Code's name for it)." })),
1212
- description: Type.Optional(Type.String({ description: "A short (3-5 word) description of the task." })),
1213
- name: Type.Optional(Type.String({ description: "Name for this run (default: <agent>-<n>)" })),
1214
- }) as never,
1542
+ // A fork inherits the parent's tools block byte for byte (its first request
1543
+ // extends the parent's recorded one, decisions/caching.md), so its spawn
1544
+ // tool takes the parent's Agent schema: the ledger keeps the recorded
1545
+ // declaration only while the live schema matches it, and pi validates the
1546
+ // call against the live one. The options a nested run lacks are refused in
1547
+ // execute() with the fix named.
1548
+ parameters: (parentRecord.agent === FORK_AGENT ? SubagentParams : NESTED_AGENT_PARAMS) as never,
1549
+ prepareArguments: prepareAgentArguments as never,
1215
1550
  async execute(_toolCallId: string, params: unknown, signal?: AbortSignal) {
1216
1551
  const ctx = lastCtx;
1217
- const p = (params ?? {}) as { subagent_type?: unknown; task?: unknown; prompt?: unknown; name?: unknown };
1552
+ const p = (params ?? {}) as { subagent_type?: unknown; task?: unknown; prompt?: unknown; name?: unknown; description?: unknown; model?: unknown; thinking?: unknown; isolation?: unknown; action?: unknown };
1553
+ const description = typeof p.description === "string" && p.description.trim() ? p.description.trim() : undefined;
1218
1554
  const agentName = typeof p.subagent_type === "string" ? p.subagent_type : "";
1219
1555
  const taskInput = typeof p.task === "string" ? p.task : typeof p.prompt === "string" ? p.prompt : "";
1220
1556
  const task = taskInput.trim();
@@ -1222,10 +1558,13 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1222
1558
  if (!ctx) return err("The host session is not ready to spawn agents — retry shortly.");
1223
1559
  if (agentName.toLowerCase() === FORK_AGENT) return err('"fork" is only available to the main conversation — pick a named agent instead.');
1224
1560
  const agents = loadAgents(parentRecord.cwd);
1561
+ // Only a fork's schema (the parent's) offers these; a nested run has none of them.
1562
+ if (p.action === "list") return { content: [{ type: "text" as const, text: `Available agents:\n${catalogLines(agents).join("\n")}` }], details: {} };
1563
+ const unsupported = (["model", "thinking", "isolation"] as const).filter((key) => p[key] != null);
1564
+ if (unsupported.length > 0) return err(`${unsupported.map((key) => `\`${key}\``).join(", ")} ${unsupported.length === 1 ? "is" : "are"} not available to an Agent call made inside a subagent: a nested agent runs blocking, in this agent's directory, on its definition's model. Call again without ${unsupported.length === 1 ? "it" : "them"}.`);
1225
1565
  const agentDef = agents.find((a) => a.name.toLowerCase() === agentName.toLowerCase());
1226
1566
  if (!agentDef) {
1227
- const catalog = agents.map((a) => `- ${a.name}: ${a.description || "(no description)"}${toolsClause(a)}`).join("\n");
1228
- return err(`${agentName ? `Unknown agent "${agentName}"` : "`subagent_type` is required"}. Available agents:\n${catalog}`);
1567
+ return err(`${agentName ? `Unknown agent "${agentName}"` : "`subagent_type` is required"}. Available agents:\n${catalogLines(agents).join("\n")}`);
1229
1568
  }
1230
1569
  if (!task) return err("`task` is required: a complete, self-contained instruction for the agent (Claude Code names this parameter `prompt` — either is accepted).");
1231
1570
 
@@ -1257,8 +1596,10 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1257
1596
  model: resolved,
1258
1597
  thinking: undefined,
1259
1598
  depth: parentDepth + 1,
1599
+ ...(description ? { description } : {}),
1260
1600
  };
1261
1601
  registry.add(record); // SendMessage from main can reach the nested run too
1602
+ parentTaskOf.set(taskId, parentRecord.taskId);
1262
1603
 
1263
1604
  // The main conversation's Agent calls are classified as delegations
1264
1605
  // (matcher DELEGATION_TOOLS); a child's nested spawn must be judged the
@@ -1269,7 +1610,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1269
1610
  try {
1270
1611
  const bridge = getPermissionBridge();
1271
1612
  const verdict = bridge
1272
- ? await bridge({ toolName: "Agent", input: { subagent_type: agentDef.name, prompt: task, description: name }, cwd: parentRecord.cwd, signal })
1613
+ ? await bridge({ toolName: "Agent", input: { subagent_type: agentDef.name, prompt: task, description: name }, cwd: parentRecord.cwd, signal, parentToolCallId: mainCallOf(parentRecord.taskId) })
1273
1614
  : { block: true as const, reason: "no permission bridge is available to judge the delegation" };
1274
1615
  if (verdict?.block) {
1275
1616
  return {
@@ -1292,6 +1633,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1292
1633
  agent: agentDef.name,
1293
1634
  task,
1294
1635
  name,
1636
+ description,
1295
1637
  model: resolved,
1296
1638
  fallbackModel: spawnFallbackModel(resolved, resolution.source, ctx.model),
1297
1639
  },
@@ -1308,7 +1650,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1308
1650
  content: [
1309
1651
  {
1310
1652
  type: "text" as const,
1311
- text: `${notes.length ? `${notes.join("\n")}\n\n` : ""}${result.output}\n\n(${formatStats(result.toolCalls, result.usage)})`,
1653
+ text: `${notes.length ? `${notes.join("\n")}\n\n` : ""}${bounded(result.output, `${record.taskId}-report`, OUTPUT_CAP)}\n\n(${formatStats(result.toolCalls, result.usage)})`,
1312
1654
  },
1313
1655
  ],
1314
1656
  details: { agentRuns: [record] },
@@ -1321,13 +1663,29 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1321
1663
  * The spawn tool a run is entitled to. THE one depth gate: it reads the
1322
1664
  * record's own depth (set once at creation), so every handout site —
1323
1665
  * fresh spawn, background resident, SendMessage resume — enforces
1324
- * MAX_SPAWN_DEPTH identically. A resumed grandchild stays capped.
1666
+ * MAX_SPAWN_DEPTH identically. A resumed grandchild stays capped. Only a
1667
+ * `resident` run can receive a cron fire, so any other run's cron_create refuses.
1325
1668
  */
1326
- const spawnToolsFor = (record: AgentRunRecord): ToolDefinition[] => {
1669
+ const spawnToolsFor = (record: AgentRunRecord, { resident = false } = {}): ToolDefinition[] => {
1327
1670
  const depth = record.depth ?? 0;
1328
- return depth < MAX_SPAWN_DEPTH ? [childAgentTool(record, depth)] : [];
1671
+ // The child's cron tools reach this session's store (lib/agent-cron.ts).
1672
+ const cron = agentCronTools(pi.events, { agentId: record.taskId, cwd: record.cwd, resident }) as unknown as ToolDefinition[];
1673
+ return depth < MAX_SPAWN_DEPTH ? [childAgentTool(record, depth), ...cron] : cron;
1329
1674
  };
1330
1675
 
1676
+ // A fired cron job of a subagent's: to that agent while it lives (Claude
1677
+ // Code's task-notification delivery); otherwise the background extension drops it.
1678
+ pi.events.on(AGENT_CRON_FIRE_CHANNEL, (data) => {
1679
+ const fire = data as AgentCronFire;
1680
+ const resident = residents.get(fire.agentId);
1681
+ if (!resident || resident.handle.exited()) return;
1682
+ fire.delivered = true;
1683
+ // A send that fails after all ends the job, as a fire to an ended agent does.
1684
+ void resident.handle.send(fire.text(resident.handle.conversation)).catch(() => {
1685
+ pi.events.emit(AGENT_CRON_CHANNEL, { op: "delete", agentId: fire.agentId, id: fire.jobId } satisfies AgentCronRequest);
1686
+ });
1687
+ });
1688
+
1331
1689
  /**
1332
1690
  * The text a child's first turn is prompted with: a fork gets the inherited-
1333
1691
  * context framing (plus, in a worktree, where its paths moved to); a named
@@ -1337,8 +1695,8 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1337
1695
  request.fork ? forkTaskMessage(request.task, worktree ? { worktreePath: worktree.path, parentCwd } : undefined) : request.task;
1338
1696
 
1339
1697
  /** Create a run's isolation worktree and point its record at it (both spawn paths). */
1340
- const isolateInWorktree = async (ctx: ExtensionContext, record: AgentRunRecord, name: string): Promise<Worktree> => {
1341
- const worktree = await createWorktree(ctx.cwd, name);
1698
+ const isolateInWorktree = async (base: string, record: AgentRunRecord, name: string): Promise<Worktree> => {
1699
+ const worktree = await createWorktree(base, name);
1342
1700
  record.cwd = worktree.path;
1343
1701
  record.worktree = true;
1344
1702
  return worktree;
@@ -1362,11 +1720,20 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1362
1720
  onProgress?: (toolCalls: number, text: string, usage: UsageTotals) => void,
1363
1721
  ): Promise<TaskResult> => {
1364
1722
  const { request, record, agentDef } = prepared;
1723
+ // The session's working directory: the entered worktree, if any.
1724
+ const base = workCwd(ctx);
1725
+
1726
+ // Before anything marks the run as running: a failed build must not leave
1727
+ // the task id in runningIds, where SendMessage would call it "still running".
1728
+ const runtime = await runtimeOrError(ctx);
1729
+ if (typeof runtime === "string") {
1730
+ return { agent: request.agent, name: request.name, taskId: record.taskId, task: request.task, output: `Subagent failed: ${runtime}`, toolCalls: 0, usage: emptyUsage(), failed: true };
1731
+ }
1365
1732
 
1366
1733
  let worktree: Worktree | undefined;
1367
1734
  if (request.worktree) {
1368
1735
  try {
1369
- worktree = await isolateInWorktree(ctx, record, request.name);
1736
+ worktree = await isolateInWorktree(base, record, request.name);
1370
1737
  } catch (error) {
1371
1738
  return {
1372
1739
  agent: request.agent,
@@ -1383,14 +1750,15 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1383
1750
 
1384
1751
  runningIds.add(record.taskId);
1385
1752
  const live = trackLiveRun(record, request, parent);
1386
- const runtime = await getRuntime(ctx);
1753
+ const { parentSystemPrompt: forkPrompt, parentRequest } = forkInputs(request, record, ctx);
1387
1754
  const handle = runtime.run({
1388
1755
  name: request.name,
1389
1756
  agent: agentDef,
1390
- task: frameTask(request, worktree, ctx.cwd),
1757
+ task: frameTask(request, worktree, base),
1391
1758
  cwd: record.cwd,
1392
1759
  forkFrom: request.fork ? forkFrom : undefined,
1393
- parentSystemPrompt: request.fork ? ctx.getSystemPrompt() : undefined,
1760
+ parentSystemPrompt: forkPrompt,
1761
+ parentRequest,
1394
1762
  sessionDir: record.sessionSearchDir || undefined,
1395
1763
  model: request.model,
1396
1764
  fallbackModel: request.fallbackModel,
@@ -1404,6 +1772,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1404
1772
  onMessageToMain: (message, summary) =>
1405
1773
  parent ? relayToParent(parent.taskId, record.taskId, request.name, message, summary) : notifyAgentMessage(record.taskId, request.name, message),
1406
1774
  extraTools: spawnToolsFor(record),
1775
+ mainToolCallId: () => mainCallOf(record.taskId),
1407
1776
  });
1408
1777
  liveHandles.set(record.taskId, handle);
1409
1778
 
@@ -1413,7 +1782,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1413
1782
  live.finish(Boolean(outcome.failed));
1414
1783
  let worktreeKept: boolean | undefined;
1415
1784
  if (worktree) {
1416
- worktreeKept = !(await cleanupWorktree(ctx.cwd, worktree));
1785
+ worktreeKept = !(await cleanupWorktree(base, worktree));
1417
1786
  }
1418
1787
  return {
1419
1788
  agent: request.agent,
@@ -1422,11 +1791,14 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1422
1791
  task: request.task,
1423
1792
  ...outcome,
1424
1793
  worktreePath: worktreeKept ? worktree?.path : undefined,
1794
+ worktreeBranch: worktreeKept ? worktree?.branch : undefined,
1425
1795
  worktreeKept,
1426
1796
  };
1427
1797
  } catch (error) {
1428
1798
  live.finish(true);
1429
- if (worktree) await cleanupWorktree(ctx.cwd, worktree);
1799
+ // A failed run can still have committed or edited in its worktree:
1800
+ // a kept one is reported like a finished run's.
1801
+ const worktreeKept = worktree ? !(await cleanupWorktree(base, worktree)) : undefined;
1430
1802
  return {
1431
1803
  agent: request.agent,
1432
1804
  name: request.name,
@@ -1436,40 +1808,321 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1436
1808
  toolCalls: 0,
1437
1809
  usage: emptyUsage(),
1438
1810
  failed: true,
1811
+ worktreePath: worktreeKept ? worktree?.path : undefined,
1812
+ worktreeBranch: worktreeKept ? worktree?.branch : undefined,
1813
+ worktreeKept,
1439
1814
  };
1440
1815
  } finally {
1441
1816
  runningIds.delete(record.taskId);
1442
1817
  }
1443
1818
  };
1444
1819
 
1445
- pi.registerTool({
1820
+ /**
1821
+ * Launch one prepared run as a background resident (Claude Code parity):
1822
+ * it returns as soon as the child starts, the report arrives as a steered
1823
+ * task notification, and the child stays resident so SendMessage can reach
1824
+ * it live (steer mid-turn, prompt when idle). `sessionFile` is the parent
1825
+ * transcript a fork clones; `toolCallId` links the notifications to the
1826
+ * spawning Agent call (none for a `/btw` fork); `forkMessages` are appended
1827
+ * to a fork's transcript. Returns the line reported for the run.
1828
+ */
1829
+ const launchResident = async (
1830
+ p: PreparedRun,
1831
+ ctx: ExtensionContext,
1832
+ runtime: SubagentRuntime,
1833
+ { sessionFile, toolCallId, forkMessages }: { sessionFile?: string; toolCallId?: string; forkMessages?: Message[] },
1834
+ ): Promise<{ launched: boolean; line: string }> => {
1835
+ const ended = () => {
1836
+ registry.remove(p.record.taskId);
1837
+ return { launched: false, line: `✗ ${p.record.name}: the session ended before the agent started.` };
1838
+ };
1839
+ if (shuttingDown) return ended();
1840
+ if (toolCallId) mainCallFor.set(p.record.taskId, toolCallId);
1841
+ // Captured as a string: onExit runs from a `.finally` long after this
1842
+ // turn's ctx may be stale (review S5). The entered worktree, if any.
1843
+ const parentCwd = workCwd(ctx);
1844
+ let worktree: Worktree | undefined;
1845
+ if (p.request.worktree) {
1846
+ try {
1847
+ worktree = await isolateInWorktree(parentCwd, p.record, p.request.name);
1848
+ } catch (error) {
1849
+ return { launched: false, line: `✗ ${p.record.name}: could not create a worktree: ${(error as Error).message}` };
1850
+ }
1851
+ // Shutdown can begin while git creates the worktree: build no child
1852
+ // after its sweep, and remove the worktree within its grace.
1853
+ if (shuttingDown) {
1854
+ await cleanupWorktree(parentCwd, worktree);
1855
+ return ended();
1856
+ }
1857
+ }
1858
+
1859
+ const logPath = outputLogPath(p.record);
1860
+ let lastLogWrite = 0;
1861
+ let finish!: () => void;
1862
+ const finished = new Promise<void>((resolve) => {
1863
+ finish = resolve;
1864
+ });
1865
+
1866
+ // The child's final output. task.output() falls back to this
1867
+ // because handle.snapshot().text can be blank at a turn boundary —
1868
+ // the same reason the log write below uses `|| outcome.output`.
1869
+ // Keeps task_output consistent with the log and the completion
1870
+ // notification rather than showing an empty body in that case.
1871
+ let lastOutput = "";
1872
+ // The initial task's own report. task_output for THIS task must return the
1873
+ // first turn's reply — not the resident's growing multi-turn transcript —
1874
+ // the same way a SendMessage task returns one reply (review S12).
1875
+ let firstTurnOutput: string | undefined;
1876
+ const live = trackLiveRun(p.record, p.request);
1877
+ const resident: Resident = { handle: undefined as never, startedAt: Date.now(), turnHandlers: [], reviewing: 0 };
1878
+ const worktreeNote = worktree
1879
+ ? `\n\n(Running in worktree ${worktree.path} on branch ${worktree.branch} — kept while the agent stays resident, and after it exits if it holds uncommitted changes or commits.)`
1880
+ : "";
1881
+ resident.turnHandlers.push((outcome, review, stopped) => {
1882
+ task.status = stopped ? "stopped" : outcome.failed ? "failed" : "completed";
1883
+ task.finishedAt = Date.now();
1884
+ firstTurnOutput = outcome.output;
1885
+ finish();
1886
+ notifyHandBack({
1887
+ from: p.record.taskId,
1888
+ taskId: p.record.taskId,
1889
+ name: p.record.name,
1890
+ toolUseId: toolCallId,
1891
+ outputFile: logPath,
1892
+ outcome,
1893
+ stopped,
1894
+ startedAt: task.startedAt,
1895
+ report: `${bounded(outcome.output, `${p.record.taskId}-report`, OUTPUT_CAP)}${worktreeNote}`,
1896
+ review,
1897
+ });
1898
+ });
1899
+
1900
+ // Idle reaper: every run is a resident now, so without this a long
1901
+ // session accumulates one live AgentSession (extension instances,
1902
+ // message array, MCP tool refs) per delegation for its whole life.
1903
+ // After RESIDENT_IDLE_MS idle the session is released quietly; the
1904
+ // agent stays reachable — SendMessage resumes it from its session
1905
+ // file. Worktree residents are exempt: release would remove the
1906
+ // worktree a resume still needs. So is one that owns a cron job, which
1907
+ // fires only to a live agent (lib/agent-cron.ts). Armed from every turn end.
1908
+ let reaper: ReturnType<typeof setTimeout> | undefined;
1909
+ const armReaper = () => {
1910
+ if (worktree) return;
1911
+ if (reaper) clearTimeout(reaper);
1912
+ reaper = setTimeout(() => {
1913
+ reaper = undefined;
1914
+ if (handle.exited()) return;
1915
+ if (handle.busy() || agentOwnsCronJobs(pi.events, p.record.taskId)) {
1916
+ armReaper();
1917
+ return;
1918
+ }
1919
+ handle.release();
1920
+ }, RESIDENT_IDLE_MS);
1921
+ reaper.unref?.();
1922
+ };
1923
+ const { parentSystemPrompt: forkPrompt, parentRequest } = forkInputs(p.request, p.record, ctx);
1924
+ // No `signal` here on purpose: a resident outlives the spawning turn and
1925
+ // is stopped through task_stop / the panel, not by the turn ending (S15).
1926
+ const started = await runtime.runResident({
1927
+ name: p.record.name,
1928
+ agent: p.agentDef,
1929
+ cwd: p.record.cwd,
1930
+ forkFrom: p.request.fork ? sessionFile : undefined,
1931
+ forkMessages: p.request.fork ? forkMessages : undefined,
1932
+ parentSystemPrompt: forkPrompt,
1933
+ parentRequest,
1934
+ sessionDir: p.record.sessionSearchDir || undefined,
1935
+ model: p.request.model,
1936
+ fallbackModel: p.request.fallbackModel,
1937
+ thinking: p.request.thinking,
1938
+ onProgress: (toolCalls, text, usage) => {
1939
+ live.progress(toolCalls, usage);
1940
+ // Throttled: onProgress fires per tool call/message with the whole
1941
+ // turn text so far, and this path now carries EVERY run — an
1942
+ // unthrottled sync rewrite would be O(n²) bytes on the hot path.
1943
+ // onTurnEnd below flushes the final state, so the tail is never lost.
1944
+ if (logPath && text && Date.now() - lastLogWrite > LOG_WRITE_INTERVAL_MS) {
1945
+ lastLogWrite = Date.now();
1946
+ writeFileSync(logPath, text);
1947
+ }
1948
+ },
1949
+ sink: live.sink,
1950
+ onMessageToMain: (message) => notifyAgentMessage(p.record.taskId, p.record.name, message),
1951
+ extraTools: spawnToolsFor(p.record, { resident: true }),
1952
+ mainToolCallId: () => mainCallOf(p.record.taskId),
1953
+ onTurnEnd: (outcome) => {
1954
+ registry.sessionFileFor(p.record);
1955
+ live.settle();
1956
+ lastOutput = resident.handle.snapshot().text || outcome.output;
1957
+ if (logPath) writeFileSync(logPath, lastOutput);
1958
+ // The handler is claimed now (so a message arriving mid-review pairs
1959
+ // with the NEXT turn), but runs only once auto mode's hand-back
1960
+ // review of this turn's action sequence has answered, so the
1961
+ // verdict rides in the same notification as the report instead of
1962
+ // trailing it. The wait is bounded (hand-back-review.ts) and
1963
+ // answered synchronously when auto mode is off.
1964
+ const handler = resident.turnHandlers.shift();
1965
+ // A stop or resume during review must not change this turn's outcome.
1966
+ const stopped = stoppedTaskIds.has(p.record.taskId);
1967
+ armReaper();
1968
+ resident.reviewing++;
1969
+ void awaitHandBackReview(pi.events, p.record, outcome.actions, undefined, mainCallOf(p.record.taskId)).then((review) => {
1970
+ resident.reviewing--;
1971
+ if (handler) {
1972
+ handler(outcome, review, stopped);
1973
+ } else {
1974
+ // A turn nobody is waiting on (e.g. a steer that raced past its
1975
+ // target turn and ran on its own) must still surface.
1976
+ // Only the launch's notification names its tool call; a later
1977
+ // turn's notifies without one, as Claude Code's do (findings §40).
1978
+ notifyHandBack({
1979
+ from: p.record.taskId,
1980
+ taskId: p.record.taskId,
1981
+ name: p.record.name,
1982
+ outputFile: logPath,
1983
+ outcome,
1984
+ stopped,
1985
+ startedAt: resident.startedAt,
1986
+ report: bounded(outcome.output, `${p.record.taskId}-update-${Date.now()}`, OUTPUT_CAP),
1987
+ review,
1988
+ details: { update: true },
1989
+ });
1990
+ }
1991
+ });
1992
+ },
1993
+ onExit: () => {
1994
+ if (reaper) clearTimeout(reaper);
1995
+ live.finish(stoppedTaskIds.has(p.record.taskId) ? "stopped" : false);
1996
+ if (residents.get(p.record.taskId) === resident) residents.delete(p.record.taskId);
1997
+ liveHandles.delete(p.record.taskId);
1998
+ // Returned, not voided: kill() settles after the cleanup, so the
1999
+ // session_shutdown grace covers it.
2000
+ if (worktree) return cleanupWorktree(parentCwd, worktree);
2001
+ return undefined;
2002
+ },
2003
+ }).catch((error: unknown) => (error instanceof Error ? error : new Error(String(error))));
2004
+ if (started instanceof Error) {
2005
+ // The child never started: settle its panel row and forget the run, so
2006
+ // neither the panel nor list_agents offers it.
2007
+ live.finish(true);
2008
+ registry.remove(p.record.taskId);
2009
+ if (worktree) void cleanupWorktree(parentCwd, worktree);
2010
+ return { launched: false, line: `✗ ${p.record.name}: could not start: ${started.message}` };
2011
+ }
2012
+ const handle = started;
2013
+ resident.handle = handle;
2014
+ // Construction can finish after shutdown's stop-all sweep. The handle
2015
+ // was not registered then, so dispose it before sending any task now.
2016
+ if (shuttingDown) {
2017
+ await handle.kill();
2018
+ return ended();
2019
+ }
2020
+ residents.set(p.record.taskId, resident);
2021
+ liveHandles.set(p.record.taskId, handle);
2022
+
2023
+ const task: BackgroundTask = {
2024
+ id: p.record.taskId,
2025
+ kind: "subagent",
2026
+ ownUI: true, // rendered live by the subagents panel's agent strip
2027
+ description: `${p.record.name}: ${p.request.task.slice(0, 80)}`,
2028
+ status: "running",
2029
+ startedAt: Date.now(),
2030
+ logPath,
2031
+ output: () => firstTurnOutput ?? (handle.snapshot().text || lastOutput),
2032
+ stop: () => stopAgent(p.record.taskId, handle),
2033
+ resident: () => !handle.exited(),
2034
+ finished,
2035
+ };
2036
+ pi.events.emit(TASK_REGISTER_CHANNEL, task);
2037
+ void handle.send(frameTask(p.request, worktree, parentCwd));
2038
+
2039
+ return {
2040
+ launched: true,
2041
+ line: `⏳ ${p.record.name} (task ${p.record.taskId}) running in background${logPath ? ` — interim output readable at ${logPath}` : ""}`,
2042
+ };
2043
+ };
2044
+
2045
+ /**
2046
+ * `/btw`'s `f to fork` (lib/btw-fork.ts): a background fork that inherits
2047
+ * the conversation (if the session has one yet), then the side question and
2048
+ * its answer, and takes the question as its task. It reports back like any
2049
+ * spawned fork.
2050
+ */
2051
+ const forkFromBtw = async (request: BtwForkRequest): Promise<BtwForkResult> => {
2052
+ const ctx = request.ctx as ExtensionContext;
2053
+ // Before the first turn the file is not written yet and the fork starts from
2054
+ // the side exchange alone, as Claude Code's does (newChildSessionManager).
2055
+ // Without a session file at all the conversation cannot be cloned, and a
2056
+ // fork framed as inheriting it would confabulate (the Agent tool refuses too).
2057
+ const sessionFile = ctx.sessionManager.getSessionFile();
2058
+ if (!sessionFile) return { error: "Cannot fork: this session is not persisted (started with --no-session), so there is no conversation to clone." };
2059
+ // Named from the question, as Claude Code names a /btw fork.
2060
+ const name = freeRunName(registry.names(), btwForkName(request.question));
2061
+ const taskId = btwForkTaskId(name);
2062
+ // The session's current model and thinking, explicitly: before the first
2063
+ // turn there is no transcript for the fork to restore them from.
2064
+ const prepared: PreparedRun = {
2065
+ request: {
2066
+ agent: FORK_AGENT,
2067
+ task: request.question,
2068
+ name,
2069
+ description: btwForkDescription(request.question),
2070
+ fork: true,
2071
+ model: ctx.model ? modelSpec(ctx.model) : undefined,
2072
+ thinking: pi.getThinkingLevel(),
2073
+ },
2074
+ agentDef: undefined,
2075
+ record: {
2076
+ name,
2077
+ agent: FORK_AGENT,
2078
+ taskId,
2079
+ sessionSearchDir: runSessionDir(ctx, taskId) ?? "",
2080
+ cwd: workCwd(ctx),
2081
+ depth: 0,
2082
+ description: btwForkDescription(request.question),
2083
+ },
2084
+ };
2085
+ registry.add(prepared.record);
2086
+ // The session can be torn down while the runtime or the child starts; a
2087
+ // fork launched after shutdown's stop-all sweep would outlive it.
2088
+ const runtime = await runtimeOrError(ctx);
2089
+ if (typeof runtime === "string") {
2090
+ registry.remove(taskId);
2091
+ return { error: `Cannot fork: ${runtime}` };
2092
+ }
2093
+ if (shuttingDown) return { error: "The session ended before the fork started." };
2094
+ const { launched, line } = await launchResident(prepared, ctx, runtime, { sessionFile, forkMessages: request.messages });
2095
+ if (shuttingDown) {
2096
+ void stopAgent(taskId);
2097
+ return { error: "The session ended before the fork started." };
2098
+ }
2099
+ if (!launched) return { error: line };
2100
+ for (const text of btwForkRecord(name, taskId, request.question)) {
2101
+ pi.events.emit(REMINDER_CHANNEL, { text, placement: "user-prepend", raw: true } satisfies ReminderPayload);
2102
+ }
2103
+ return { name, taskId };
2104
+ };
2105
+ pi.events.on(BTW_FORK_CHANNEL, (data) => {
2106
+ const request = data as BtwForkRequest;
2107
+ request.handled = true;
2108
+ void forkFromBtw(request).then(request.respond, (error: unknown) =>
2109
+ request.respond({ error: `Failed to fork: ${error instanceof Error ? error.message : String(error)}` }),
2110
+ );
2111
+ });
2112
+
2113
+ // Claude Code's Agent text, short or long by the model's tier
2114
+ // (agent-description.ts, lib/tool-variants.ts); registered again when the
2115
+ // form changes.
2116
+ const agentTool = defineTool({
1446
2117
  name: "Agent",
1447
2118
  label: "Agent",
1448
2119
  ...ccToolRenderers<{ subagent_type?: string; task?: string; prompt?: string; description?: string; action?: string }>("Agent", {
1449
2120
  title: (a) => (a ? [a.subagent_type, a.description ?? a.task ?? a.prompt ?? a.action].filter(Boolean).join(": ") || undefined : undefined),
1450
2121
  }),
1451
- description:
1452
- 'Delegate a task to a specialist agent that runs in its own context window and reports back. The available agents are listed in the "Available agents" system reminder.\n' +
1453
- "\n" +
1454
- "## When to use\n" +
1455
- "- Broad codebase searches or exploration where you need the conclusion, not the file dumps.\n" +
1456
- "- Self-contained research, reviews, or verification whose intermediate output you won't need again.\n" +
1457
- "- Independent questions that can run at once — issue multiple Agent tool calls in one message to run them in parallel.\n" +
1458
- "\n" +
1459
- "For a single-fact lookup you already know how to run, search directly instead. Once you've delegated work, don't also run it yourself — wait for the result.\n" +
1460
- "\n" +
1461
- "## How agents run\n" +
1462
- "Agents run in the background: the call returns immediately with a task id, and you'll be notified when one completes — the agent's report arrives as a task notification while you keep working, or on its own if you are idle. Never fabricate or predict a pending agent's results — the notification is never something you write yourself; if the user asks before it arrives, say it's still running. Call task_output only if your next step cannot proceed without the result (block=true waits); stop a run with task_stop. Both are deferred — load them with tool_search first. (Exception: in a one-shot print session the call blocks and returns the report directly — no notification follows.)\n" +
1463
- "\n" +
1464
- "## Usage notes\n" +
1465
- "- Give a complete, self-contained task: the agent cannot ask follow-up questions.\n" +
1466
- "- If an agent's description says it should be used proactively, try your best to use it without the user having to ask first.\n" +
1467
- '- `subagent_type: "fork"` clones this conversation instead of starting fresh; a fork always runs on this conversation\'s model and reasoning settings. If you are the fork, execute your assigned task directly — don\'t re-delegate.\n' +
1468
- '- `isolation: "worktree"` gives the agent its own git worktree when it will edit files.\n' +
1469
- "- Each run gets a name — SendMessage reaches it live while it runs and continues it after it finishes. `action: \"list\"` re-prints the agent catalog.\n" +
1470
- "- The agent's final report is not shown to the user, so relay what matters.",
2122
+ description: agentDescription("short", projectConfigDirName()),
1471
2123
  promptSnippet: "Delegate scoped work to a specialist agent in its own context",
1472
2124
  parameters: SubagentParams,
2125
+ prepareArguments: prepareAgentArguments as (args: unknown) => Static<typeof SubagentParams>,
1473
2126
  async execute(toolCallId, params, signal, onUpdate, ctx) {
1474
2127
  const agents = loadAgents(ctx.cwd);
1475
2128
 
@@ -1479,11 +2132,10 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1479
2132
  const taskText = params.task ?? params.prompt;
1480
2133
 
1481
2134
  // A call carrying run options (a task, a name, a model/thinking/isolation
1482
- // override, or action:"run") but no `subagent_type` is a run that forgot
1483
- // to name its agent. Fail loudly with a diagnostic rather than silently
1484
- // returning the catalog: a weaker model reads the catalog as a
1485
- // non-sequitur and invents wrong reasons for it, instead of learning it
1486
- // omitted `subagent_type`.
2135
+ // override, or action:"run") is a run; prepareAgentArguments gave one that
2136
+ // named no agent general-purpose, as Claude Code does (a tiny model
2137
+ // repeated such a call 30 times against an error naming the fix, battery
2138
+ // round 7). A call with neither lists the catalog.
1487
2139
  const wantsRun =
1488
2140
  params.action === "run" ||
1489
2141
  taskText != null ||
@@ -1499,19 +2151,6 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1499
2151
  };
1500
2152
  }
1501
2153
 
1502
- if (!params.subagent_type) {
1503
- return {
1504
- content: [
1505
- {
1506
- type: "text",
1507
- text: `No \`subagent_type\` given, but you passed run options — this looks like a run that forgot to name its agent. Set \`subagent_type\` to one of the names below (or "fork" to clone this conversation). To only browse the catalog, call with action:"list".\n\nAvailable agents:\n${describeAgents(ctx.cwd)}`,
1508
- },
1509
- ],
1510
- details: {},
1511
- isError: true,
1512
- };
1513
- }
1514
-
1515
2154
  // A run needs a task. A CC-shaped call that put its instruction in an
1516
2155
  // unrecognized key, or none at all, must fail loud rather than start a
1517
2156
  // child prompted with "" (H1). Fork included — cloning to do nothing is
@@ -1544,6 +2183,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1544
2183
  agent: entry.agent,
1545
2184
  task: entry.task,
1546
2185
  name,
2186
+ description: params.description?.trim() || undefined,
1547
2187
  fork: entry.agent === FORK_AGENT,
1548
2188
  model: params.model,
1549
2189
  thinking: params.thinking,
@@ -1551,86 +2191,8 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1551
2191
  };
1552
2192
  });
1553
2193
 
1554
- // A fork continues this conversation; running it on a different model or
1555
- // reasoning effort is how the fork-confabulation incident happened (a
1556
- // minimal-thinking fork resumed the inherited topic instead of its task).
1557
- // Claude Code silently ignores `model` for forks; we fail loud instead.
1558
- if (requested.some((r) => r.fork) && (params.model || params.thinking)) {
1559
- return {
1560
- content: [
1561
- {
1562
- type: "text",
1563
- text: '`model`/`thinking` overrides are not applied to fork runs — a fork continues this conversation and keeps its exact model and reasoning settings. Drop the override, or use a named agent (e.g. "general-purpose") to run the task with different settings.',
1564
- },
1565
- ],
1566
- details: {},
1567
- isError: true,
1568
- };
1569
- }
1570
-
1571
2194
  const sessionFile = ctx.sessionManager.getSessionFile();
1572
- if (requested.some((r) => r.fork) && !sessionFile) {
1573
- return {
1574
- content: [
1575
- {
1576
- type: "text",
1577
- text: "Cannot fork: this session is not persisted (started with --no-session), so there is no transcript to clone. Use a named agent instead.",
1578
- },
1579
- ],
1580
- details: {},
1581
- isError: true,
1582
- };
1583
- }
1584
-
1585
- const unknown = requested.filter((r) => !r.fork && !agents.some((a) => a.name === r.agent));
1586
- if (unknown.length > 0) {
1587
- return {
1588
- content: [
1589
- {
1590
- type: "text",
1591
- text: `Unknown agent(s): ${unknown.map((u) => u.agent).join(", ")}\n\nAvailable agents:\n${describeAgents(ctx.cwd)}`,
1592
- },
1593
- ],
1594
- details: {},
1595
- isError: true,
1596
- };
1597
- }
1598
-
1599
- if (requested.some((r) => r.worktree) && !(await isGitRepo(ctx.cwd))) {
1600
- return {
1601
- content: [{ type: "text", text: 'isolation: "worktree" needs a git repository; this directory is not one.' }],
1602
- details: {},
1603
- isError: true,
1604
- };
1605
- }
1606
-
1607
- const prepared: PreparedRun[] = requested.map((request) => {
1608
- const taskId = generateTaskId();
1609
- return {
1610
- request,
1611
- agentDef: request.fork ? undefined : agents.find((a) => a.name === request.agent),
1612
- record: {
1613
- name: request.name,
1614
- agent: request.agent,
1615
- taskId,
1616
- sessionSearchDir: runSessionDir(ctx, taskId) ?? "",
1617
- cwd: ctx.cwd,
1618
- model: request.model,
1619
- thinking: request.thinking,
1620
- depth: 0,
1621
- },
1622
- };
1623
- });
1624
-
1625
- /**
1626
- * Model resolution happens here, in the parent, against the real
1627
- * registry — never in the child, whose `--model` fuzzy-matches across
1628
- * every configured provider. The child is spawned with a concrete
1629
- * `provider/id`; anything surprising (a fallback, a provider crossing)
1630
- * is said out loud rather than happening silently.
1631
- */
1632
- const available = usableModels(ctx);
1633
- const configuredDefault = applicableSubagentDefault(loadSubagentDefault(os.homedir()), ctx.model);
2195
+ let prepared: PreparedRun[] = [];
1634
2196
  /**
1635
2197
  * What the model is told about its own model choice: the same sentences
1636
2198
  * the user gets, plus the model each child actually runs on. Deduped —
@@ -1638,94 +2200,182 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1638
2200
  * once. See `subagentModelNotes` for why the model needs this.
1639
2201
  */
1640
2202
  const modelNotes = new Set<string>();
1641
- for (const p of prepared) {
1642
- // A fork inherits the parent transcript, so it must continue on THIS
1643
- // conversation's exact model — never a configured default or the
1644
- // automatic same-provider pick, which would move the inherited context
1645
- // onto a different (often weaker) model. Leaving model undefined makes
1646
- // the forked session restore its own model. (Per-call `model` on a fork
1647
- // is already rejected above.)
1648
- if (p.request.fork) continue;
1649
- // No per-call or agent-frontmatter override → the automatic default,
1650
- // which is stable for the session, so reuse the cached resolution
1651
- // instead of re-ranking the catalog per spawn. An override varies per
1652
- // call and always resolves fresh.
1653
- const resolution =
1654
- p.request.model || p.agentDef?.model
1655
- ? resolveSubagentModel({
1656
- requested: p.request.model,
1657
- agentModel: p.agentDef?.model,
1658
- configuredDefault: configuredDefault,
1659
- sessionModel: ctx.model,
1660
- available,
1661
- requireImageInput: supportsImageInput(ctx.model),
1662
- })
1663
- : resolveAutoDefault(ctx.model, available, configuredDefault);
1664
- if (resolution.unresolved) {
1665
- // The main model chose this string; the menu lets it retry.
1666
- const fallback = resolveSubagentModel({
1667
- configuredDefault: configuredDefault,
1668
- sessionModel: ctx.model,
1669
- available,
1670
- requireImageInput: supportsImageInput(ctx.model),
1671
- });
2203
+ // Every check before the records land: a rejection, or a throw on the way,
2204
+ // frees the spawn's names for the retry.
2205
+ let landed = false;
2206
+ try {
2207
+ // A fork continues this conversation; running it on a different model or
2208
+ // reasoning effort is how the fork-confabulation incident happened (a
2209
+ // minimal-thinking fork resumed the inherited topic instead of its task).
2210
+ // Claude Code silently ignores `model` for forks; we fail loud instead.
2211
+ if (requested.some((r) => r.fork) && (params.model || params.thinking)) {
1672
2212
  return {
1673
2213
  content: [
1674
2214
  {
1675
2215
  type: "text",
1676
- text:
1677
- `${resolution.unresolvedReason ?? `Unknown model "${resolution.unresolved}" — no available model matches it.`}\n\n` +
1678
- subagentModelsReminder({
1679
- available,
1680
- sessionModel: ctx.model,
1681
- defaultModel: fallback.model,
1682
- defaultSource: fallback.source,
1683
- requireImageInput: supportsImageInput(ctx.model),
1684
- }),
2216
+ text: '`model`/`thinking` overrides are not applied to fork runs — a fork continues this conversation and keeps its exact model and reasoning settings. Drop the override, or use a named agent (e.g. "general-purpose") to run the task with different settings.',
1685
2217
  },
1686
2218
  ],
1687
2219
  details: {},
1688
2220
  isError: true,
1689
2221
  };
1690
2222
  }
1691
- const gate = expensiveModelGate(resolution, ctx.model, params.allow_expensive);
1692
- if (gate) {
1693
- const fallback = resolveSubagentModel({
1694
- configuredDefault: configuredDefault,
1695
- sessionModel: ctx.model,
1696
- available,
1697
- requireImageInput: supportsImageInput(ctx.model),
1698
- });
2223
+
2224
+ if (requested.some((r) => r.fork) && !sessionFile) {
1699
2225
  return {
1700
2226
  content: [
1701
2227
  {
1702
2228
  type: "text",
1703
- text:
1704
- `${gate}\n` +
1705
- "If the user explicitly asked for this model, retry with allow_expensive: true; " +
1706
- "otherwise pick a cheaper model or omit the field.\n\n" +
1707
- subagentModelsReminder({
1708
- available,
1709
- sessionModel: ctx.model,
1710
- defaultModel: fallback.model,
1711
- defaultSource: fallback.source,
1712
- requireImageInput: supportsImageInput(ctx.model),
1713
- }),
2229
+ text: "Cannot fork: this session is not persisted (started with --no-session), so there is no transcript to clone. Use a named agent instead.",
1714
2230
  },
1715
2231
  ],
1716
2232
  details: {},
1717
2233
  isError: true,
1718
2234
  };
1719
2235
  }
1720
- for (const notice of resolution.notices) notifyModelOnce(ctx, notice);
1721
- for (const note of subagentModelNotes(resolution)) modelNotes.add(note);
1722
- const resolved = resolution.model ? modelSpec(resolution.model) : undefined;
1723
- p.request.model = resolved;
1724
- p.request.fallbackModel = spawnFallbackModel(resolved, resolution.source, ctx.model);
1725
- p.record.model = resolved;
1726
- }
1727
2236
 
1728
- for (const p of prepared) registry.add(p.record);
2237
+ const unknown = requested.filter((r) => !r.fork && !agents.some((a) => a.name === r.agent));
2238
+ if (unknown.length > 0) {
2239
+ return {
2240
+ content: [
2241
+ {
2242
+ type: "text",
2243
+ text: `Unknown agent(s): ${unknown.map((u) => u.agent).join(", ")}\n\nAvailable agents:\n${describeAgents(ctx.cwd)}`,
2244
+ },
2245
+ ],
2246
+ details: {},
2247
+ isError: true,
2248
+ };
2249
+ }
2250
+
2251
+ if (requested.some((r) => r.worktree) && !(await isGitRepo(workCwd(ctx)))) {
2252
+ return {
2253
+ content: [{ type: "text", text: 'isolation: "worktree" needs a git repository; this directory is not one.' }],
2254
+ details: {},
2255
+ isError: true,
2256
+ };
2257
+ }
2258
+
2259
+ prepared = requested.map((request) => {
2260
+ const taskId = generateTaskId();
2261
+ return {
2262
+ request,
2263
+ agentDef: request.fork ? undefined : agents.find((a) => a.name === request.agent),
2264
+ record: {
2265
+ name: request.name,
2266
+ agent: request.agent,
2267
+ taskId,
2268
+ sessionSearchDir: runSessionDir(ctx, taskId) ?? "",
2269
+ cwd: workCwd(ctx),
2270
+ model: request.model,
2271
+ thinking: request.thinking,
2272
+ depth: 0,
2273
+ ...(request.description ? { description: request.description } : {}),
2274
+ },
2275
+ };
2276
+ });
2277
+
2278
+ /**
2279
+ * Model resolution happens here, in the parent, against the real
2280
+ * registry — never in the child, whose `--model` fuzzy-matches across
2281
+ * every configured provider. The child is spawned with a concrete
2282
+ * `provider/id`; anything surprising (a fallback, a provider crossing)
2283
+ * is said out loud rather than happening silently.
2284
+ */
2285
+ const available = usableModels(ctx);
2286
+ const configuredDefault = applicableSubagentDefault(loadSubagentDefault(os.homedir()), ctx.model);
2287
+ for (const p of prepared) {
2288
+ // A fork inherits the parent transcript, so it must continue on THIS
2289
+ // conversation's exact model — never a configured default or the
2290
+ // automatic same-provider pick, which would move the inherited context
2291
+ // onto a different (often weaker) model. Leaving model undefined makes
2292
+ // the forked session restore its own model. (Per-call `model` on a fork
2293
+ // is already rejected above.)
2294
+ if (p.request.fork) continue;
2295
+ // No per-call or agent-frontmatter override → the automatic default,
2296
+ // which is stable for the session, so reuse the cached resolution
2297
+ // instead of re-ranking the catalog per spawn. An override varies per
2298
+ // call and always resolves fresh.
2299
+ const resolution =
2300
+ p.request.model || p.agentDef?.model
2301
+ ? resolveSubagentModel({
2302
+ requested: p.request.model,
2303
+ agentModel: p.agentDef?.model,
2304
+ configuredDefault: configuredDefault,
2305
+ sessionModel: ctx.model,
2306
+ available,
2307
+ requireImageInput: supportsImageInput(ctx.model),
2308
+ })
2309
+ : resolveAutoDefault(ctx.model, available, configuredDefault);
2310
+ if (resolution.unresolved) {
2311
+ // The main model chose this string; the menu lets it retry.
2312
+ const fallback = resolveSubagentModel({
2313
+ configuredDefault: configuredDefault,
2314
+ sessionModel: ctx.model,
2315
+ available,
2316
+ requireImageInput: supportsImageInput(ctx.model),
2317
+ });
2318
+ return {
2319
+ content: [
2320
+ {
2321
+ type: "text",
2322
+ text:
2323
+ `${resolution.unresolvedReason ?? `Unknown model "${resolution.unresolved}" — no available model matches it.`}\n\n` +
2324
+ subagentModelsReminder({
2325
+ available,
2326
+ sessionModel: ctx.model,
2327
+ defaultModel: fallback.model,
2328
+ defaultSource: fallback.source,
2329
+ requireImageInput: supportsImageInput(ctx.model),
2330
+ }),
2331
+ },
2332
+ ],
2333
+ details: {},
2334
+ isError: true,
2335
+ };
2336
+ }
2337
+ const gate = expensiveModelGate(resolution, ctx.model, params.allow_expensive);
2338
+ if (gate) {
2339
+ const fallback = resolveSubagentModel({
2340
+ configuredDefault: configuredDefault,
2341
+ sessionModel: ctx.model,
2342
+ available,
2343
+ requireImageInput: supportsImageInput(ctx.model),
2344
+ });
2345
+ return {
2346
+ content: [
2347
+ {
2348
+ type: "text",
2349
+ text:
2350
+ `${gate}\n` +
2351
+ "If the user explicitly asked for this model, retry with allow_expensive: true; " +
2352
+ "otherwise pick a cheaper model or omit the field.\n\n" +
2353
+ subagentModelsReminder({
2354
+ available,
2355
+ sessionModel: ctx.model,
2356
+ defaultModel: fallback.model,
2357
+ defaultSource: fallback.source,
2358
+ requireImageInput: supportsImageInput(ctx.model),
2359
+ }),
2360
+ },
2361
+ ],
2362
+ details: {},
2363
+ isError: true,
2364
+ };
2365
+ }
2366
+ for (const notice of resolution.notices) notifyModelOnce(ctx, notice);
2367
+ for (const note of subagentModelNotes(resolution)) modelNotes.add(note);
2368
+ const resolved = resolution.model ? modelSpec(resolution.model) : undefined;
2369
+ p.request.model = resolved;
2370
+ p.request.fallbackModel = spawnFallbackModel(resolved, resolution.source, ctx.model);
2371
+ p.record.model = resolved;
2372
+ }
2373
+
2374
+ for (const p of prepared) registry.add(p.record);
2375
+ landed = true;
2376
+ } finally {
2377
+ if (!landed) for (const request of requested) registry.release(request.name);
2378
+ }
1729
2379
  const records = prepared.map((p) => p.record);
1730
2380
 
1731
2381
  // One-shot modes (`-p` / `--mode json`) exit when the turn settles, so
@@ -1742,6 +2392,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1742
2392
  onUpdate?.({ content: [{ type: "text", text: line }], details: {} });
1743
2393
  };
1744
2394
  report();
2395
+ mainCallFor.set(prepared[0].record.taskId, toolCallId);
1745
2396
  const result = await executeRun(prepared[0], ctx, signal, sessionFile ?? undefined, undefined, (toolCalls, text, usage) => {
1746
2397
  progress = { toolCalls, text, usage };
1747
2398
  report();
@@ -1753,7 +2404,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1753
2404
  actions: result.actions ?? [],
1754
2405
  } satisfies SubagentActionsPayload);
1755
2406
  const stats = formatStats(result.toolCalls, result.usage);
1756
- const worktreeNote = result.worktreePath ? `\n\n(Changes left in worktree ${result.worktreePath} — review or merge them.)` : "";
2407
+ const worktreeNote = result.worktreePath ? `\n\n${keptWorktreeNote(result.worktreePath, result.worktreeBranch)}` : "";
1757
2408
  // The report arrives inline with no frame, no task id and no note, so a
1758
2409
  // model told to "retrieve the result with task_output" chased an id that
1759
2410
  // was never registered (WEAK-MODEL-REVIEW-2026-09-06 M3). Say it up
@@ -1770,7 +2421,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1770
2421
  content: [
1771
2422
  {
1772
2423
  type: "text",
1773
- text: `${notes.length ? `${notes.join("\n")}\n\n` : ""}${inlineNote}\n\n${result.output}${worktreeNote}\n\n(${stats})`,
2424
+ text: `${notes.length ? `${notes.join("\n")}\n\n` : ""}${inlineNote}\n\n${bounded(result.output, `${prepared[0].record.taskId}-report`, OUTPUT_CAP)}${worktreeNote}\n\n(${stats})`,
1774
2425
  },
1775
2426
  ],
1776
2427
  details: { results: [result], agentRuns: records },
@@ -1783,181 +2434,16 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1783
2434
  // steered system notification, and the child stays resident so
1784
2435
  // SendMessage can reach it live (steer mid-turn, prompt when idle).
1785
2436
  const lines: string[] = [...renamed, ...modelNotes];
1786
- const runtime = await getRuntime(ctx);
2437
+ const runtime = await runtimeOrError(ctx);
2438
+ if (typeof runtime === "string") {
2439
+ // Nothing launched: forget the runs so list_agents and SendMessage never offer them.
2440
+ for (const p of prepared) registry.remove(p.record.taskId);
2441
+ return { content: [{ type: "text", text: `Could not start the agent: ${runtime}` }], details: {}, isError: true };
2442
+ }
1787
2443
  for (const p of prepared) {
1788
- let worktree: Worktree | undefined;
1789
- if (p.request.worktree) {
1790
- try {
1791
- worktree = await isolateInWorktree(ctx, p.record, p.request.name);
1792
- } catch (error) {
1793
- lines.push(`✗ ${p.record.name}: could not create a worktree: ${(error as Error).message}`);
1794
- continue;
1795
- }
1796
- }
1797
-
1798
- const logPath = outputLogPath(p.record);
1799
- let lastLogWrite = 0;
1800
- let finish!: () => void;
1801
- const finished = new Promise<void>((resolve) => {
1802
- finish = resolve;
1803
- });
1804
-
1805
- // The child's final output. task.output() falls back to this
1806
- // because handle.snapshot().text can be blank at a turn boundary —
1807
- // the same reason the log write below uses `|| outcome.output`.
1808
- // Keeps task_output consistent with the log and the completion
1809
- // notification rather than showing an empty body in that case.
1810
- let lastOutput = "";
1811
- // The initial task's own report. task_output for THIS task must return the
1812
- // first turn's reply — not the resident's growing multi-turn transcript —
1813
- // the same way a SendMessage task returns one reply (review S12).
1814
- let firstTurnOutput: string | undefined;
1815
- const live = trackLiveRun(p.record, p.request);
1816
- const resident: Resident = { handle: undefined as never, startedAt: Date.now(), turnHandlers: [] };
1817
- const worktreeNote = worktree
1818
- ? `\n\n(Running in worktree ${worktree.path} — kept while the agent stays resident.)`
1819
- : "";
1820
- resident.turnHandlers.push((outcome, review, stopped) => {
1821
- task.status = stopped ? "stopped" : outcome.failed ? "failed" : "completed";
1822
- task.finishedAt = Date.now();
1823
- firstTurnOutput = outcome.output;
1824
- finish();
1825
- notifyHandBack({
1826
- from: p.record.taskId,
1827
- taskId: p.record.taskId,
1828
- name: p.record.name,
1829
- toolUseId: toolCallId,
1830
- outputFile: logPath,
1831
- outcome,
1832
- stopped,
1833
- startedAt: task.startedAt,
1834
- report: `${bounded(outcome.output, `${p.record.taskId}-report`, OUTPUT_CAP)}${worktreeNote}`,
1835
- review,
1836
- });
1837
- });
1838
-
1839
- // Idle reaper: every run is a resident now, so without this a long
1840
- // session accumulates one live AgentSession (extension instances,
1841
- // message array, MCP tool refs) per delegation for its whole life.
1842
- // After RESIDENT_IDLE_MS idle the session is released quietly; the
1843
- // agent stays reachable — SendMessage resumes it from its session
1844
- // file. Worktree residents are exempt: release would remove the
1845
- // worktree a resume still needs. Armed from every turn end.
1846
- let reaper: ReturnType<typeof setTimeout> | undefined;
1847
- const armReaper = () => {
1848
- if (worktree) return;
1849
- if (reaper) clearTimeout(reaper);
1850
- reaper = setTimeout(() => {
1851
- reaper = undefined;
1852
- if (handle.exited()) return;
1853
- if (handle.busy()) {
1854
- armReaper();
1855
- return;
1856
- }
1857
- handle.release();
1858
- }, RESIDENT_IDLE_MS);
1859
- reaper.unref?.();
1860
- };
1861
- // Captured as a string: onExit runs from a `.finally` long after this
1862
- // turn's ctx may be stale (review S5).
1863
- const parentCwd = ctx.cwd;
1864
- const forkPrompt = p.request.fork ? ctx.getSystemPrompt() : undefined;
1865
- if (forkPrompt !== undefined) persistForkPrompt(p.record, forkPrompt);
1866
- // No `signal` here on purpose: a resident outlives the spawning turn and
1867
- // is stopped through task_stop / the panel, not by the turn ending (S15).
1868
- const handle = await runtime.runResident({
1869
- name: p.record.name,
1870
- agent: p.agentDef,
1871
- cwd: p.record.cwd,
1872
- forkFrom: p.request.fork ? (sessionFile ?? undefined) : undefined,
1873
- parentSystemPrompt: forkPrompt,
1874
- sessionDir: p.record.sessionSearchDir || undefined,
1875
- model: p.request.model,
1876
- fallbackModel: p.request.fallbackModel,
1877
- thinking: p.request.thinking,
1878
- onProgress: (toolCalls, text, usage) => {
1879
- live.progress(toolCalls, usage);
1880
- // Throttled: onProgress fires per tool call/message with the whole
1881
- // turn text so far, and this path now carries EVERY run — an
1882
- // unthrottled sync rewrite would be O(n²) bytes on the hot path.
1883
- // onTurnEnd below flushes the final state, so the tail is never lost.
1884
- if (logPath && text && Date.now() - lastLogWrite > LOG_WRITE_INTERVAL_MS) {
1885
- lastLogWrite = Date.now();
1886
- writeFileSync(logPath, text);
1887
- }
1888
- },
1889
- sink: live.sink,
1890
- onMessageToMain: (message) => notifyAgentMessage(p.record.taskId, p.record.name, message),
1891
- extraTools: spawnToolsFor(p.record),
1892
- onTurnEnd: (outcome) => {
1893
- registry.sessionFileFor(p.record);
1894
- live.settle();
1895
- lastOutput = resident.handle.snapshot().text || outcome.output;
1896
- if (logPath) writeFileSync(logPath, lastOutput);
1897
- // The handler is claimed now (so a message arriving mid-review pairs
1898
- // with the NEXT turn), but runs only once auto mode's hand-back
1899
- // review of this turn's action sequence has answered, so the
1900
- // verdict rides in the same notification as the report instead of
1901
- // trailing it. The wait is bounded (hand-back-review.ts) and
1902
- // answered synchronously when auto mode is off.
1903
- const handler = resident.turnHandlers.shift();
1904
- // A stop or resume during review must not change this turn's outcome.
1905
- const stopped = stoppedTaskIds.has(p.record.taskId);
1906
- armReaper();
1907
- void awaitHandBackReview(pi.events, p.record, outcome.actions).then((review) => {
1908
- if (handler) {
1909
- handler(outcome, review, stopped);
1910
- } else {
1911
- // A turn nobody is waiting on (e.g. a steer that raced past its
1912
- // target turn and ran on its own) must still surface.
1913
- notifyHandBack({
1914
- from: p.record.taskId,
1915
- taskId: p.record.taskId,
1916
- name: p.record.name,
1917
- toolUseId: toolCallId,
1918
- outputFile: logPath,
1919
- outcome,
1920
- stopped,
1921
- startedAt: resident.startedAt,
1922
- report: bounded(outcome.output, `${p.record.taskId}-update-${Date.now()}`, OUTPUT_CAP),
1923
- review,
1924
- details: { update: true },
1925
- });
1926
- }
1927
- });
1928
- },
1929
- onExit: () => {
1930
- if (reaper) clearTimeout(reaper);
1931
- live.finish(stoppedTaskIds.has(p.record.taskId) ? "stopped" : false);
1932
- if (residents.get(p.record.taskId) === resident) residents.delete(p.record.taskId);
1933
- liveHandles.delete(p.record.taskId);
1934
- if (worktree) void cleanupWorktree(parentCwd, worktree);
1935
- },
1936
- });
1937
- resident.handle = handle;
1938
- residents.set(p.record.taskId, resident);
1939
- liveHandles.set(p.record.taskId, handle);
1940
-
1941
- const task: BackgroundTask = {
1942
- id: p.record.taskId,
1943
- kind: "subagent",
1944
- ownUI: true, // rendered live by the subagents panel's agent strip
1945
- description: `${p.record.name}: ${p.request.task.slice(0, 80)}`,
1946
- status: "running",
1947
- startedAt: Date.now(),
1948
- logPath,
1949
- output: () => firstTurnOutput ?? (handle.snapshot().text || lastOutput),
1950
- stop: () => stopAgent(p.record.taskId, handle),
1951
- resident: () => !handle.exited(),
1952
- finished,
1953
- };
1954
- pi.events.emit(TASK_REGISTER_CHANNEL, task);
1955
- void handle.send(frameTask(p.request, worktree, parentCwd));
1956
-
1957
- spawnedThisLoop.add(p.record.taskId);
1958
- lines.push(
1959
- `⏳ ${p.record.name} (task ${p.record.taskId}) running in background${logPath ? ` — interim output readable at ${logPath}` : ""}`,
1960
- );
2444
+ const { launched, line } = await launchResident(p, ctx, runtime, { sessionFile: sessionFile ?? undefined, toolCallId });
2445
+ if (launched) spawnedThisLoop.add(p.record.taskId);
2446
+ lines.push(line);
1961
2447
  }
1962
2448
  return {
1963
2449
  content: [
@@ -1970,231 +2456,97 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1970
2456
  };
1971
2457
  },
1972
2458
  });
2459
+ const setAgentForm = registerVariantTool<DescriptionForm>(pi, "short", (form) => ({ ...agentTool, description: agentDescription(form, projectConfigDirName()) }));
2460
+ followDescriptionForm(pi, setAgentForm);
1973
2461
 
1974
- pi.registerTool({
1975
- name: "SendMessage",
1976
- label: "Send Message",
1977
- ...ccToolRenderers<{ to?: string; summary?: string; message?: string }>("Send Message", {
1978
- title: (a) => (a?.to ? `to ${a.to}${a.summary ? `: ${a.summary}` : ""}` : undefined),
1979
- }),
1980
- description:
1981
- 'Send a message to a previously spawned agent, addressed by the name from its spawn result (or its task id). To discover the names of running or finished agents, use list_agents (deferred — load it with tool_search select:list_agents). A resident background agent is reached live (mid-turn the message is steered into its current work; when idle it starts a new turn); a finished agent is resumed from its session with full context. Replies arrive as task notifications. (A subagent reporting back to the main conversation uses its own SendMessage with to: "main".)',
1982
- parameters: Type.Object({
1983
- to: Type.String({ description: "Agent name (or task id) from a previous Agent run" }),
1984
- message: Type.String({ description: "Plain text message for the agent" }),
1985
- summary: Type.Optional(Type.String({ description: "5-10 word preview shown in the UI" })),
1986
- }),
1987
- async execute(toolCallId, params, _signal, _onUpdate, ctx) {
1988
- if (params.to === "main") {
1989
- // The main conversation's SendMessage only addresses spawned agents; the
1990
- // "main" recipient exists only on a subagent's own injected SendMessage.
1991
- return {
1992
- content: [{ type: "text", text: 'You are the main conversation — "main" is only a valid recipient from inside a subagent.' }],
1993
- details: {},
1994
- isError: true,
1995
- };
1996
- }
2462
+ const SendMessageParams = Type.Object({
2463
+ to: Type.String({ description: "Agent name (or task id) from a previous Agent run" }),
2464
+ message: Type.String({ description: "Plain text message for the agent" }),
2465
+ summary: Type.Optional(Type.String({ description: "5-10 word preview shown in the UI" })),
2466
+ });
1997
2467
 
1998
- const record = registry.resolve(params.to);
1999
- if (!record) {
2000
- const known = registry.names().join(", ") || "(none)";
2001
- return {
2002
- content: [
2003
- {
2004
- type: "text",
2005
- text: `No run named "${params.to}". SendMessage only reaches agent runs already started this session — address them by their run name or task id, not by a catalog agent name. Start one with the Agent tool first if you haven't. Known runs: ${known}.`,
2006
- },
2007
- ],
2008
- details: {},
2009
- isError: true,
2010
- };
2011
- }
2012
- // Resident background agent: reach its in-process session live.
2013
- const resident = residents.get(record.taskId);
2014
- if (resident && !resident.handle.exited()) {
2015
- if (resident.handle.busy()) {
2016
- let delivery: "started" | "steered";
2017
- try {
2018
- delivery = await resident.handle.send(params.message);
2019
- } catch (error) {
2020
- return {
2021
- content: [{ type: "text", text: `Could not deliver the message to ${record.name}: ${(error as Error).message}` }],
2022
- details: {},
2023
- isError: true,
2024
- };
2025
- }
2026
- if (delivery === "steered") {
2027
- return {
2028
- content: [
2029
- {
2030
- type: "text",
2031
- text: `Message steered into ${record.name}'s running turn — it will be taken into account before the turn completes, and the turn's completion notification will reflect it.`,
2032
- },
2033
- ],
2034
- details: { agentRuns: [record], steered: true },
2035
- };
2036
- }
2037
- // The turn had just ended (settle window): the message rides the next
2038
- // turn, whose reply nobody has claimed yet — announce it as an update.
2039
- spawnedThisLoop.add(record.taskId); // arm the anti-fabrication backstop (WEAK-MODEL-REVIEW H1)
2468
+ /**
2469
+ * SendMessage's body, typed on the plain extension context: the agent view
2470
+ * also sends from an input handler, which has no tool context (pi 0.99's
2471
+ * `ExtensionToolContext` adds nested tool calls this never makes).
2472
+ */
2473
+ const sendMessage = async (toolCallId: string, params: Static<typeof SendMessageParams>, signal: AbortSignal | undefined, ctx: ExtensionContext): Promise<AgentToolResult<unknown>> => {
2474
+ if (params.to === "main") {
2475
+ // The main conversation's SendMessage only addresses spawned agents; the
2476
+ // "main" recipient exists only on a subagent's own injected SendMessage.
2477
+ return {
2478
+ content: [{ type: "text", text: 'You are the main conversation — "main" is only a valid recipient from inside a subagent.' }],
2479
+ details: {},
2480
+ isError: true,
2481
+ };
2482
+ }
2483
+
2484
+ const record = registry.resolve(params.to);
2485
+ if (!record) {
2486
+ // Recorded runs only: a name reserved by a spawn that then failed validation is not addressable.
2487
+ const known = registry.list().map((run) => run.name).join(", ") || "(none)";
2488
+ return {
2489
+ content: [
2490
+ {
2491
+ type: "text",
2492
+ text: `No run named "${params.to}". SendMessage only reaches agent runs already started this session — address them by their run name or task id, not by a catalog agent name. Start one with the Agent tool first if you haven't. Known runs: ${known}.`,
2493
+ },
2494
+ ],
2495
+ details: {},
2496
+ isError: true,
2497
+ };
2498
+ }
2499
+ // Resident background agent: reach its in-process session live.
2500
+ const resident = residents.get(record.taskId);
2501
+ if (resident && !resident.handle.exited()) {
2502
+ // This call now starts (or joins) the agent's turn.
2503
+ mainCallFor.set(record.taskId, toolCallId);
2504
+ if (resident.handle.busy()) {
2505
+ let delivery: "started" | "steered";
2506
+ try {
2507
+ delivery = await resident.handle.send(params.message);
2508
+ } catch (error) {
2509
+ return {
2510
+ content: [{ type: "text", text: `Could not deliver the message to ${record.name}: ${(error as Error).message}` }],
2511
+ details: {},
2512
+ isError: true,
2513
+ };
2514
+ }
2515
+ if (delivery === "steered") {
2040
2516
  return {
2041
2517
  content: [
2042
2518
  {
2043
2519
  type: "text",
2044
- text: `${record.name}'s turn had just finished; the message starts its next turn. The reply will arrive as a task notification from ${record.name}. ${PENDING_RESULT_PROHIBITION}`,
2520
+ text: `Message steered into ${record.name}'s running turn — it will be taken into account before the turn completes, and the turn's completion notification will reflect it.`,
2045
2521
  },
2046
2522
  ],
2047
- details: { agentRuns: [record] },
2523
+ details: { agentRuns: [record], steered: true },
2048
2524
  };
2049
2525
  }
2050
-
2051
- const taskId = generateTaskId();
2052
- let finish!: () => void;
2053
- const finished = new Promise<void>((resolve) => {
2054
- finish = resolve;
2055
- });
2056
- // This task is ONE turn of the resident agent. Its output must be that
2057
- // turn's reply — the same text the reply notification carries — not the
2058
- // resident's whole multi-turn transcript, or task_output and the
2059
- // notification disagree (superset), which weak models conflate.
2060
- let replyOutput = "";
2061
- const task: BackgroundTask = {
2062
- id: taskId,
2063
- kind: "subagent",
2064
- ownUI: true, // rendered live by the subagents panel's agent strip
2065
- description: `message to ${record.name}${params.summary ? `: ${params.summary}` : ""}`,
2066
- status: "running",
2067
- startedAt: Date.now(),
2068
- output: () => replyOutput || resident.handle.snapshot().text,
2069
- stop: () => stopAgent(record.taskId, resident.handle),
2070
- resident: () => !resident.handle.exited(),
2071
- finished,
2072
- };
2073
- resident.turnHandlers.push((outcome, review, stopped) => {
2074
- task.status = stopped ? "stopped" : outcome.failed ? "failed" : "completed";
2075
- task.finishedAt = Date.now();
2076
- replyOutput = outcome.output;
2077
- finish();
2078
- // `<task-id>` is this message's task (what task_output resolves); `from=`
2079
- // stays the agent's id, the address a further SendMessage uses. The
2080
- // tracker's tokens and tool uses span the resident's life, so the
2081
- // duration does too.
2082
- notifyHandBack({
2083
- from: record.taskId,
2084
- taskId,
2085
- name: record.name,
2086
- toolUseId: toolCallId,
2087
- outputFile: outputLogPath(record),
2088
- outcome,
2089
- stopped,
2090
- startedAt: resident.startedAt,
2091
- report: bounded(outcome.output, `${taskId}-reply`, OUTPUT_CAP),
2092
- review,
2093
- });
2094
- });
2095
- pi.events.emit(TASK_REGISTER_CHANNEL, task);
2096
- void resident.handle.send(params.message);
2097
- // Same anti-fabrication guard as a fresh spawn (WEAK-MODEL-REVIEW H1):
2098
- // the reply arrives as a notification, so forbid predicting it and arm
2099
- // the agent_end backstop (keyed by the agent's persistent id).
2100
- spawnedThisLoop.add(record.taskId);
2526
+ // The turn had just ended (settle window): the message rides the next
2527
+ // turn, whose reply nobody has claimed yet — announce it as an update.
2528
+ spawnedThisLoop.add(record.taskId); // arm the anti-fabrication backstop (WEAK-MODEL-REVIEW H1)
2101
2529
  return {
2102
2530
  content: [
2103
2531
  {
2104
2532
  type: "text",
2105
- text: `Message sent to resident agent ${record.name} (task ${taskId}). The reply will arrive as a task notification on its own; inspect with task_output. ${PENDING_RESULT_PROHIBITION}`,
2533
+ text: `${record.name}'s turn had just finished; the message starts its next turn. The reply will arrive as a task notification from ${record.name}. ${PENDING_RESULT_PROHIBITION}`,
2106
2534
  },
2107
2535
  ],
2108
- details: { agentRuns: [record], taskId },
2109
- };
2110
- }
2111
-
2112
- if (runningIds.has(record.taskId)) {
2113
- return {
2114
- content: [
2115
- { type: "text", text: `Agent ${record.name} is still running — wait for its completion notification, then resend.` },
2116
- ],
2117
- details: {},
2118
- isError: true,
2119
- };
2120
- }
2121
- const sessionFile = registry.sessionFileFor(record);
2122
- // A fork has no agent definition: its identity is the parent's prompt at
2123
- // spawn time, persisted beside its session. Without it a resume would run
2124
- // on pi's stock prompt and default tools — refuse instead (review S6).
2125
- const forkPrompt = record.agent === FORK_AGENT ? readForkPrompt(record) : undefined;
2126
- if (record.agent === FORK_AGENT && forkPrompt === undefined) {
2127
- return {
2128
- content: [
2129
- {
2130
- type: "text",
2131
- text: `Agent ${record.name} is a fork whose system prompt was not persisted (it ran before fork resume was supported, or its files were removed), so it cannot be resumed faithfully. Start a fresh run instead.`,
2132
- },
2133
- ],
2134
- details: {},
2135
- isError: true,
2136
- };
2137
- }
2138
- if (!sessionFile) {
2139
- return {
2140
- content: [
2141
- { type: "text", text: `Agent ${record.name} has no persisted session to resume (it may have run before session persistence, or its files were removed).` },
2142
- ],
2143
- details: {},
2144
- isError: true,
2536
+ details: { agentRuns: [record] },
2145
2537
  };
2146
2538
  }
2147
2539
 
2148
- // A worktree run whose agent left no changes had its worktree removed at
2149
- // exit, but the record (persisted in the tool result) still points there.
2150
- // Resume in the parent cwd instead of a deleted directory, say so in the
2151
- // reply, and fix the record so later messages don't repeat the note (M2).
2152
- let relocationNote = "";
2153
- if (!existsSync(record.cwd)) {
2154
- relocationNote = `[${record.name}'s working directory ${record.cwd} no longer exists${record.worktree ? " (its isolation worktree was removed when the run left no changes)" : ""}; this turn ran in ${ctx.cwd}.]\n\n`;
2155
- record.cwd = ctx.cwd;
2156
- record.worktree = undefined;
2157
- }
2158
-
2159
2540
  const taskId = generateTaskId();
2160
2541
  let finish!: () => void;
2161
2542
  const finished = new Promise<void>((resolve) => {
2162
2543
  finish = resolve;
2163
2544
  });
2164
-
2165
- runningIds.add(record.taskId);
2166
- // Re-enter the panel: the finished run's entry flips back to running (or
2167
- // registers fresh after a session resume) and streams this turn live.
2168
- const live = trackLiveRun(record, {
2169
- agent: record.agent,
2170
- name: record.name,
2171
- task: params.message,
2172
- model: record.model,
2173
- thinking: record.thinking,
2174
- });
2175
- const runtime = await getRuntime(ctx);
2176
- const handle = runtime.run({
2177
- name: record.name,
2178
- agent: loadAgents(ctx.cwd).find((a) => a.name === record.agent),
2179
- task: params.message,
2180
- cwd: record.cwd,
2181
- sessionFile,
2182
- parentSystemPrompt: forkPrompt,
2183
- model: record.model,
2184
- // Resume degrades to the session model if the recorded model has become
2185
- // unavailable since the original run, rather than failing the resume
2186
- // outright (the note surfaces the swap). Undefined for a fork (record.model
2187
- // unset — it inherits) or when the record already is the session model.
2188
- fallbackModel:
2189
- record.model && ctx.model && record.model !== modelSpec(ctx.model) ? modelSpec(ctx.model) : undefined,
2190
- thinking: record.thinking,
2191
- onProgress: (toolCalls, _text, usage) => live.progress(toolCalls, usage),
2192
- sink: live.sink,
2193
- onMessageToMain: (message) => notifyAgentMessage(record.taskId, record.name, message),
2194
- extraTools: spawnToolsFor(record),
2195
- });
2196
- liveHandles.set(record.taskId, handle);
2197
-
2545
+ // This task is ONE turn of the resident agent. Its output must be that
2546
+ // turn's reply — the same text the reply notification carries — not the
2547
+ // resident's whole multi-turn transcript, or task_output and the
2548
+ // notification disagree (superset), which weak models conflate.
2549
+ let replyOutput = "";
2198
2550
  const task: BackgroundTask = {
2199
2551
  id: taskId,
2200
2552
  kind: "subagent",
@@ -2202,43 +2554,415 @@ export default function subagentsExtension(pi: ExtensionAPI) {
2202
2554
  description: `message to ${record.name}${params.summary ? `: ${params.summary}` : ""}`,
2203
2555
  status: "running",
2204
2556
  startedAt: Date.now(),
2205
- output: () => handle.snapshot().text,
2206
- stop: () => stopAgent(record.taskId, handle),
2557
+ output: () => replyOutput || resident.handle.snapshot().text,
2558
+ stop: () => stopAgent(record.taskId, resident.handle),
2559
+ resident: () => !resident.handle.exited(),
2207
2560
  finished,
2208
2561
  };
2209
- pi.events.emit(TASK_REGISTER_CHANNEL, task);
2210
-
2211
- void handle.result.then((outcome) => {
2212
- runningIds.delete(record.taskId);
2213
- const stopped = stoppedTaskIds.has(record.taskId);
2214
- live.finish(stopped ? "stopped" : Boolean(outcome.failed));
2562
+ resident.turnHandlers.push((outcome, review, stopped) => {
2215
2563
  task.status = stopped ? "stopped" : outcome.failed ? "failed" : "completed";
2216
2564
  task.finishedAt = Date.now();
2565
+ replyOutput = outcome.output;
2217
2566
  finish();
2567
+ // `<task-id>` is this message's task (what task_output resolves); `from=`
2568
+ // stays the agent's id, the address a further SendMessage uses. The
2569
+ // tracker's tokens and tool uses span the resident's life, so the
2570
+ // duration does too.
2218
2571
  notifyHandBack({
2219
2572
  from: record.taskId,
2220
2573
  taskId,
2221
2574
  name: record.name,
2222
2575
  toolUseId: toolCallId,
2576
+ outputFile: outputLogPath(record),
2223
2577
  outcome,
2224
2578
  stopped,
2225
- startedAt: task.startedAt,
2226
- report: `${relocationNote}${bounded(outcome.output, `${taskId}-reply`, OUTPUT_CAP)}`,
2227
- review: undefined,
2579
+ startedAt: resident.startedAt,
2580
+ report: bounded(outcome.output, `${taskId}-reply`, OUTPUT_CAP),
2581
+ review,
2228
2582
  });
2229
2583
  });
2230
-
2231
- spawnedThisLoop.add(record.taskId); // arm the anti-fabrication backstop (WEAK-MODEL-REVIEW H1)
2584
+ pi.events.emit(TASK_REGISTER_CHANNEL, task);
2585
+ void resident.handle.send(params.message);
2586
+ // Same anti-fabrication guard as a fresh spawn (WEAK-MODEL-REVIEW H1):
2587
+ // the reply arrives as a notification, so forbid predicting it and arm
2588
+ // the agent_end backstop (keyed by the agent's persistent id).
2589
+ spawnedThisLoop.add(record.taskId);
2232
2590
  return {
2233
2591
  content: [
2234
2592
  {
2235
2593
  type: "text",
2236
- text: `Message sent to ${record.name} (task ${taskId}). The reply will arrive as a task notification on its own; inspect with task_output. ${PENDING_RESULT_PROHIBITION}`,
2594
+ text: `Message sent to resident agent ${record.name} (task ${taskId}). The reply will arrive as a task notification on its own; inspect with task_output. ${PENDING_RESULT_PROHIBITION}`,
2237
2595
  },
2238
2596
  ],
2239
2597
  details: { agentRuns: [record], taskId },
2240
2598
  };
2241
- },
2599
+ }
2600
+
2601
+ if (runningIds.has(record.taskId)) {
2602
+ return {
2603
+ content: [
2604
+ { type: "text", text: `Agent ${record.name} is still running — wait for its completion notification, then resend.` },
2605
+ ],
2606
+ details: {},
2607
+ isError: true,
2608
+ };
2609
+ }
2610
+ const sessionFile = registry.sessionFileFor(record);
2611
+ // A fork has no agent definition: its identity is the parent's prompt at
2612
+ // spawn time, persisted beside its session. Without it a resume would run
2613
+ // on pi's stock prompt and default tools — refuse instead (review S6).
2614
+ const forkPrompt = record.agent === FORK_AGENT ? readForkPrompt(record) : undefined;
2615
+ if (record.agent === FORK_AGENT && forkPrompt === undefined) {
2616
+ return {
2617
+ content: [
2618
+ {
2619
+ type: "text",
2620
+ text: `Agent ${record.name} is a fork whose system prompt was not persisted (it ran before fork resume was supported, or its files were removed), so it cannot be resumed faithfully. Start a fresh run instead.`,
2621
+ },
2622
+ ],
2623
+ details: {},
2624
+ isError: true,
2625
+ };
2626
+ }
2627
+ if (!sessionFile) {
2628
+ return {
2629
+ content: [
2630
+ { type: "text", text: `Agent ${record.name} has no persisted session to resume (it may have run before session persistence, or its files were removed).` },
2631
+ ],
2632
+ details: {},
2633
+ isError: true,
2634
+ };
2635
+ }
2636
+
2637
+ // A worktree run whose agent left no changes had its worktree removed at
2638
+ // exit, but the record (persisted in the tool result) still points there.
2639
+ // Resume in the parent cwd instead of a deleted directory, say so in the
2640
+ // reply, and fix the record so later messages don't repeat the note (M2).
2641
+ let relocationNote = "";
2642
+ if (!existsSync(record.cwd)) {
2643
+ const cwd = workCwd(ctx);
2644
+ relocationNote = `[${record.name}'s working directory ${record.cwd} no longer exists${record.worktree ? " (its isolation worktree was removed when the run left no changes)" : ""}; this turn ran in ${cwd}.]\n\n`;
2645
+ record.cwd = cwd;
2646
+ record.worktree = undefined;
2647
+ }
2648
+
2649
+ // Before the run is marked running (L8, as in executeRun).
2650
+ const runtime = await runtimeOrError(ctx);
2651
+ if (typeof runtime === "string") {
2652
+ return { content: [{ type: "text", text: `Could not resume ${record.name}: ${runtime}` }], details: {}, isError: true };
2653
+ }
2654
+
2655
+ const taskId = generateTaskId();
2656
+ let finish!: () => void;
2657
+ const finished = new Promise<void>((resolve) => {
2658
+ finish = resolve;
2659
+ });
2660
+
2661
+ runningIds.add(record.taskId);
2662
+ // Re-enter the panel: the finished run's entry flips back to running (or
2663
+ // registers fresh after a session resume) and streams this turn live.
2664
+ const live = trackLiveRun(record, {
2665
+ agent: record.agent,
2666
+ name: record.name,
2667
+ task: params.message,
2668
+ model: record.model,
2669
+ thinking: record.thinking,
2670
+ });
2671
+ // One-shot modes (`-p`, `--mode json`) exit when the turn settles, so the
2672
+ // resumed turn runs blocking there and the reply is the tool result, as a
2673
+ // spawn's report is (SUBAGENTS-WORKFLOWS-REVIEW-2026-09-26 M3).
2674
+ const oneShot = !sessionOutlivesTurn(ctx.mode);
2675
+ mainCallFor.set(record.taskId, toolCallId);
2676
+ const handle = runtime.run({
2677
+ name: record.name,
2678
+ agent: loadAgents(ctx.cwd).find((a) => a.name === record.agent),
2679
+ task: params.message,
2680
+ cwd: record.cwd,
2681
+ sessionFile,
2682
+ parentSystemPrompt: forkPrompt,
2683
+ parentRequest: record.agent === FORK_AGENT ? readForkRequest(record) : undefined,
2684
+ // Blocking, so the call's own abort stops it; a background turn is stopped by task_stop.
2685
+ signal: oneShot ? signal : undefined,
2686
+ model: record.model,
2687
+ // Resume degrades to the session model if the recorded model has become
2688
+ // unavailable since the original run, rather than failing the resume
2689
+ // outright (the note surfaces the swap). Undefined for a fork (record.model
2690
+ // unset — it inherits) or when the record already is the session model.
2691
+ fallbackModel:
2692
+ record.model && ctx.model && record.model !== modelSpec(ctx.model) ? modelSpec(ctx.model) : undefined,
2693
+ thinking: record.thinking,
2694
+ onProgress: (toolCalls, _text, usage) => live.progress(toolCalls, usage),
2695
+ sink: live.sink,
2696
+ onMessageToMain: (message) => notifyAgentMessage(record.taskId, record.name, message),
2697
+ extraTools: spawnToolsFor(record),
2698
+ mainToolCallId: () => mainCallOf(record.taskId),
2699
+ });
2700
+ liveHandles.set(record.taskId, handle);
2701
+
2702
+ if (oneShot) {
2703
+ const outcome = await handle.result;
2704
+ runningIds.delete(record.taskId);
2705
+ live.finish(stoppedTaskIds.has(record.taskId) ? "stopped" : Boolean(outcome.failed));
2706
+ // Auto mode reviews the turn's actions; the gate attaches the verdict to this tool result.
2707
+ pi.events.emit(SUBAGENT_ACTIONS_CHANNEL, { toolCallId, actions: outcome.actions } satisfies SubagentActionsPayload);
2708
+ return {
2709
+ content: [
2710
+ {
2711
+ type: "text",
2712
+ text:
2713
+ `${record.name}: ${oneShotNote("agent's turn")} Its reply follows here; there is no background task to poll.\n\n` +
2714
+ `${relocationNote}${bounded(outcome.output, `${taskId}-reply`, OUTPUT_CAP)}\n\n(${formatStats(outcome.toolCalls, outcome.usage)})`,
2715
+ },
2716
+ ],
2717
+ details: { agentRuns: [record] },
2718
+ isError: outcome.failed ?? false,
2719
+ };
2720
+ }
2721
+
2722
+ let settledOutput: string | undefined;
2723
+ const task: BackgroundTask = {
2724
+ id: taskId,
2725
+ kind: "subagent",
2726
+ ownUI: true, // rendered live by the subagents panel's agent strip
2727
+ description: `message to ${record.name}${params.summary ? `: ${params.summary}` : ""}`,
2728
+ status: "running",
2729
+ startedAt: Date.now(),
2730
+ output: () => settledOutput ?? handle.snapshot().text,
2731
+ stop: () => stopAgent(record.taskId, handle),
2732
+ finished,
2733
+ };
2734
+ pi.events.emit(TASK_REGISTER_CHANNEL, task);
2735
+
2736
+ void handle.result.then(async (outcome) => {
2737
+ runningIds.delete(record.taskId);
2738
+ // Read before the review: a stop or resume during it must not change this turn's outcome.
2739
+ const stopped = stoppedTaskIds.has(record.taskId);
2740
+ live.finish(stopped ? "stopped" : Boolean(outcome.failed));
2741
+ // Auto mode reviews this turn's actions before the reply goes out, as it
2742
+ // does for a resident's turn; a resumed turn once skipped it
2743
+ // (SUBAGENTS-WORKFLOWS-REVIEW-2026-09-26 M2). The task settles after the
2744
+ // review, so task_output never returns the reply ahead of its verdict.
2745
+ const review = await awaitHandBackReview(pi.events, record, outcome.actions, undefined, mainCallOf(record.taskId));
2746
+ // The outcome includes SubagentHandback and startup/abort errors;
2747
+ // the live snapshot only contains assistant text, not the final report.
2748
+ settledOutput = `${relocationNote}${outcome.output}`;
2749
+ task.status = stopped ? "stopped" : outcome.failed ? "failed" : "completed";
2750
+ task.finishedAt = Date.now();
2751
+ finish();
2752
+ notifyHandBack({
2753
+ from: record.taskId,
2754
+ taskId,
2755
+ name: record.name,
2756
+ toolUseId: toolCallId,
2757
+ outcome,
2758
+ stopped,
2759
+ startedAt: task.startedAt,
2760
+ report: `${relocationNote}${bounded(outcome.output, `${taskId}-reply`, OUTPUT_CAP)}`,
2761
+ review,
2762
+ });
2763
+ });
2764
+
2765
+ spawnedThisLoop.add(record.taskId); // arm the anti-fabrication backstop (WEAK-MODEL-REVIEW H1)
2766
+ return {
2767
+ content: [
2768
+ {
2769
+ type: "text",
2770
+ text: `Message sent to ${record.name} (task ${taskId}). The reply will arrive as a task notification on its own; inspect with task_output. ${PENDING_RESULT_PROHIBITION}`,
2771
+ },
2772
+ ],
2773
+ details: { agentRuns: [record], taskId },
2774
+ };
2775
+ };
2776
+
2777
+ const sendMessageTool = defineTool({
2778
+ name: "SendMessage",
2779
+ label: "Send Message",
2780
+ ...ccToolRenderers<{ to?: string; summary?: string; message?: string }>("Send Message", {
2781
+ title: (a) => (a?.to ? `to ${a.to}${a.summary ? `: ${a.summary}` : ""}` : undefined),
2782
+ }),
2783
+ description:
2784
+ 'Send a message to a previously spawned agent, addressed by the name from its spawn result (or its task id). To discover the names of running or finished agents, use list_agents. A resident background agent is reached live (mid-turn the message is steered into its current work; when idle it starts a new turn); a finished agent is resumed from its session with full context. Replies arrive as task notifications. (A subagent reporting back to the main conversation uses its own SendMessage with to: "main".)',
2785
+ parameters: SendMessageParams,
2786
+ execute: (toolCallId, params, signal, _onUpdate, ctx) => sendMessage(toolCallId, params, signal, ctx),
2787
+ });
2788
+ pi.registerTool(sendMessageTool);
2789
+
2790
+ /**
2791
+ * What the user submits while viewing an agent goes to that agent, as in
2792
+ * Claude Code (findings §40): Claude Code's mid-turn wrapper, steered into a
2793
+ * running turn or starting one on an idle resident, whose reply notifies
2794
+ * under the agent's own task id. A released or finished agent resumes
2795
+ * through SendMessage's path. Slash commands, skills and `!` shell lines
2796
+ * keep their main-conversation meaning.
2797
+ */
2798
+ const deliverFromView = async (ctx: ExtensionContext, taskId: string, text: string) => {
2799
+ const record = registry.resolve(taskId);
2800
+ const fail = (reason: string) => ctx.ui.notify(`Could not send the message to ${record?.name ?? "the agent"}: ${reason}`, "error");
2801
+ if (!record) return fail("the agent is gone.");
2802
+ const wrapped = userMessageForAgent(text);
2803
+ const resident = residents.get(record.taskId);
2804
+ if (resident && !resident.handle.exited()) {
2805
+ liveRuns.block(record.taskId, { kind: "user", text });
2806
+ try {
2807
+ await resident.handle.send(wrapped);
2808
+ } catch (error) {
2809
+ fail((error as Error).message);
2810
+ }
2811
+ return;
2812
+ }
2813
+ const result = await sendMessage(`view-${Date.now()}`, { to: record.taskId, message: wrapped }, undefined, ctx);
2814
+ if (result.isError) fail(result.content.map((block) => (block.type === "text" ? block.text : "")).join(" "));
2815
+ };
2816
+ pi.on("input", (event, ctx) => {
2817
+ const taskId = view ? panel.viewedTaskId() : undefined;
2818
+ if (!taskId || event.source !== "interactive") return undefined;
2819
+ const text = event.text.trim();
2820
+ if (!text || text.startsWith("/") || text.startsWith("!")) return undefined;
2821
+ void deliverFromView(ctx, taskId, event.text).catch((error: unknown) => {
2822
+ ctx.ui.notify(`Could not send the message: ${error instanceof Error ? error.message : String(error)}`, "error");
2823
+ });
2824
+ return { action: "handled" as const };
2825
+ });
2826
+
2827
+ /** A run's model as Claude Code names it in `/tasks` (`Sonnet 5`), else its spec. */
2828
+ const modelLabel = (ctx: ExtensionContext, spec: string | undefined): string | undefined => {
2829
+ if (!spec) return undefined;
2830
+ try {
2831
+ return findConfigured(ctx.modelRegistry.getAvailable(), spec)?.name ?? spec;
2832
+ } catch {
2833
+ return spec; // no registry (a torn-down session)
2834
+ }
2835
+ };
2836
+
2837
+ /**
2838
+ * `/tasks`: Claude Code's Background dialog (findings §40): running shells,
2839
+ * running agents, and the finished agents the user viewed. Enter views an
2840
+ * agent or shows a shell's details in the dialog, x stops the selected
2841
+ * running task, Esc closes. Enter on the shells chip below the editor opens
2842
+ * it too.
2843
+ */
2844
+ const openTasksDialog = async (ctx: ExtensionContext) => {
2845
+ if (!canShowCustomUi(ctx)) {
2846
+ if (ctx.mode !== "rpc") return;
2847
+ const lines = [
2848
+ ...shellRows(shellTasks.list(), Date.now()).map((task) => `- ${task.id} [shell, ${task.status}] ${task.command ?? task.description}`),
2849
+ ...liveRuns.list().filter((run) => run.depth === 0).map((run) => `- ${run.taskId} [${run.name}, ${run.status}] ${run.label}`),
2850
+ ];
2851
+ ctx.ui.notify([
2852
+ "The interactive task viewer requires TUI mode; showing a task snapshot instead.",
2853
+ ...(lines.length ? lines : ["No background tasks in this session."]),
2854
+ "Use task_output to inspect a task and task_stop to stop it.",
2855
+ ].join("\n"), "info");
2856
+ return;
2857
+ }
2858
+ const build = () => {
2859
+ const runs = liveRuns.list().filter((run) => run.depth === 0);
2860
+ // The shells the chip counts, so Enter on a chip whose shell just finished finds it.
2861
+ const shells = shellRows(shellTasks.list(), Date.now()).map((task) => ({
2862
+ kind: "shell" as const,
2863
+ id: task.id,
2864
+ text: task.command ?? task.description,
2865
+ running: task.status === "running",
2866
+ status: task.status,
2867
+ }));
2868
+ const agents = [
2869
+ ...runs.filter((run) => run.status === "running"),
2870
+ ...runs.filter((run) => run.status !== "running" && run.viewed),
2871
+ ].map((run) => ({ kind: "agent" as const, taskId: run.taskId, text: run.label, model: modelLabel(ctx, run.model), running: run.status === "running" }));
2872
+ return { shells, agents };
2873
+ };
2874
+ let selected = 0;
2875
+ // Rebuilt when a run or shell changes, not per frame or keystroke.
2876
+ let snapshot = build();
2877
+ // The rows on screen: keys act on what the user sees, even when a change
2878
+ // has rebuilt the snapshot and its render is still pending.
2879
+ let shown = tasksItems(snapshot);
2880
+ // The list, or one shell's details inside the same dialog.
2881
+ let dialogView: TasksView = { mode: "list" };
2882
+ const shellById = (id: string) => shellTasks.list().find((task) => task.id === id);
2883
+ const stopShell = (task: { stop(): void } | undefined) => {
2884
+ try {
2885
+ task?.stop();
2886
+ } catch {
2887
+ // A dead process's stop() throwing must not break the dialog.
2888
+ }
2889
+ };
2890
+ const chosen = await panelUi(pi.events, ctx.ui).custom<string | null>((tui, theme, _keybindings, done) => {
2891
+ const paint = { fg: safeThemePaint(theme), bold: safeThemeBold(theme) };
2892
+ const refresh = () => {
2893
+ snapshot = build();
2894
+ tui.requestRender();
2895
+ };
2896
+ const unsubscribe = liveRuns.subscribe(refresh);
2897
+ const unsubscribeShells = shellTasks.subscribe(refresh);
2898
+ // A running shell's details follow its runtime and output once a second,
2899
+ // and the list drops a finished shell once its linger window ends, as
2900
+ // the chip does (a rebuild only when that row set changes).
2901
+ const ticker = setInterval(() => {
2902
+ if (dialogView.mode === "shell") {
2903
+ if (shellById(dialogView.id)?.status === "running") tui.requestRender();
2904
+ } else if (snapshot.shells.some((shell) => !shell.running) && shellRows(shellTasks.list(), Date.now()).length !== snapshot.shells.length) {
2905
+ refresh();
2906
+ }
2907
+ }, 1000);
2908
+ ticker.unref?.();
2909
+ const finish = (value: string | null) => {
2910
+ clearInterval(ticker);
2911
+ unsubscribe();
2912
+ unsubscribeShells();
2913
+ done(value);
2914
+ };
2915
+ return {
2916
+ render: (width: number) => {
2917
+ const task = dialogView.mode === "shell" ? shellById(dialogView.id) : undefined;
2918
+ dialogView = settleTasksView(dialogView, task !== undefined);
2919
+ if (dialogView.mode === "shell" && task) {
2920
+ return ["", ...renderShellDetail(task, width - 2, Date.now(), paint).map((line) => ` ${line}`), ""].map((line) => truncateLine(line, width));
2921
+ }
2922
+ shown = tasksItems(snapshot);
2923
+ selected = Math.max(0, Math.min(selected, shown.length - 1));
2924
+ return ["", ...renderTasksDialog({ ...snapshot, selected, width }, paint), ""].map((line) => truncateLine(line, width));
2925
+ },
2926
+ handleInput: (data: string) => {
2927
+ if (isKeyRelease(data)) return;
2928
+ const id = keyId(data);
2929
+ if (dialogView.mode === "shell") {
2930
+ const task = shellById(dialogView.id);
2931
+ const action = shellDetailKey(id, keyText(data), task?.status === "running");
2932
+ if (action === "close") return finish(null);
2933
+ if (action === "back") dialogView = { mode: "list" };
2934
+ else if (action === "stop") stopShell(task);
2935
+ tui.requestRender();
2936
+ return;
2937
+ }
2938
+ const items = shown;
2939
+ if (id === "escape") return finish(null);
2940
+ if (id === "up" || id === "down") {
2941
+ selected = moveTasksSelection(selected, id, items.length);
2942
+ tui.requestRender();
2943
+ return;
2944
+ }
2945
+ const item = items[selected];
2946
+ if (id === "enter" && item?.kind === "agent") return finish(item.taskId);
2947
+ if (id === "enter" && item?.kind === "shell") {
2948
+ dialogView = { mode: "shell", id: item.id };
2949
+ tui.requestRender();
2950
+ return;
2951
+ }
2952
+ if (keyText(data)?.toLowerCase() === "x" && item?.running) {
2953
+ if (item.kind === "agent") void stopAgent(item.taskId);
2954
+ else stopShell(shellById(item.id));
2955
+ tui.requestRender();
2956
+ }
2957
+ },
2958
+ invalidate: () => {},
2959
+ };
2960
+ });
2961
+ if (chosen) openView(ctx, chosen);
2962
+ };
2963
+ registerLocalCommand(pi, "tasks", {
2964
+ description: "List background tasks: shells, running agents, and the agents you viewed",
2965
+ handler: async (_args, ctx) => openTasksDialog(ctx as ExtensionContext),
2242
2966
  });
2243
2967
  pi.events.emit(DEFER_CHANNEL, { name: "SendMessage", keywords: ["message", "agent", "resume", "continue", "teammate"] });
2244
2968
 
@@ -2279,7 +3003,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
2279
3003
  };
2280
3004
  },
2281
3005
  });
2282
- pi.events.emit(DEFER_CHANNEL, { name: "list_agents", keywords: ["agents", "list", "running", "spawned", "subagents", "who"] });
3006
+ // Eager, as Claude Code's ListAgents is: not emitted on the deferral channel.
2283
3007
 
2284
3008
  registerLocalCommand(pi, "agents", {
2285
3009
  description: "Open the live subagent panel, or list available agents",
@@ -2287,7 +3011,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
2287
3011
  // With live children, focus the strip on the newest and open its view
2288
3012
  // (Claude Code's agent panel); otherwise there is nothing running, so
2289
3013
  // list the catalog the way the tool's action:"list" does.
2290
- if (ctx.hasUI && panel.rowCount() > 1) {
3014
+ if (ctx.hasUI && ctx.mode === "tui" && panel.rowCount() > 1) {
2291
3015
  panel.setFocus(1); // row 0 is `main`; 1 is the newest live child
2292
3016
  const run = panel.selectedRun();
2293
3017
  if (run) {
@@ -2295,6 +3019,14 @@ export default function subagentsExtension(pi: ExtensionAPI) {
2295
3019
  return;
2296
3020
  }
2297
3021
  }
3022
+ if (ctx.mode === "rpc" && registry.list().length) {
3023
+ ctx.ui.notify([
3024
+ "The live subagent panel requires TUI mode; showing spawned agents instead.",
3025
+ ...registry.list().map((run) => `- ${run.name} [${run.agent}] — ${agentStatus(run)} (task ${run.taskId})`),
3026
+ "Use /tasks for task status; task_output and task_stop inspect or stop a task.",
3027
+ ].join("\n"), "info");
3028
+ return;
3029
+ }
2298
3030
  ctx.ui.notify(`Available agents:\n${describeAgents(ctx.cwd)}`, "info");
2299
3031
  },
2300
3032
  });
@@ -2317,6 +3049,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
2317
3049
  : "subagentModel: (not set)",
2318
3050
  `effective: ${resolution.model ? `${resolution.model.provider}/${resolution.model.id}` : "none"} (${resolution.source})`,
2319
3051
  ...(resolution.notices.length ? [resolution.notices.join("\n")] : []),
3052
+ ...(resolution.source === "default" && resolution.model
3053
+ ? chosenModelWarnings({ available, sessionModel: ctx.model, chosen: resolution.model, role: "subagent", suggestNewer: readSuggestNewerModels(os.homedir()) }).map((warning) => `! ${warning.text} ${warning.fix}`)
3054
+ : []),
2320
3055
  "Set it with /subagent <provider/model-id|sonnet|opus|haiku|fable|inherit>, or clear with /subagent clear.",
2321
3056
  ].join("\n"),
2322
3057
  "info",
@@ -2388,16 +3123,23 @@ export default function subagentsExtension(pi: ExtensionAPI) {
2388
3123
  ctx.ui.notify("Could not save subagent model: " + (error as Error).message, "error");
2389
3124
  return;
2390
3125
  }
2391
- emitModelStatus(ctx);
2392
3126
  ctx.ui.notify(`Subagent default set to "${spec}" (saved to ~/.onecode/settings.json).`, "info");
3127
+ // What the choice costs, shown now even if an earlier choice already
3128
+ // raised the same line; marked noticed so the status refresh is quiet.
3129
+ if (resolved) {
3130
+ for (const warning of chosenModelWarnings({ available, sessionModel: ctx.model, chosen: resolved, role: "subagent", suggestNewer: readSuggestNewerModels(os.homedir()) })) {
3131
+ const message = `${warning.text} ${warning.fix}`;
3132
+ noticedModels.add(message);
3133
+ ctx.ui.notify(message, "warning");
3134
+ }
3135
+ }
3136
+ emitModelStatus(ctx);
2393
3137
  };
2394
3138
 
2395
3139
  registerLocalCommand(pi, "subagent", {
2396
3140
  description: "Set the default model for subagent/workflow runs: /subagent [provider/model-id|inherit|status|clear]",
2397
- getArgumentCompletions: (prefix) =>
2398
- ["inherit", "status", "clear"]
2399
- .filter((value) => value.startsWith(prefix.trim().toLowerCase()))
2400
- .map((value) => ({ value, label: value })),
3141
+ argumentHint: "[provider/model-id|inherit|status|clear]",
3142
+ getArgumentCompletions: (prefix) => extendingCompletions(prefix, ["inherit", "status", "clear"].map((value) => ({ value, label: value }))),
2401
3143
  handler: async (args, ctx) => {
2402
3144
  const typed = args.trim();
2403
3145
  if (typed === "clear") {
@@ -2441,7 +3183,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
2441
3183
  const current = configured && configured.spec.includes("/") ? configured.spec : undefined;
2442
3184
  const entries = toPickerEntries(available);
2443
3185
 
2444
- const chosen = await ctx.ui.custom<PickerEntry | null>((tui, theme, _keybindings, done) =>
3186
+ const chosen = await panelUi(pi.events, ctx.ui).custom<PickerEntry | null>((tui, theme, _keybindings, done) =>
2445
3187
  modelPickerComponent(
2446
3188
  {
2447
3189
  entries,