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