one-code-extension 0.1.4 → 0.3.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 (300) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +200 -172
  3. package/agents/explore.md +9 -6
  4. package/agents/general-purpose.md +1 -1
  5. package/agents/plan.md +6 -4
  6. package/docs/guide/README.md +52 -0
  7. package/docs/guide/bring-your-claude-code-setup.md +126 -0
  8. package/docs/guide/configuration.md +171 -0
  9. package/docs/guide/differences-from-claude-code.md +150 -0
  10. package/docs/guide/doctor.md +86 -0
  11. package/docs/guide/hooks.md +109 -0
  12. package/docs/guide/installation.md +124 -0
  13. package/docs/guide/permissions-modes-and-auto-mode.md +450 -0
  14. package/docs/guide/providers-and-models.md +194 -0
  15. package/docs/guide/reference.md +224 -0
  16. package/docs/guide/sessions-and-context.md +190 -0
  17. package/docs/guide/skills-plugins-and-mcp.md +176 -0
  18. package/docs/guide/subagents-and-workflows.md +193 -0
  19. package/docs/guide/tasks-and-background-work.md +111 -0
  20. package/docs/guide/terminal-interface.md +155 -0
  21. package/docs/guide/tools.md +175 -0
  22. package/docs/guide/troubleshooting.md +175 -0
  23. package/extensions/ask-user/index.ts +65 -71
  24. package/extensions/ask-user/questions.ts +15 -28
  25. package/extensions/ask-user/widget.ts +533 -0
  26. package/extensions/auto-mode/actions.ts +9 -26
  27. package/extensions/auto-mode/classifier-prompt.ts +127 -0
  28. package/extensions/auto-mode/classifier.ts +305 -227
  29. package/extensions/auto-mode/config.ts +279 -77
  30. package/extensions/auto-mode/defaults.ts +17 -60
  31. package/extensions/auto-mode/instructions.ts +2 -1
  32. package/extensions/auto-mode/model-picker.ts +53 -31
  33. package/extensions/auto-mode/model-select.ts +153 -82
  34. package/extensions/auto-mode/paths.ts +13 -1
  35. package/extensions/auto-mode/pause.ts +8 -0
  36. package/extensions/auto-mode/prompt.ts +205 -238
  37. package/extensions/auto-mode/recoverability.ts +229 -0
  38. package/extensions/auto-mode/rules.ts +92 -75
  39. package/extensions/auto-mode/safety-floor.ts +43 -12
  40. package/extensions/auto-mode/sensitive.ts +11 -0
  41. package/extensions/auto-mode/setup-run.ts +245 -0
  42. package/extensions/auto-mode/setup.ts +235 -0
  43. package/extensions/auto-mode/shell-analysis.ts +332 -26
  44. package/extensions/auto-mode/transcript.ts +102 -0
  45. package/extensions/background/index.ts +256 -43
  46. package/extensions/background/monitor-batch.ts +58 -0
  47. package/extensions/background/registry.ts +9 -0
  48. package/extensions/background/wakeup.ts +45 -0
  49. package/extensions/bash/background.ts +47 -22
  50. package/extensions/bash/guards.ts +229 -0
  51. package/extensions/bash/index.ts +81 -19
  52. package/extensions/branding/index.ts +44 -18
  53. package/extensions/branding/startup.ts +23 -8
  54. package/extensions/claude-compat/index.ts +17 -2
  55. package/extensions/claude-context/index.ts +41 -12
  56. package/extensions/clear/index.ts +27 -0
  57. package/extensions/compaction/fit.ts +121 -0
  58. package/extensions/compaction/index.ts +176 -71
  59. package/extensions/compaction/prompt.ts +21 -1
  60. package/extensions/context-budget/budget.ts +61 -0
  61. package/extensions/context-budget/index.ts +66 -0
  62. package/extensions/context-management/index.ts +40 -16
  63. package/extensions/doctor/build.ts +226 -0
  64. package/extensions/doctor/cli.ts +99 -0
  65. package/extensions/doctor/compat.ts +532 -0
  66. package/extensions/doctor/dependencies.ts +186 -0
  67. package/extensions/doctor/fix-prompt.ts +125 -0
  68. package/extensions/doctor/index.ts +308 -0
  69. package/extensions/doctor/models.ts +231 -0
  70. package/extensions/doctor/presets.ts +208 -0
  71. package/extensions/doctor/report.ts +189 -0
  72. package/extensions/doctor/update-lookup.ts +46 -0
  73. package/extensions/doctor/viewer.ts +124 -0
  74. package/extensions/effort/index.ts +41 -4
  75. package/extensions/effort/slider.ts +33 -2
  76. package/extensions/file-tracker/index.ts +143 -20
  77. package/extensions/file-tracker/replay.ts +46 -0
  78. package/extensions/file-tracker/tracker.ts +51 -4
  79. package/extensions/footer/footer-line.ts +207 -0
  80. package/extensions/footer/index.ts +152 -0
  81. package/extensions/footer/pr.ts +53 -0
  82. package/extensions/hooks/executor.ts +57 -20
  83. package/extensions/hooks/formatter-notice.ts +76 -0
  84. package/extensions/hooks/index.ts +314 -73
  85. package/extensions/hooks/matcher.ts +35 -1
  86. package/extensions/hooks/plugin-hooks.ts +3 -3
  87. package/extensions/hooks/protocol.ts +58 -0
  88. package/extensions/hooks/subagent-bridge.ts +77 -0
  89. package/extensions/hooks/trust.ts +15 -4
  90. package/extensions/init/index.ts +26 -0
  91. package/extensions/init/prompt.ts +40 -0
  92. package/extensions/interrupted/index.ts +35 -0
  93. package/extensions/interrupted/line.ts +9 -0
  94. package/extensions/lib/abort.ts +15 -0
  95. package/extensions/lib/agent-loader.ts +183 -0
  96. package/extensions/lib/anthropic-payload.ts +15 -0
  97. package/extensions/lib/atomic-write.ts +56 -0
  98. package/extensions/lib/bridge-watch.ts +20 -0
  99. package/extensions/lib/cache-retention.ts +60 -0
  100. package/extensions/lib/capability-index.ts +466 -0
  101. package/extensions/lib/claude-context.ts +318 -27
  102. package/extensions/lib/claude-settings.ts +64 -0
  103. package/extensions/lib/consent-preview.ts +30 -0
  104. package/extensions/lib/deferred.ts +144 -13
  105. package/extensions/lib/favorites.ts +43 -0
  106. package/extensions/lib/fetch-timeout.ts +15 -0
  107. package/extensions/lib/frontmatter.ts +31 -0
  108. package/extensions/lib/git.ts +44 -2
  109. package/extensions/lib/hook-gate.ts +90 -0
  110. package/extensions/lib/interrupt.ts +94 -0
  111. package/extensions/lib/mcp-overrides.ts +61 -0
  112. package/extensions/lib/mcp-share.ts +25 -0
  113. package/extensions/lib/mcp-status.ts +34 -0
  114. package/extensions/lib/memory.ts +41 -6
  115. package/extensions/lib/model-facts.generated.json +2480 -0
  116. package/extensions/lib/model-facts.ts +162 -0
  117. package/extensions/lib/model-policy.ts +145 -189
  118. package/extensions/lib/model-tier.ts +480 -64
  119. package/extensions/lib/model-unusable.ts +37 -0
  120. package/extensions/lib/notifications.ts +320 -1
  121. package/extensions/lib/one-code-settings.ts +63 -0
  122. package/extensions/lib/original-command.ts +71 -0
  123. package/extensions/lib/package-version.ts +31 -0
  124. package/extensions/lib/paths.ts +39 -4
  125. package/extensions/lib/permission-gate.ts +192 -0
  126. package/extensions/lib/persisted-output.ts +18 -0
  127. package/extensions/lib/pi-ai-estimate.ts +160 -0
  128. package/extensions/lib/pi-version.ts +1 -1
  129. package/extensions/lib/plan-mode-channels.ts +11 -0
  130. package/extensions/lib/plugin-overrides.ts +24 -0
  131. package/extensions/lib/plugin-root.ts +44 -0
  132. package/extensions/lib/plugins.ts +233 -54
  133. package/extensions/lib/prefix-warm-gate.ts +157 -0
  134. package/extensions/lib/process-tree.ts +56 -0
  135. package/extensions/lib/reminders.ts +321 -68
  136. package/extensions/lib/scratchpad.ts +11 -8
  137. package/extensions/lib/session-lifecycle.ts +28 -0
  138. package/extensions/lib/settings-channels.ts +14 -0
  139. package/extensions/lib/shell-tasks.ts +54 -0
  140. package/extensions/lib/skill-overrides.ts +86 -0
  141. package/extensions/lib/skill-scan.ts +110 -0
  142. package/extensions/lib/text-width.ts +267 -0
  143. package/extensions/lib/tui-render.ts +281 -54
  144. package/extensions/lib/usage-bus.ts +75 -0
  145. package/extensions/lib/usage-tracker.ts +67 -0
  146. package/extensions/lib/worktree-isolation.ts +170 -0
  147. package/extensions/lsp/client.ts +69 -12
  148. package/extensions/lsp/format.ts +12 -2
  149. package/extensions/lsp/index.ts +273 -84
  150. package/extensions/lsp/install-hints.ts +43 -0
  151. package/extensions/lsp/keep-alive.ts +19 -0
  152. package/extensions/lsp/plugin-servers.ts +331 -0
  153. package/extensions/lsp/watcher.ts +157 -0
  154. package/extensions/mcp/announce.ts +99 -0
  155. package/extensions/mcp/client.ts +181 -22
  156. package/extensions/mcp/config.ts +55 -24
  157. package/extensions/mcp/index.ts +586 -86
  158. package/extensions/mcp/oauth/browser.ts +26 -0
  159. package/extensions/mcp/oauth/callback.ts +104 -0
  160. package/extensions/mcp/oauth/flow.ts +68 -0
  161. package/extensions/mcp/oauth/provider.ts +147 -0
  162. package/extensions/mcp/oauth/store.ts +86 -0
  163. package/extensions/mcp/panel/keys.ts +34 -0
  164. package/extensions/mcp/panel/model.ts +62 -0
  165. package/extensions/mcp/panel/render.ts +141 -0
  166. package/extensions/mcp/panel/state.ts +108 -0
  167. package/extensions/mcp/schema.ts +83 -6
  168. package/extensions/mcp/trust.ts +340 -0
  169. package/extensions/memory/entries.ts +163 -0
  170. package/extensions/memory/index.ts +119 -2
  171. package/extensions/memory/open-external.ts +97 -0
  172. package/extensions/memory/panel.ts +124 -0
  173. package/extensions/model-default/index.ts +74 -0
  174. package/extensions/notebook/index.ts +36 -18
  175. package/extensions/notebook/notebook.ts +110 -17
  176. package/extensions/permissions/index.ts +1012 -247
  177. package/extensions/permissions/matcher.ts +488 -57
  178. package/extensions/permissions/modes.ts +55 -7
  179. package/extensions/permissions/project-trust.ts +81 -0
  180. package/extensions/permissions/protected-paths.ts +16 -3
  181. package/extensions/permissions/rule-prose.ts +79 -0
  182. package/extensions/permissions/session-grant.ts +91 -0
  183. package/extensions/permissions/settings.ts +112 -33
  184. package/extensions/permissions/subagent-gate.ts +57 -0
  185. package/extensions/plan-mode/index.ts +143 -33
  186. package/extensions/plan-mode/reminder.ts +16 -19
  187. package/extensions/plan-mode/viewer.ts +40 -31
  188. package/extensions/plugins/counts.ts +68 -0
  189. package/extensions/plugins/index.ts +496 -32
  190. package/extensions/plugins/install/install.ts +167 -0
  191. package/extensions/plugins/install/paths.ts +23 -0
  192. package/extensions/plugins/install/registry.ts +72 -0
  193. package/extensions/plugins/marketplace/git.ts +103 -0
  194. package/extensions/plugins/marketplace/official.ts +36 -0
  195. package/extensions/plugins/marketplace/parse.ts +94 -0
  196. package/extensions/plugins/marketplace/registry.ts +60 -0
  197. package/extensions/plugins/marketplace/sync.ts +146 -0
  198. package/extensions/plugins/marketplace/types.ts +128 -0
  199. package/extensions/plugins/panel/keys.ts +52 -0
  200. package/extensions/plugins/panel/render.ts +315 -0
  201. package/extensions/plugins/panel/rows.ts +230 -0
  202. package/extensions/plugins/panel/state.ts +333 -0
  203. package/extensions/recap/index.ts +215 -0
  204. package/extensions/recap/prompt.ts +44 -0
  205. package/extensions/recap/scheduler.ts +87 -0
  206. package/extensions/search-tools/index.ts +0 -0
  207. package/extensions/search-tools/tools.ts +7 -5
  208. package/extensions/skill/index.ts +360 -34
  209. package/extensions/skill/invoke.ts +185 -0
  210. package/extensions/skill/listing.ts +21 -0
  211. package/extensions/skill/panel/keys.ts +41 -0
  212. package/extensions/skill/panel/render.ts +91 -0
  213. package/extensions/skill/panel/state.ts +115 -0
  214. package/extensions/spinner/index.ts +110 -0
  215. package/extensions/spinner/line.ts +51 -0
  216. package/extensions/spinner/verbs.ts +201 -0
  217. package/extensions/subagents/agents.ts +88 -44
  218. package/extensions/subagents/default-model.ts +62 -32
  219. package/extensions/subagents/delegation-steer.ts +23 -0
  220. package/extensions/subagents/hand-back-review.ts +67 -0
  221. package/extensions/subagents/index.ts +1545 -346
  222. package/extensions/subagents/live-runs.ts +293 -0
  223. package/extensions/subagents/model-select.ts +252 -82
  224. package/extensions/subagents/outcome.ts +90 -0
  225. package/extensions/subagents/panel-keys.ts +41 -0
  226. package/extensions/subagents/panel-render.ts +357 -0
  227. package/extensions/subagents/panel-widget.ts +330 -0
  228. package/extensions/subagents/pending-claim.ts +45 -0
  229. package/extensions/subagents/prose.ts +105 -0
  230. package/extensions/subagents/runner.ts +674 -0
  231. package/extensions/subagents/runs.ts +68 -1
  232. package/extensions/subagents/send-to-main-tool.ts +55 -0
  233. package/extensions/subagents/session-turns.ts +138 -0
  234. package/extensions/subagents/shell-panel.ts +198 -0
  235. package/extensions/subagents/usage.ts +16 -4
  236. package/extensions/subagents/worktree.ts +13 -0
  237. package/extensions/system-prompt/environment.ts +5 -3
  238. package/extensions/system-prompt/git-status.ts +93 -0
  239. package/extensions/system-prompt/index.ts +23 -1
  240. package/extensions/system-prompt/template.ts +34 -4
  241. package/extensions/system-prompt/tiers/common.ts +31 -6
  242. package/extensions/system-prompt/tiers/low.ts +13 -3
  243. package/extensions/system-prompt/tiers/mid.ts +21 -8
  244. package/extensions/system-reminder/index.ts +53 -9
  245. package/extensions/tasks/index.ts +65 -16
  246. package/extensions/tasks/store.ts +76 -3
  247. package/extensions/tool-search/announce.ts +42 -0
  248. package/extensions/tool-search/index.ts +136 -13
  249. package/extensions/turn-duration/index.ts +60 -0
  250. package/extensions/turn-duration/line.ts +22 -0
  251. package/extensions/turn-duration/span.ts +41 -0
  252. package/extensions/turn-duration/verbs.ts +24 -0
  253. package/extensions/web/backends.ts +421 -0
  254. package/extensions/web/index.ts +178 -21
  255. package/extensions/web-fetch/index.ts +74 -18
  256. package/extensions/web-fetch/summarize.ts +1 -1
  257. package/extensions/workflow/agent-session.ts +182 -112
  258. package/extensions/workflow/globals.ts +129 -106
  259. package/extensions/workflow/index.ts +343 -28
  260. package/extensions/workflow/records.ts +118 -0
  261. package/extensions/workflow/run-manager.ts +80 -31
  262. package/extensions/workflow/saved-workflows.ts +2 -1
  263. package/extensions/workflow/script-worker.mjs +110 -0
  264. package/extensions/workflow/types.ts +74 -8
  265. package/extensions/workflow/viewer.ts +562 -0
  266. package/extensions/workflow/vm-runtime.ts +118 -22
  267. package/extensions/workflow/widget.ts +126 -18
  268. package/extensions/worktree/guards.ts +240 -0
  269. package/extensions/worktree/index.ts +30 -5
  270. package/extensions/worktree/rewrite.ts +32 -16
  271. package/package.json +20 -10
  272. package/skills/code-review/SKILL.md +165 -0
  273. package/skills/fewer-permission-prompts/SKILL.md +61 -0
  274. package/skills/security-review/SKILL.md +178 -0
  275. package/skills/simplify/SKILL.md +70 -0
  276. package/themes/{one-code-light.json → onecode-light.json} +6 -1
  277. package/themes/{one-code.json → onecode.json} +6 -1
  278. package/types/app.d.ts +8 -1
  279. package/types/community-packages.d.ts +45 -2
  280. package/docs/decisions/auto-mode.md +0 -526
  281. package/docs/decisions/branding.md +0 -127
  282. package/docs/decisions/code-review-remediation.md +0 -106
  283. package/docs/decisions/compaction.md +0 -140
  284. package/docs/decisions/distribution.md +0 -168
  285. package/docs/decisions/hooks.md +0 -64
  286. package/docs/decisions/lsp.md +0 -58
  287. package/docs/decisions/mcp.md +0 -104
  288. package/docs/decisions/memory-state.md +0 -170
  289. package/docs/decisions/model-policy.md +0 -126
  290. package/docs/decisions/modes.md +0 -178
  291. package/docs/decisions/skills-plugins.md +0 -56
  292. package/docs/decisions/subagents-workflows.md +0 -225
  293. package/docs/decisions/system-prompt.md +0 -98
  294. package/docs/decisions/tools.md +0 -407
  295. package/docs/decisions/tui.md +0 -217
  296. package/docs/decisions.md +0 -148
  297. package/extensions/subagents/child.ts +0 -414
  298. package/extensions/subagents/rpc-turns.ts +0 -123
  299. package/extensions/todo/index.ts +0 -133
  300. package/extensions/workflow/permission-gate.ts +0 -71
@@ -1,58 +1,76 @@
1
1
  /**
2
2
  * subagents extension — Claude Code's Agent/Task tool, plus send_message.
3
3
  *
4
- * Delegates a task to a specialist agent running in a separate pi process with
5
- * its own context window. Agent definitions come from `.claude/agents/*.md`
4
+ * Delegates a task to a specialist agent running in-process (its own
5
+ * AgentSession, see runner.ts) with its own context window. Agent definitions
6
+ * come from `.claude/agents/*.md`
6
7
  * (project), `~/.claude/agents/*.md` (user), and the catalog bundled with this
7
8
  * package — the same markdown + frontmatter format Claude Code uses.
8
9
  *
9
10
  * Claude Code features covered: parallel tasks, per-call `model`/`thinking`
10
11
  * overrides, `fork` (a child inheriting this conversation), `isolation:
11
- * "worktree"`, `run_in_background` (detached runs addressable via
12
- * task_output/task_stop, completion delivered as a system notification), and
13
- * send_message (resume a finished agent with its context intact — children
14
- * persist their sessions per run to make that possible).
12
+ * "worktree"`, background execution (every run returns immediately — CC parity
13
+ * — stays addressable via task_output/task_stop, and steers its completion
14
+ * into the conversation as a system notification), and SendMessage (reach a
15
+ * running agent live, or resume a finished one with its context intact —
16
+ * children persist their sessions per run to make that possible).
15
17
  */
16
18
 
17
- import { mkdirSync, writeFileSync } from "node:fs";
19
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
18
20
  import os from "node:os";
19
21
  import { dirname, join } from "node:path";
20
22
  import { fileURLToPath } from "node:url";
21
23
  import { StringEnum } from "@earendil-works/pi-ai";
22
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
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";
23
26
  import { Type } from "typebox";
24
27
  import { SUBAGENT_ACTIONS_CHANNEL, type SubagentActionsPayload } from "../auto-mode/actions.ts";
25
28
  import { type AgentDefinition, type AgentSource, agentDirs, discoverAgents } from "./agents.ts";
26
- import { applicableSubagentDefault, loadSubagentDefault, persistSubagentModel } from "./default-model.ts";
29
+ import { modelIdentity, modelSpec } from "../lib/model-policy.ts";
30
+ import { MODEL_UNUSABLE_CHANNEL, type ModelUnusableEvent, withoutUnusable } from "../lib/model-unusable.ts";
31
+ import { applicableSubagentDefault, loadSubagentDefault, persistSubagentModel, type SubagentDefault } from "./default-model.ts";
27
32
  import {
28
33
  expensiveModelGate,
29
34
  resolveSubagentModel,
35
+ type SubagentModelResolution,
30
36
  SUBAGENT_STATUS_CHANNEL,
37
+ subagentModelNotes,
31
38
  subagentModelsReminder,
32
39
  subagentStatusModel,
33
40
  } from "./model-select.ts";
34
41
  import { modelPickerComponent, pickerSpec, toPickerEntries, type PickerEntry } from "../auto-mode/model-picker.ts";
35
- import { discoverPlugins } from "../lib/plugins.ts";
42
+ import { defaultDiscoverRoots, discoverPlugins } from "../lib/plugins.ts";
36
43
  import { DEFER_CHANNEL } from "../lib/deferred.ts";
44
+ import { MCP_TOOLS_CHANNEL, type McpToolsPayload } from "../lib/mcp-share.ts";
45
+ import { resolveModelTier } from "../lib/model-tier.ts";
46
+ import { pendingClaimReminder } from "./pending-claim.ts";
47
+ import { watchPermissionBridge } from "../permissions/subagent-gate.ts";
48
+ import { watchHookBridge } from "../hooks/subagent-bridge.ts";
37
49
  import { CONTEXT_ORDER, REMINDER_CHANNEL } from "../lib/reminders.ts";
38
50
  import { type BackgroundTask, generateTaskId, TASK_REGISTER_CHANNEL } from "../background/registry.ts";
39
51
  import { type ChildAction } from "../auto-mode/actions.ts";
40
- import {
41
- type ChildHandle,
42
- type ChildOutcome,
43
- forkTaskMessage,
44
- OUTPUT_CAP,
45
- type RpcChildHandle,
46
- startChild,
47
- startRpcChild,
48
- } from "./child.ts";
49
- import { type AgentRunRecord, nextRunName, RunRegistry } from "./runs.ts";
52
+ import { type ChildOutcome, forkTaskMessage, OUTPUT_CAP, type RpcChildHandle } from "./outcome.ts";
53
+ import { type AgentRunRecord, resolveRunName, RunRegistry } from "./runs.ts";
54
+ import { SubagentRuntime } from "./runner.ts";
50
55
  import { emptyUsage, formatStats, type UsageTotals } from "./usage.ts";
51
56
  import { cleanupWorktree, createWorktree, isGitRepo, type Worktree } from "./worktree.ts";
52
- import { systemNotification } from "../lib/notifications.ts";
53
- import { ccToolRenderers, customMessageText, notificationComponent } from "../lib/tui-render.ts";
54
-
55
- const MAX_PARALLEL = 4;
57
+ import { findGitRoot } from "../lib/git.ts";
58
+ import { registerWorktreeIsolation } from "../lib/worktree-isolation.ts";
59
+ import { createTaskNotifier, oneShotNote, sessionOutlivesTurn, systemNotification } from "../lib/notifications.ts";
60
+ import { persistIfLarge, sessionResultsDir } from "../lib/persisted-output.ts";
61
+ import { ccToolRenderers, customMessageText, liveUiCtx, notificationComponent, safeThemeBold, safeThemePaint, truncateLine } from "../lib/tui-render.ts";
62
+ import { deriveActivity, type FinishOutcome, LiveRunRegistry } from "./live-runs.ts";
63
+ import { DELEGATION_STEER } from "./delegation-steer.ts";
64
+ import { awaitHandBackReview, withReview } from "./hand-back-review.ts";
65
+ import type { LiveSink } from "./runner.ts";
66
+ import { recordUsage } from "../lib/usage-bus.ts";
67
+ import { SUBAGENT_DEFAULT_CHANGED_CHANNEL } from "../lib/settings-channels.ts";
68
+ import { SubagentWidget } from "./panel-widget.ts";
69
+ import { type ProseRenderer, renderTranscript } from "./panel-render.ts";
70
+ import { decodeStripKey, type StripKey } from "./panel-keys.ts";
71
+ import { reduceShellKey } from "./shell-panel.ts";
72
+ import { trackShellTasks } from "../lib/shell-tasks.ts";
73
+ import { createMarkdownProse } from "./prose.ts";
56
74
 
57
75
  /** The catalog shipped in this package: <package>/agents. */
58
76
  const BUNDLED_AGENTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "agents");
@@ -64,10 +82,34 @@ interface RunRequest {
64
82
  /** Inherit the caller's conversation instead of starting from an agent prompt. */
65
83
  fork?: boolean;
66
84
  model?: string;
85
+ /**
86
+ * Session model to fall back to if `model` (a non-per-call automatic/default
87
+ * pick) cannot spawn. Unset for a per-call model, which surfaces its error to
88
+ * the main model to retry rather than being silently swapped. See the runner's
89
+ * buildChildSession.
90
+ */
91
+ fallbackModel?: string;
67
92
  thinking?: string;
68
93
  worktree?: boolean;
69
94
  }
70
95
 
96
+ /**
97
+ * The session model a broken *automatic* pick should fall back to at spawn.
98
+ * Undefined for a per-call model (source "call") — that surfaces its error to
99
+ * the main model to retry rather than being silently swapped — and when the
100
+ * pick already IS the session model. `resolvedSpec` is the caller's already-
101
+ * computed `provider/id` for the resolved model.
102
+ */
103
+ function spawnFallbackModel(
104
+ resolvedSpec: string | undefined,
105
+ source: SubagentModelResolution["source"],
106
+ sessionModel: { provider: string; id: string } | undefined,
107
+ ): string | undefined {
108
+ if (!sessionModel || source === "call" || !resolvedSpec) return undefined;
109
+ const sessionSpec = modelSpec(sessionModel);
110
+ return resolvedSpec !== sessionSpec ? sessionSpec : undefined;
111
+ }
112
+
71
113
  interface TaskResult {
72
114
  agent: string;
73
115
  name: string;
@@ -83,28 +125,23 @@ interface TaskResult {
83
125
  actions?: ChildAction[];
84
126
  }
85
127
 
86
- const TaskShape = Type.Object({
87
- agent: Type.String({
88
- description: 'Agent name from the "Available agents" system reminder, or "fork" to clone this conversation',
89
- }),
90
- task: Type.String({ description: "Complete, self-contained instruction — the agent cannot ask follow-ups" }),
91
- name: Type.Optional(Type.String({ description: "Name for this run, usable later with send_message (default: <agent>-<n>)" })),
92
- });
93
-
94
128
  const SubagentParams = Type.Object({
95
- agent: Type.Optional(
129
+ subagent_type: Type.Optional(
96
130
  Type.String({
97
131
  description:
98
- 'Agent for a single run — a name from the "Available agents" system reminder, or "fork" to clone this conversation. Required unless passing `tasks` or action:"list"',
132
+ '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.',
99
133
  }),
100
134
  ),
101
135
  task: Type.Optional(
102
- Type.String({ description: 'Task for a single run — required with `agent` (only action:"list" needs neither)' }),
103
- ),
104
- name: Type.Optional(Type.String({ description: "Name for a single run, usable later with send_message" })),
105
- tasks: Type.Optional(
106
- Type.Array(TaskShape, { description: `Run several agents in parallel (max ${MAX_PARALLEL} at a time)` }),
136
+ Type.String({ description: "The task — a complete, self-contained instruction (the agent cannot ask follow-ups). Required with subagent_type." }),
107
137
  ),
138
+ // `prompt` is Claude Code's name for the task text and `description` its short
139
+ // title. A CC-trained model writes `{subagent_type, description, prompt}`; pi
140
+ // does not reject unknown keys, so without these the task silently became ""
141
+ // (TOOL-FIDELITY-REVIEW-2026-09-07 H1). Accept both as aliases.
142
+ prompt: Type.Optional(Type.String({ description: "Alias of `task` (Claude Code's name for it) — the task for the agent to perform." })),
143
+ description: Type.Optional(Type.String({ description: "A short (3-5 word) description of the task, shown as the run's title." })),
144
+ name: Type.Optional(Type.String({ description: "Name for this run, usable later with SendMessage (default: <agent>-<n>)" })),
108
145
  model: Type.Optional(
109
146
  Type.String({
110
147
  description:
@@ -125,13 +162,7 @@ const SubagentParams = Type.Object({
125
162
  ),
126
163
  isolation: Type.Optional(
127
164
  StringEnum(["worktree"] as const, {
128
- description: "Run each agent in its own git worktree so parallel file edits cannot collide",
129
- }),
130
- ),
131
- run_in_background: Type.Optional(
132
- Type.Boolean({
133
- description:
134
- "Return immediately with a task id instead of waiting. Completion arrives as a system notification; inspect with task_output, stop with task_stop",
165
+ description: "Run the agent in its own git worktree so its file edits are isolated from the main checkout",
135
166
  }),
136
167
  ),
137
168
  action: Type.Optional(
@@ -141,60 +172,264 @@ const SubagentParams = Type.Object({
141
172
  ),
142
173
  });
143
174
 
144
- async function runPool<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
145
- const results: R[] = new Array(items.length);
146
- let next = 0;
147
- const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
148
- while (next < items.length) {
149
- const index = next++;
150
- results[index] = await fn(items[index]);
151
- }
152
- });
153
- await Promise.all(workers);
154
- return results;
155
- }
156
-
157
175
  export const FORK_AGENT = "fork";
158
176
 
177
+ /**
178
+ * The anti-fabrication sentence, carried by the tool RESULT of a background
179
+ * spawn and not only by the Agent tool's description. Claude Code puts it in the
180
+ * result ("You know nothing about its results until that notification
181
+ * arrives…"), and a cheap-tier model that had the description in every request
182
+ * still wrote a pending agent's answer in the message before the notification
183
+ * arrived, then read the real notification as confirmation of what it had
184
+ * invented (WEAK-MODEL-REVIEW-2026-09-06 H1). The description keeps its own
185
+ * copy; this is the one the model reads at the point of action.
186
+ */
187
+ const PENDING_RESULT_PROHIBITION =
188
+ "You know nothing about the agent's result until that notification arrives: do not report, assume, or predict it, in prose or otherwise. If the request asks you to report what the agent found, that IS a step that cannot proceed without the result — call task_output with block=true and wait, rather than ending your turn with an answer you do not have yet.";
189
+
190
+ /** Floor between interim output.log rewrites (onProgress fires per tool call/message). */
191
+ const LOG_WRITE_INTERVAL_MS = 250;
192
+ /** Idle time after which a resident agent session is released (its session file keeps it reachable). */
193
+ const RESIDENT_IDLE_MS = 15 * 60_000;
194
+
159
195
  /** A background agent's process, kept alive after its run so it can be messaged. */
160
196
  interface Resident {
161
197
  handle: RpcChildHandle;
162
- /** FIFO — the head entry handles the next turn_end (initial task, then one per idle-time message). */
163
- turnHandlers: Array<(outcome: ChildOutcome) => void>;
198
+ /**
199
+ * FIFO — the head entry handles the next turn_end (initial task, then one per
200
+ * idle-time message). `review` is auto mode's rendered hand-back flag for
201
+ * that turn (undefined when clean or auto mode is off); the handler puts it
202
+ * ahead of the report in its notification.
203
+ */
204
+ turnHandlers: Array<(outcome: ChildOutcome, review: string | undefined) => void>;
164
205
  }
165
206
 
166
207
  export default function subagentsExtension(pi: ExtensionAPI) {
167
208
  const registry = new RunRegistry();
168
- /** Names with a foreground/respawned child currently running (send_message must wait for these). */
169
- const runningNames = new Set<string>();
170
- const liveChildren = new Set<{ kill(): void }>();
209
+ /**
210
+ * Task ids with a blocking child (nested spawn or SendMessage resume) currently
211
+ * running (SendMessage must wait for these). Keyed by task id, like `residents`:
212
+ * run NAMES repeat across a session (the model reuses "reviewer"), and a name key
213
+ * routed a message addressed to the older task id to the newer agent (M1).
214
+ */
215
+ const runningIds = new Set<string>();
216
+ /** Task id → live resident. */
171
217
  const residents = new Map<string, Resident>();
218
+ /**
219
+ * Task ids of background runs spawned during the agent loop now in flight.
220
+ * Cleared at `agent_start`; read at `agent_end` to notice a turn that ended
221
+ * with an agent still pending, which is the shape of the fabricated-result
222
+ * failure (pending-claim.ts, WEAK-MODEL-REVIEW-2026-09-06 H1).
223
+ */
224
+ const spawnedThisLoop = new Set<string>();
225
+
226
+ // The live subagent panel (Claude Code's below-editor agent tree): a registry
227
+ // of every in-process child fed by the runner's live sink, a below-editor
228
+ // strip, and taskId→kill handles for the viewer's stop/stop-all. The same
229
+ // widget carries the background-shell manager (chip → list → details), fed
230
+ // by the bash tasks that cross TASK_REGISTER_CHANNEL.
231
+ const liveRuns = new LiveRunRegistry();
232
+ /** taskId → kill handle: a resident's kill resolves when it has exited; a blocking run exposes `result`. */
233
+ const liveHandles = new Map<string, { kill(): void | Promise<void>; result?: Promise<unknown> }>();
234
+ /**
235
+ * Task ids the user asked to stop (x / ctrl+x ctrl+k / session teardown). A
236
+ * kill still surfaces as an exit, but it neither completed nor failed — so the
237
+ * strip and the notification read this to say "Stopped" instead of the
238
+ * misleading "Completed"/"failed" (TUI-REVIEW L1). Cleared when a run (re)starts.
239
+ */
240
+ const stoppedTaskIds = new Set<string>();
241
+ let lastCtx: ExtensionContext | undefined;
242
+ const shellTasks = trackShellTasks(pi);
243
+ const panel = new SubagentWidget(liveRuns, () => lastCtx, shellTasks);
244
+
245
+ /**
246
+ * Register a child in the live panel and return the wiring the spawn sites
247
+ * need: a runner `sink` (activity + transcript blocks), a `progress` hook to
248
+ * fold into the existing onProgress, and settle/finish terminal marks.
249
+ */
250
+ const trackLiveRun = (record: AgentRunRecord, request: RunRequest, parent?: { taskId: string; depth: number }) => {
251
+ // A (re)starting run is no longer stopped — a resumed agent that was killed
252
+ // earlier must not inherit the "Stopped" mark.
253
+ stoppedTaskIds.delete(record.taskId);
254
+ // A resumed run (SendMessage to a finished agent) re-enters its existing
255
+ // panel entry; only a never-seen taskId registers fresh.
256
+ if (!liveRuns.reactivate(record.taskId, request.task, Date.now())) {
257
+ liveRuns.register({
258
+ taskId: record.taskId,
259
+ name: record.name,
260
+ agentType: request.agent,
261
+ model: request.model,
262
+ thinking: request.thinking,
263
+ task: request.task,
264
+ startedAt: Date.now(),
265
+ parentTaskId: parent?.taskId,
266
+ depth: record.depth ?? 0,
267
+ });
268
+ }
269
+ const sink: LiveSink = {
270
+ onActivity: (tool, args, text) => liveRuns.setActivity(record.taskId, deriveActivity(tool, args, text)),
271
+ onBlock: (block) => liveRuns.block(record.taskId, block),
272
+ onStreaming: (message) => liveRuns.setStreaming(record.taskId, message),
273
+ onUsage: (usage) => recordUsage(pi, "subagent", usage),
274
+ };
275
+ return {
276
+ sink,
277
+ progress: (toolCalls: number, usage: UsageTotals) => liveRuns.stats(record.taskId, toolCalls, usage),
278
+ settle: () => liveRuns.settle(record.taskId),
279
+ finish: (outcome: FinishOutcome) => {
280
+ liveRuns.finish(record.taskId, outcome);
281
+ liveHandles.delete(record.taskId);
282
+ },
283
+ };
284
+ };
285
+
286
+ // Live MCP tool definitions published by the mcp extension; injected into
287
+ // child sessions so subagents share the parent's open connections (no reconnect).
288
+ // Empty when no MCP servers are configured, so this is a no-op for most sessions.
289
+ // The parent's MCP connect runs in the background, so a spawn in the first
290
+ // seconds of a session waits for the settled publish (capped, so a hung server
291
+ // can't stall spawns) instead of baking in a still-connecting snapshot.
292
+ const MCP_SETTLE_CAP_MS = 10_000;
293
+ let mcpTools: ToolDefinition[] = [];
294
+ let mcpSettled = false;
295
+ let resolveMcpSettled: (() => void) | undefined;
296
+ const mcpSettledPromise = new Promise<void>((resolve) => {
297
+ resolveMcpSettled = resolve;
298
+ });
299
+ pi.events.on(MCP_TOOLS_CHANNEL, (data) => {
300
+ const payload = data as McpToolsPayload | undefined;
301
+ mcpTools = payload?.tools ?? [];
302
+ if (payload?.settled) {
303
+ mcpSettled = true;
304
+ resolveMcpSettled?.();
305
+ }
306
+ });
307
+ const awaitMcpTools = async (): Promise<ToolDefinition[]> => {
308
+ if (!mcpSettled) {
309
+ await Promise.race([
310
+ mcpSettledPromise,
311
+ new Promise<void>((resolve) => {
312
+ const timer = setTimeout(resolve, MCP_SETTLE_CAP_MS);
313
+ timer.unref?.();
314
+ }),
315
+ ]);
316
+ }
317
+ return mcpTools;
318
+ };
319
+
320
+ // The parent permissions extension's decision closure, used to gate a child's
321
+ // tool calls through the real pipeline (mode inheritance, classifier, prompts
322
+ // bubbled to the user). Undefined until permissions emits it at session start;
323
+ // a child built before then falls back to the fail-closed local gate.
324
+ const getPermissionBridge = watchPermissionBridge(pi);
325
+ // Likewise the hooks extension's bridge: the user's PreToolUse/PostToolUse
326
+ // hooks run for a child's tool calls too (hooks/subagent-bridge.ts).
327
+ const getHookBridge = watchHookBridge(pi);
328
+
329
+ /**
330
+ * Models this account cannot run, learned during the session: the auto-mode
331
+ * classifier's rejections arrive on the shared channel, a child's own
332
+ * provider refusal is reported by the runtime. Both roles pick from one
333
+ * floor, so both would otherwise keep landing on the same refused model
334
+ * (lib/model-unusable.ts). `usableModels` is the catalog every resolution
335
+ * and menu here reads.
336
+ */
337
+ const unusableModels = new Set<string>();
338
+ const usableModels = (ctx: ExtensionContext) => withoutUnusable(ctx.modelRegistry.getAvailable(), unusableModels);
339
+ const rememberUnusable = (model: string) => {
340
+ if (unusableModels.has(model)) return false;
341
+ unusableModels.add(model);
342
+ return true;
343
+ };
344
+ pi.events.on(MODEL_UNUSABLE_CHANNEL, (data) => {
345
+ // The default may have been that model: recompute and republish the
346
+ // reminder/banner so the next request already names the replacement.
347
+ const event = data as ModelUnusableEvent;
348
+ if (rememberUnusable(event.model) && lastCtx) emitModelStatus(lastCtx);
349
+ });
350
+
351
+ /** The in-process runner, built lazily on first run and shared across all runs. */
352
+ let runtimePromise: Promise<SubagentRuntime> | undefined;
353
+ const getRuntime = (ctx: ExtensionContext) =>
354
+ (runtimePromise ??= SubagentRuntime.create(
355
+ ctx.cwd,
356
+ awaitMcpTools,
357
+ getPermissionBridge,
358
+ getHookBridge,
359
+ // A child extension handler threw. pi's wrapper swallows it inside the
360
+ // child, so this is the only trace; shown in the parent's UI (stderr
361
+ // when headless), through the live ctx so a late one cannot throw.
362
+ (runName, error) => {
363
+ const text = `Subagent ${runName ?? "(unnamed)"}: extension error in ${error.event} (${error.extensionPath}): ${error.error}`;
364
+ const live = liveUiCtx(lastCtx);
365
+ if (live) live.ui.notify(text, "warning");
366
+ else process.stderr.write(`${text}\n`);
367
+ },
368
+ (model, reason) => {
369
+ if (!rememberUnusable(model)) return;
370
+ pi.events.emit(MODEL_UNUSABLE_CHANNEL, { model, reason } satisfies ModelUnusableEvent);
371
+ const live = liveUiCtx(lastCtx);
372
+ live?.ui.notify(`Subagent model ${model} is not usable on this account (${reason}); automatic selection skips it from now on.`, "warning");
373
+ if (lastCtx) emitModelStatus(lastCtx);
374
+ },
375
+ ));
172
376
 
173
377
  const loadAgents = (cwd: string) => {
174
378
  // Plugin agents sit between bundled and user definitions, and are exposed
175
379
  // namespaced (`<plugin>:<agent>`) so two plugins can ship the same name.
176
380
  const sources: Array<string | AgentSource> = [
177
381
  BUNDLED_AGENTS_DIR,
178
- ...discoverPlugins(join(os.homedir(), ".claude")).agentDirs,
382
+ ...discoverPlugins(defaultDiscoverRoots(getAgentDir(), cwd)).agentDirs,
179
383
  ...agentDirs(cwd, os.homedir()),
180
384
  ];
181
385
  return discoverAgents(sources);
182
386
  };
183
387
 
388
+ // Claude Code closes every catalog row with a tools clause — "(Tools: *)",
389
+ // "(Tools: All tools except Edit, Write)", "(Tools: Bash, Read)" — the one
390
+ // place it tells the model an agent cannot edit. Our frontmatter enforces
391
+ // `tools`/`excludeTools` but never showed them (TOOL-FIDELITY-REVIEW M4).
392
+ const toolsClause = (a: AgentDefinition): string => {
393
+ if (a.tools && a.tools.length > 0) return ` (Tools: ${a.tools.join(", ")})`;
394
+ if (a.excludeTools && a.excludeTools.length > 0) return ` (Tools: All tools except ${a.excludeTools.join(", ")})`;
395
+ return " (Tools: *)";
396
+ };
397
+
184
398
  const describeAgents = (cwd: string) => {
185
399
  const agents = loadAgents(cwd);
186
- const lines = agents.map((a) => `- ${a.name}: ${a.description || "(no description)"}`);
400
+ const lines = agents.map((a) => `- ${a.name}: ${a.description || "(no description)"}${toolsClause(a)}`);
187
401
  lines.push(`- ${FORK_AGENT}: clone this conversation, with its full context, to work on a task in parallel`);
188
402
  return lines.join("\n");
189
403
  };
190
404
 
191
405
  const reconstructRuns = (ctx: ExtensionContext) => {
406
+ /** Loop-invariant; computed lazily so worktree-free histories pay nothing. */
407
+ let sharedRoot: string | undefined;
408
+ /** A resident messaged N times embeds its record N times; stat and register each worktree once. */
409
+ const worktreesSeen = new Set<string>();
192
410
  for (const entry of ctx.sessionManager.getBranch()) {
193
411
  if (entry.type !== "message") continue;
194
412
  const msg = entry.message;
195
- if (msg.role !== "toolResult" || !["subagent", "send_message"].includes(msg.toolName ?? "")) continue;
413
+ // Both the current names and the pre-rename ones, so runs recorded before the
414
+ // Agent/SendMessage rename still reconstruct on resume.
415
+ if (msg.role !== "toolResult" || !["Agent", "SendMessage", "subagent", "send_message"].includes(msg.toolName ?? "")) continue;
196
416
  const records = (msg.details as { agentRuns?: AgentRunRecord[] } | undefined)?.agentRuns;
197
- for (const record of records ?? []) registry.add(record);
417
+ for (const record of records ?? []) {
418
+ registry.add(record);
419
+ // A kept worktree can host a resumed run in a NEW process, where
420
+ // createWorktree's registration no longer exists — re-register so the
421
+ // git-isolation guard survives the restart. A removed worktree's entry
422
+ // is inert (no session runs there any more). findGitRoot instead of
423
+ // `git rev-parse` because session_start handlers must stay fast
424
+ // (findings §15); a slightly-off sharedRoot only softens one message —
425
+ // the worktree containment check itself uses the exact record.cwd.
426
+ if (record.worktree && !worktreesSeen.has(record.cwd)) {
427
+ worktreesSeen.add(record.cwd);
428
+ if (!existsSync(record.cwd)) continue;
429
+ sharedRoot ??= findGitRoot(ctx.cwd) ?? ctx.cwd;
430
+ registerWorktreeIsolation(record.cwd, sharedRoot);
431
+ }
432
+ }
198
433
  }
199
434
  };
200
435
  /** Notices about model fallbacks/crossings, shown once per distinct message. */
@@ -205,6 +440,29 @@ export default function subagentsExtension(pi: ExtensionAPI) {
205
440
  ctx.ui.notify(message, "warning");
206
441
  };
207
442
 
443
+ /**
444
+ * Session-scoped cache of the AUTOMATIC subagent default (no per-call or agent
445
+ * override), so the O(catalog) ranking is not rebuilt on every turn (the
446
+ * every-turn reminder/banner) or every no-override spawn. Keyed on the session
447
+ * model plus the configured default; catalog churn is deliberately ignored,
448
+ * matching the classifier's pin — the session commits to one default worker,
449
+ * and /model or /subagent both change the signature and recompute. A per-call
450
+ * `model` or an agent's frontmatter model still resolves fresh (they vary).
451
+ */
452
+ let autoDefaultCache: { signature: string; resolution: SubagentModelResolution } | undefined;
453
+ const resolveAutoDefault = (
454
+ sessionModel: Model<Api> | undefined,
455
+ available: Model<Api>[],
456
+ configured: SubagentDefault | undefined,
457
+ ): SubagentModelResolution => {
458
+ const session = sessionModel ? modelSpec(sessionModel) : "(none)";
459
+ const signature = `${session}|${configured?.spec ?? ""}|${configured?.setForContainment ?? ""}|${configured?.source ?? ""}|${unusableModels.size}`;
460
+ if (autoDefaultCache?.signature === signature) return autoDefaultCache.resolution;
461
+ const resolution = resolveSubagentModel({ configuredDefault: configured, sessionModel, available });
462
+ autoDefaultCache = { signature, resolution };
463
+ return resolution;
464
+ };
465
+
208
466
  /**
209
467
  * The every-turn menu reminder and the banner's subagent-default status.
210
468
  * Every-turn because reminders are transient per-request injections — that
@@ -212,9 +470,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
212
470
  * replaces it: the very next LLM call, even mid-turn, carries the update.
213
471
  */
214
472
  const emitModelStatus = (ctx: ExtensionContext, sessionModel = ctx.model) => {
215
- const available = ctx.modelRegistry.getAvailable();
473
+ const available = usableModels(ctx);
216
474
  const configured = applicableSubagentDefault(loadSubagentDefault(os.homedir()), sessionModel);
217
- const resolution = resolveSubagentModel({ configuredDefault: configured?.spec, sessionModel, available });
475
+ const resolution = resolveAutoDefault(sessionModel, available, configured);
218
476
  for (const notice of resolution.notices) notifyModelOnce(ctx, notice);
219
477
  pi.events.emit(REMINDER_CHANNEL, {
220
478
  text: subagentModelsReminder({
@@ -222,6 +480,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
222
480
  sessionModel,
223
481
  defaultModel: resolution.model,
224
482
  defaultSource: resolution.source,
483
+ configured,
225
484
  }),
226
485
  scope: "every-turn",
227
486
  key: "subagent-models",
@@ -241,23 +500,470 @@ export default function subagentsExtension(pi: ExtensionAPI) {
241
500
  pi.events.emit(REMINDER_CHANNEL, {
242
501
  scope: "every-turn",
243
502
  key: "subagent-agents",
244
- text: `Available agents for the subagent tool (\`agent\` field):\n${describeAgents(ctx.cwd)}`,
503
+ 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.`,
245
504
  placement: "first-prepend",
246
505
  order: CONTEXT_ORDER.agents,
247
506
  });
248
507
  };
249
508
 
509
+ const emitDelegationSteer = (sessionModel = lastCtx?.model) => {
510
+ if (resolveModelTier(sessionModel) === "tiny") {
511
+ pi.events.emit(REMINDER_CHANNEL, {
512
+ scope: "every-turn",
513
+ key: "subagent-delegation",
514
+ text: DELEGATION_STEER,
515
+ placement: "first-prepend",
516
+ order: CONTEXT_ORDER.delegation,
517
+ });
518
+ } else {
519
+ pi.events.emit(REMINDER_CHANNEL, { key: "subagent-delegation", remove: true });
520
+ }
521
+ };
522
+
523
+ let shuttingDown = false;
524
+
525
+ /**
526
+ * The fabricated-result backstop (pending-claim.ts). Only the tiers that were
527
+ * measured to need it: a frontier model that correctly said "I'm waiting"
528
+ * would get the reminder on every delegating turn for nothing, and the
529
+ * trigger fires on correct behaviour as readily as on the failure.
530
+ */
531
+ const backstopApplies = (ctx: ExtensionContext | undefined) => {
532
+ const tier = resolveModelTier(ctx?.model);
533
+ return tier === "cheap" || tier === "tiny";
534
+ };
535
+ pi.on("agent_start", () => {
536
+ spawnedThisLoop.clear();
537
+ return undefined;
538
+ });
539
+ pi.on("agent_end", (_event, ctx) => {
540
+ const spawned = [...spawnedThisLoop];
541
+ spawnedThisLoop.clear();
542
+ // Tier check first: on a frontier session the backstop never fires, so the
543
+ // lookups below would be discarded work on every turn that delegated.
544
+ if (!backstopApplies(ctx)) return undefined;
545
+ const pending = spawned
546
+ .filter((taskId) => {
547
+ const resident = residents.get(taskId);
548
+ return (resident !== undefined && !resident.handle.exited()) || runningIds.has(taskId);
549
+ })
550
+ .map((taskId) => registry.resolve(taskId))
551
+ .filter((record): record is AgentRunRecord => record !== undefined)
552
+ .map((record) => ({ name: record.name, taskId: record.taskId }));
553
+ const text = pendingClaimReminder(pending);
554
+ if (text) pi.events.emit(REMINDER_CHANNEL, { text });
555
+ return undefined;
556
+ });
557
+
250
558
  pi.on("session_start", (_event, ctx) => {
559
+ lastCtx = ctx;
560
+ // A replaced session (/clear, /new, /resume) never reaches this instance
561
+ // with the old one's agents: pi emits `session_shutdown` (reason "new" /
562
+ // "resume") to the OLD extension instance, which stops every agent below,
563
+ // then re-runs this factory for the new session (verified live on pi
564
+ // 0.84.1, SUBAGENT-REVIEW-2026-09-04 appendix B). So the state here is
565
+ // always fresh; only a resumed session's persisted run records are rebuilt.
251
566
  reconstructRuns(ctx);
252
567
  emitModelStatus(ctx);
253
568
  emitAgentCatalog(ctx);
569
+ emitDelegationSteer(ctx.model);
570
+ registerPanelInputHook(ctx);
571
+ });
572
+ pi.on("model_select", (event, ctx) => {
573
+ emitModelStatus(ctx, event.model);
574
+ emitDelegationSteer(event.model);
575
+ });
576
+ // Another extension wrote `subagentModel` on disk (`/doctor preset`): drop the
577
+ // cached automatic default and republish the reminder + banner status, the
578
+ // same refresh `/subagent` itself performs after its own write.
579
+ pi.events.on(SUBAGENT_DEFAULT_CHANGED_CHANNEL, () => {
580
+ autoDefaultCache = undefined;
581
+ const live = liveUiCtx(lastCtx);
582
+ if (live) emitModelStatus(live);
254
583
  });
255
- pi.on("model_select", (event, ctx) => emitModelStatus(ctx, event.model));
256
584
  pi.on("session_tree", (_event, ctx) => reconstructRuns(ctx));
257
- pi.on("session_shutdown", () => {
258
- for (const child of liveChildren) child.kill();
585
+ pi.on("session_shutdown", async () => {
586
+ // Teardown — at process exit AND on /clear, /new, /resume (pi replaces the
587
+ // session and re-runs this factory; afterwards every use of this
588
+ // instance's ctx or pi throws). Kills must not surface as "terminated"
589
+ // notifications; the panel and the transcript view must stop painting
590
+ // before the ctx goes stale (a timer painting on the dead ctx took the
591
+ // whole process down — SUBAGENT-REVIEW H1); and the exits — including the
592
+ // worktree cleanups they trigger — get a bounded moment to finish (S14).
593
+ shuttingDown = true;
594
+ closeView();
595
+ const exits = stopAllAgents();
596
+ panel.dispose();
597
+ lastCtx = undefined;
598
+ await Promise.race([Promise.allSettled(exits), new Promise((resolve) => setTimeout(resolve, 1_500))]);
259
599
  });
260
600
 
601
+ // --- The subagent panel: strip soft focus + Enter-to-view transcript swap ---
602
+
603
+ /** Stop one live agent by task id (blocking-run handle or resident task). */
604
+ const stopAgent = (taskId: string) => {
605
+ stoppedTaskIds.add(taskId); // the coming exit is a user stop, not a completion (L1)
606
+ void liveHandles.get(taskId)?.kill();
607
+ };
608
+ /** Stop every live agent; returns one settle promise per agent (a resident's exit, a blocking run's result). */
609
+ const stopAllAgents = (): Promise<unknown>[] => {
610
+ const exits: Promise<unknown>[] = [];
611
+ for (const [taskId, handle] of liveHandles) {
612
+ stoppedTaskIds.add(taskId);
613
+ const killed = handle.kill();
614
+ const settled = handle.result ?? killed;
615
+ if (settled) exits.push(settled);
616
+ }
617
+ return exits;
618
+ };
619
+
620
+ /**
621
+ * Markdown prose for the transcript views — pi-tui's real renderer, loaded
622
+ * lazily on first view; falls back to plain wrapped text with one warning.
623
+ */
624
+ let prosePromise: Promise<ProseRenderer | undefined> | undefined;
625
+ const getProse = (ctx: ExtensionContext): Promise<ProseRenderer | undefined> =>
626
+ (prosePromise ??= createMarkdownProse().catch((error) => {
627
+ try {
628
+ ctx.ui.notify(`Subagent viewer: markdown rendering unavailable (${(error as Error).message}) — using plain text.`, "warning");
629
+ } catch {
630
+ // no UI — plain text is fine
631
+ }
632
+ return undefined;
633
+ }));
634
+
635
+ /**
636
+ * Liveness for the transcript view: change-driven repaints (one registry
637
+ * event per provider delta, coalesced to ~80ms) plus a 1s ticker for elapsed
638
+ * time. `relevant` filters by the event's taskId so unrelated concurrent
639
+ * children's deltas don't invalidate the view's cache.
640
+ */
641
+ const liveRepaint = (tui: { requestRender(): void }, invalidate: () => void, relevant: (taskId?: string) => boolean = () => true) => {
642
+ let coalesce: ReturnType<typeof setTimeout> | undefined;
643
+ const repaint = () => {
644
+ invalidate();
645
+ tui.requestRender();
646
+ };
647
+ const unsubscribe = liveRuns.subscribe((taskId) => {
648
+ if (!relevant(taskId) || coalesce) return;
649
+ coalesce = setTimeout(() => {
650
+ coalesce = undefined;
651
+ repaint();
652
+ }, 80);
653
+ coalesce.unref?.();
654
+ });
655
+ const ticker = setInterval(repaint, 1000);
656
+ ticker.unref?.();
657
+ return {
658
+ repaint,
659
+ cleanup: () => {
660
+ clearInterval(ticker);
661
+ if (coalesce) clearTimeout(coalesce);
662
+ unsubscribe();
663
+ },
664
+ };
665
+ };
666
+
667
+ /**
668
+ * Claude Code's agent view: with no view open, arrows move the strip
669
+ * selection and Enter swaps the transcript region to the selected child (a
670
+ * non-capturing full-width overlay composited over the main transcript; the
671
+ * editor keeps focus, strip keys keep working). Once a view IS open the panel
672
+ * switches to read mode (see the input hook): arrows/page keys scroll it, Tab
673
+ * retargets to the next agent, Enter/esc close it and the main transcript is
674
+ * back.
675
+ */
676
+ let view: { retarget(taskId: string): void; scrollBy(delta: number): void; close(): void } | undefined;
677
+ /** Rows kept clear at the bottom: token counter + editor(3) + mode line + strip hint + strip rows + shell section + cwd/status(2) + one spare. */
678
+ const viewReserve = () => {
679
+ const shellLines = panel.shellLineCount();
680
+ return panel.rowCount() + (shellLines ? shellLines + 1 : 0) + 9;
681
+ };
682
+
683
+ const openView = (ctx: ExtensionContext, taskId: string) => {
684
+ if (view) return view.retarget(taskId);
685
+ let currentId = taskId;
686
+ let scroll = 0;
687
+ let maxScroll = 0;
688
+ let closed = false;
689
+ let repaintFn: (() => void) | undefined;
690
+ let doneFn: ((result: null) => void) | undefined;
691
+ const entry = {
692
+ retarget(id: string) {
693
+ if (currentId === id) return;
694
+ currentId = id;
695
+ scroll = 0;
696
+ panel.setView(id);
697
+ repaintFn?.();
698
+ },
699
+ scrollBy(delta: number) {
700
+ scroll = Math.max(0, Math.min(maxScroll, scroll + delta));
701
+ repaintFn?.();
702
+ },
703
+ close() {
704
+ if (closed) return;
705
+ closed = true;
706
+ if (view === entry) view = undefined;
707
+ panel.setView(undefined);
708
+ doneFn?.(null);
709
+ },
710
+ };
711
+ view = entry;
712
+ panel.setView(currentId);
713
+ void (async () => {
714
+ const prose = await getProse(ctx);
715
+ if (closed) return;
716
+ await ctx.ui.custom<null>(
717
+ (tui, theme, _keybindings, done) => {
718
+ doneFn = done;
719
+ // close() raced ahead of the factory — dissolve as soon as we exist.
720
+ if (closed) queueMicrotask(() => done(null));
721
+ const paint = { fg: safeThemePaint(theme), bold: safeThemeBold(theme) };
722
+ let cache: { width: number; lines: string[] } | undefined;
723
+ const live = liveRepaint(
724
+ tui,
725
+ () => {
726
+ cache = undefined;
727
+ },
728
+ (taskId) => taskId === undefined || taskId === currentId,
729
+ );
730
+ repaintFn = live.repaint;
731
+ const height = () => Math.max(8, tui.terminal.rows - viewReserve());
732
+ return {
733
+ render: (width: number) => {
734
+ if (cache?.width === width) return cache.lines;
735
+ const run = liveRuns.get(currentId);
736
+ let lines: string[];
737
+ if (run) {
738
+ const transcript = renderTranscript({ run, width, height: height(), scroll, now: Date.now(), prose }, paint);
739
+ maxScroll = transcript.maxScroll;
740
+ lines = transcript.lines;
741
+ } else {
742
+ lines = [paint.fg("dim", "agent gone")];
743
+ }
744
+ cache = { width, lines: lines.map((line) => truncateLine(line, width)) };
745
+ return cache.lines;
746
+ },
747
+ invalidate: () => {
748
+ cache = undefined;
749
+ },
750
+ dispose: () => live.cleanup(),
751
+ };
752
+ },
753
+ { overlay: true, overlayOptions: { row: 0, col: 0, width: "100%", nonCapturing: true } },
754
+ );
755
+ })().catch(() => entry.close()); // full teardown (clears view + panel.setView) so state can't desync
756
+ };
757
+ const closeView = () => view?.close();
758
+
759
+ let panelHookRegistered = false;
760
+ const registerPanelInputHook = (registerCtx: ExtensionContext) => {
761
+ if (panelHookRegistered || !registerCtx.hasUI) return;
762
+ panelHookRegistered = true;
763
+ let chordArmed = false;
764
+ const leave = () => {
765
+ panel.setFocus(undefined);
766
+ closeView();
767
+ };
768
+ type KeyResult = { consume: boolean } | undefined;
769
+ // Actions shared by both key modes (browse and read), declared once so the
770
+ // two switches below never drift. esc consumes only when idle — a streaming
771
+ // turn must stay interruptible.
772
+ const doLeave = (ctx: ExtensionContext): KeyResult => {
773
+ leave();
774
+ return ctx.isIdle() ? { consume: true } : undefined;
775
+ };
776
+ // `taskId` targets a specific run (read mode stops the VIEWED agent, which
777
+ // can differ from the windowed strip selection); default is the selection.
778
+ const doStop = (taskId?: string): KeyResult => {
779
+ const id = taskId ?? panel.selectedRun()?.taskId;
780
+ if (id) stopAgent(id);
781
+ return { consume: true };
782
+ };
783
+ const doStopAll = (): KeyResult => {
784
+ stopAllAgents();
785
+ return { consume: true };
786
+ };
787
+ /** Unrecognized key: ctrl+x keeps the stop-all chord armed, else drop focus
788
+ * (closing any open view) and let the byte resume in the editor. */
789
+ const closeAndPassthrough = (): KeyResult => {
790
+ if (chordArmed) return { consume: true };
791
+ leave();
792
+ return undefined;
793
+ };
794
+ const DOWN_KEYS = new Set(["\x1b[B", "\x1bOB"]);
795
+ /** Keys the agents branch owns; any other decode (typing, shell-only keys
796
+ * like left/space) drops focus and passes through — stated positively so a
797
+ * future StripKey addition is foreign here by default. */
798
+ const AGENT_KEYS = new Set<StripKey>(["up", "down", "open", "leave", "stop", "stopAll", "pageUp", "pageDown"]);
799
+ /** Row 0 exists only past the synthetic `main` row. */
800
+ const focusFirstAgentRow = (): boolean => {
801
+ if (panel.rowCount() <= 1) return false;
802
+ panel.setFocus(0);
803
+ return true;
804
+ };
805
+ /** The shell-manager stages (chip → list → details); agents keys untouched. */
806
+ const handleShellKey = (data: string, ctx: ExtensionContext): KeyResult => {
807
+ // Focus moved to a dialog/overlay — drop soft focus, let it have the key.
808
+ if (!panel.editorFocused()) {
809
+ panel.setShellFocus(undefined);
810
+ return undefined;
811
+ }
812
+ // Derive the row order once per keystroke; every step below reuses it.
813
+ const ids = panel.shellIds();
814
+ const focus = panel.anchoredShellFocus(ids);
815
+ if (!focus) return undefined;
816
+ const decoded = decodeStripKey(data, false);
817
+ // ctrl+x (the agents stop-all chord arm) means nothing in the shell
818
+ // stages — swallow it rather than leaking the raw \x18 into the editor.
819
+ if (decoded.chordArmed) return { consume: true };
820
+ const result = reduceShellKey(focus, decoded.key, ids);
821
+ if (result.effect === "passthrough") {
822
+ panel.setShellFocus(undefined);
823
+ return undefined; // typing resumes in the editor, byte included
824
+ }
825
+ if (result.effect === "toAgents") {
826
+ // No agent rows to land on → the byte passes through to the editor,
827
+ // consistent with the initial-entry branch.
828
+ panel.setShellFocus(undefined);
829
+ return focusFirstAgentRow() ? { consume: true } : undefined;
830
+ }
831
+ if (result.effect === "stopSelected") {
832
+ try {
833
+ panel.selectedShellTask()?.stop();
834
+ } catch {
835
+ // A dead process's stop() throwing must not break the panel.
836
+ }
837
+ panel.setShellFocus(result.focus, ids);
838
+ return { consume: true };
839
+ }
840
+ panel.setShellFocus(result.focus, ids);
841
+ // While the model streams, esc must still interrupt it — consume only when idle.
842
+ if (decoded.key === "leave" && result.focus === undefined) {
843
+ return ctx.isIdle() ? { consume: true } : undefined;
844
+ }
845
+ return { consume: true };
846
+ };
847
+ try {
848
+ registerCtx.ui.onTerminalInput((data) => {
849
+ const ctx = lastCtx ?? registerCtx;
850
+ // Enter focus: down-arrow while the editor holds real focus. Unlike the
851
+ // workflow strip we do NOT require the editor to be idle — the whole
852
+ // point is inspecting agents (and shells) that run while the main turn
853
+ // is in flight. This branch is the per-keystroke hot path — the
854
+ // O(rows) checks run only once a Down actually asks to focus, never on
855
+ // plain typing. Claude Code's order: the FIRST ↓ lands on the shells
856
+ // chip when shells are running; the next ↓ moves into the agent rows.
857
+ if (panel.focusIndex === undefined && panel.shellFocus === undefined) {
858
+ if (!DOWN_KEYS.has(data) || !panel.editorFocused()) return undefined;
859
+ if (panel.shellChipAvailable()) {
860
+ panel.setShellFocus({ stage: "chip" });
861
+ return { consume: true };
862
+ }
863
+ return focusFirstAgentRow() ? { consume: true } : undefined;
864
+ }
865
+ if (panel.shellFocus !== undefined) return handleShellKey(data, ctx);
866
+ const focusIndex = panel.focusIndex;
867
+ if (focusIndex === undefined) return undefined; // unreachable: one of the two focuses was set
868
+ // The strip emptied under us (finished rows lingered out) — let go.
869
+ if (panel.rowCount() <= 1) {
870
+ leave();
871
+ return undefined;
872
+ }
873
+ // Focus moved to a dialog/overlay — drop soft focus, let it have the key.
874
+ if (!panel.editorFocused()) {
875
+ leave();
876
+ return undefined;
877
+ }
878
+ const decoded = decodeStripKey(data, chordArmed);
879
+ chordArmed = decoded.chordArmed;
880
+ // Read mode: while a transcript view is open the arrows/page keys
881
+ // scroll IT (the natural gesture — pi's main screen has no mouse
882
+ // wheel, so PageUp/PageDown alone were the only path and few reach
883
+ // for them), Tab retargets to the next agent, Enter/esc close, and
884
+ // ← closes the transcript but keeps the panel focused on that agent
885
+ // (setView parked the highlight there) so ↑/↓ can pick a different
886
+ // agent and Enter reopens — Claude Code's back-to-selection gesture.
887
+ if (view) {
888
+ switch (decoded.key) {
889
+ case "up":
890
+ view.scrollBy(1);
891
+ return { consume: true };
892
+ case "down":
893
+ view.scrollBy(-1);
894
+ return { consume: true };
895
+ case "pageUp":
896
+ view.scrollBy(10);
897
+ return { consume: true };
898
+ case "pageDown":
899
+ view.scrollBy(-10);
900
+ return { consume: true };
901
+ case "left":
902
+ closeView();
903
+ return { consume: true };
904
+ case "switch": {
905
+ // Retarget to the next agent, anchored to the VIEWED run in the
906
+ // full row list (not the windowed selection) so it stays correct
907
+ // once the viewed run scrolls past MAX_STRIP_ROWS; setView then
908
+ // moves the strip highlight to it when it is in-window.
909
+ const nextId = panel.nextAgentAfter(panel.viewedTaskId());
910
+ if (nextId) openView(ctx, nextId);
911
+ return { consume: true };
912
+ }
913
+ case "open": // Enter toggles the view shut, mirroring Enter-to-open.
914
+ closeView();
915
+ return { consume: true };
916
+ case "leave":
917
+ return doLeave(ctx);
918
+ case "stop":
919
+ // Stop the agent whose transcript is open, not the strip selection.
920
+ return doStop(panel.viewedTaskId());
921
+ case "stopAll":
922
+ return doStopAll();
923
+ default:
924
+ return closeAndPassthrough(); // typing closes the view and resumes in the editor
925
+ }
926
+ }
927
+ if (!decoded.key || !AGENT_KEYS.has(decoded.key)) return closeAndPassthrough();
928
+ switch (decoded.key) {
929
+ case "up":
930
+ if (focusIndex === 0) {
931
+ // Back up out of the agent rows: onto the shells chip when
932
+ // shells are running, otherwise out of the panel entirely.
933
+ leave();
934
+ if (panel.shellChipAvailable()) panel.setShellFocus({ stage: "chip" });
935
+ } else panel.setFocus(focusIndex - 1);
936
+ return { consume: true };
937
+ case "down":
938
+ panel.setFocus(Math.min(panel.rowCount() - 1, focusIndex + 1));
939
+ return { consume: true };
940
+ case "leave":
941
+ return doLeave(ctx);
942
+ case "open": {
943
+ // Claude Code's model: Enter swaps the view to the selection (and
944
+ // switches an open view); Enter on `main` restores the main
945
+ // transcript. Focus stays in the strip for the next switch.
946
+ const run = panel.selectedRun();
947
+ if (run) openView(ctx, run.taskId);
948
+ else closeView();
949
+ return { consume: true };
950
+ }
951
+ case "stop":
952
+ return doStop();
953
+ case "stopAll":
954
+ return doStopAll();
955
+ case "pageUp":
956
+ case "pageDown":
957
+ // No transcript open → nothing to scroll; swallow so the page
958
+ // keys never leak into the editor while the strip holds focus.
959
+ return { consume: true };
960
+ }
961
+ });
962
+ } catch {
963
+ // Mode without raw terminal input (print/RPC) — the strip is view-only.
964
+ }
965
+ };
966
+
261
967
  // Compact transcript rendering for the notifications this extension injects
262
968
  // (full body on ctrl+o); the verbose framing stays model-only.
263
969
  for (const customType of ["subagent-message", "subagent-result"]) {
@@ -266,20 +972,68 @@ export default function subagentsExtension(pi: ExtensionAPI) {
266
972
  );
267
973
  }
268
974
 
269
- const notify = (customType: string, text: string, details: Record<string, unknown>) => {
270
- pi.sendMessage(
271
- { customType, content: [{ type: "text", text }], display: true, details },
272
- { deliverAs: "followUp", triggerTurn: true },
273
- );
975
+ const notifier = createTaskNotifier(pi);
976
+ /** Every subagent notification; silent during shutdown (a teardown kill is not news). */
977
+ const notify: typeof notifier = (customType, text, details) => {
978
+ if (shuttingDown) return;
979
+ notifier(customType, text, details);
980
+ };
981
+
982
+ /**
983
+ * Anything a child sends the parent — a mid-run message or its final report —
984
+ * is unmetered model output landing in the parent's context, N children at
985
+ * once (review S4). Past the cap it is PERSISTED, not cut: the full text goes
986
+ * to `<session-dir>/tool-results/<id>.txt` (Claude Code's convention, the
987
+ * same block MCP results use) and the notification carries a preview plus
988
+ * the path, so nothing is lost — the parent reads the file when it needs it.
989
+ */
990
+ const MESSAGE_TO_MAIN_CAP = 10_000;
991
+ const bounded = (text: string, id: string, maxBytes: number): string =>
992
+ persistIfLarge(text, { dir: sessionResultsDir(lastCtx), id, maxBytes });
993
+
994
+ /**
995
+ * Route a nested run's SendMessage {to: "main"}: to the CHILD that spawned it
996
+ * (steered into the turn that is blocking on the grandchild's result) when
997
+ * that child is a live resident, else to the main conversation. Without this
998
+ * the spawning child never saw the message and the main model was told about
999
+ * work it did not delegate (SUBAGENT-REVIEW L4).
1000
+ */
1001
+ const relayToParent = (parentTaskId: string, name: string, message: string, summary?: string) => {
1002
+ // Bounded once: the fallback reuses the same body (and persisted file).
1003
+ const body = boundedMessage(name, message);
1004
+ const toMain = () => announceAgentMessage(name, body, summary);
1005
+ const parent = residents.get(parentTaskId);
1006
+ if (!parent || parent.handle.exited()) {
1007
+ toMain();
1008
+ return;
1009
+ }
1010
+ void parent.handle.send(`Message from your subagent ${name}${summary ? ` (${summary})` : ""}:\n\n${body}`).catch(toMain);
274
1011
  };
275
1012
 
1013
+ const boundedMessage = (name: string, message: string) => bounded(message, `message-${name}-${Date.now()}`, MESSAGE_TO_MAIN_CAP);
1014
+ const announceAgentMessage = (name: string, body: string, summary?: string) =>
1015
+ notify("subagent-message", systemNotification(`Message from agent ${name}${summary ? ` (${summary})` : ""}:\n\n${body}`), { name, summary });
276
1016
  /** Relay a child's send_message {to: "main"} into this conversation. */
277
- const notifyAgentMessage = (name: string, message: string, summary?: string) =>
278
- notify(
279
- "subagent-message",
280
- systemNotification(`Message from agent ${name}${summary ? ` (${summary})` : ""}:\n\n${message}`),
281
- { name, summary },
282
- );
1017
+ const notifyAgentMessage = (name: string, message: string, summary?: string) => announceAgentMessage(name, boundedMessage(name, message), summary);
1018
+
1019
+ /** A fork's system prompt is persisted beside its session so a later resume can restore it (review S6). */
1020
+ const FORK_PROMPT_FILE = "system-prompt.md";
1021
+ const persistForkPrompt = (record: AgentRunRecord, prompt: string) => {
1022
+ if (!record.sessionSearchDir) return;
1023
+ try {
1024
+ writeFileSync(join(record.sessionSearchDir, FORK_PROMPT_FILE), prompt);
1025
+ } catch {
1026
+ // The run still works; only a later resume loses the prompt (and says so).
1027
+ }
1028
+ };
1029
+ const readForkPrompt = (record: AgentRunRecord): string | undefined => {
1030
+ if (!record.sessionSearchDir) return undefined;
1031
+ try {
1032
+ return readFileSync(join(record.sessionSearchDir, FORK_PROMPT_FILE), "utf-8");
1033
+ } catch {
1034
+ return undefined;
1035
+ }
1036
+ };
283
1037
 
284
1038
  /** Session dir for a run's persisted child session; undefined → child runs --no-session. */
285
1039
  const runSessionDir = (ctx: ExtensionContext, taskId: string): string | undefined => {
@@ -298,22 +1052,216 @@ export default function subagentsExtension(pi: ExtensionAPI) {
298
1052
  agentDef?: AgentDefinition;
299
1053
  }
300
1054
 
301
- /** Start one child (creating a worktree first if asked) and finalize its record when done. */
1055
+ /**
1056
+ * How deep Agent-in-Agent nesting goes: a run at depth < MAX_SPAWN_DEPTH
1057
+ * gets the spawn tool, so main(–) → child(0) → grandchild(1) and no further.
1058
+ * Matches CC (its subagents/forks spawn their own agents) while bounding
1059
+ * runaway fan-out — every session shares one in-process event loop.
1060
+ */
1061
+ const MAX_SPAWN_DEPTH = 1;
1062
+
1063
+ /**
1064
+ * Catalog text for the injected spawn tool, cached per cwd: building a
1065
+ * child's toolset must not re-scan the agent dirs on every spawn — a wide
1066
+ * fan-out would pay the filesystem walk N times for a description most
1067
+ * children never use. Fork is not listed (unavailable inside a child).
1068
+ * Validation in execute() still loads fresh; only this string is pinned,
1069
+ * which is what tool descriptions are anyway — static for the session.
1070
+ */
1071
+ const childCatalogCache = new Map<string, string>();
1072
+ const childCatalog = (cwd: string): string => {
1073
+ let text = childCatalogCache.get(cwd);
1074
+ if (text === undefined) {
1075
+ text = loadAgents(cwd)
1076
+ .map((a) => `- ${a.name}: ${a.description || "(no description)"}${toolsClause(a)}`)
1077
+ .join("\n");
1078
+ childCatalogCache.set(cwd, text);
1079
+ }
1080
+ return text;
1081
+ };
1082
+
1083
+ /**
1084
+ * The Agent tool injected into a child session (CC parity: subagents can
1085
+ * spawn subagents). It delegates to THIS extension's runtime and registries,
1086
+ * so a nested run streams into the same panel (as a `└` row under its
1087
+ * parent), is gated through the parent's real permission pipeline, and is
1088
+ * addressable by SendMessage like any other run. Foreground-only: the call
1089
+ * returns when the nested agent finishes — no fork, worktree, or background
1090
+ * from inside a child.
1091
+ */
1092
+ const childAgentTool = (parentRecord: AgentRunRecord, parentDepth: number): ToolDefinition =>
1093
+ ({
1094
+ name: "Agent",
1095
+ label: "Agent",
1096
+ description:
1097
+ "Delegate a scoped task to a specialist subagent running in its own context window. The call BLOCKS until the agent finishes and returns its report — use it for well-scoped work whose intermediate output you don't need (broad searches, focused verification, independent research). Give a complete, self-contained task: the agent cannot ask follow-ups. If an agent's description says it should be used proactively, use it without being asked. Available agents:\n" +
1098
+ `${childCatalog(parentRecord.cwd)}\n` +
1099
+ '(No "fork" here — forking is only available to the main conversation.)',
1100
+ // Same CC-parity surface as the main Agent tool (H1): `prompt` is
1101
+ // accepted as `task`, `description` is tolerated, and the agent name is
1102
+ // matched case-insensitively — a nested subagent is just as likely to be
1103
+ // CC-trained.
1104
+ parameters: Type.Object({
1105
+ subagent_type: Type.String({ description: "An agent name from the list in this tool's description" }),
1106
+ task: Type.Optional(Type.String({ description: "The task — a complete, self-contained instruction" })),
1107
+ prompt: Type.Optional(Type.String({ description: "Alias of `task` (Claude Code's name for it)." })),
1108
+ description: Type.Optional(Type.String({ description: "A short (3-5 word) description of the task." })),
1109
+ name: Type.Optional(Type.String({ description: "Name for this run (default: <agent>-<n>)" })),
1110
+ }) as never,
1111
+ async execute(_toolCallId: string, params: unknown, signal?: AbortSignal) {
1112
+ const ctx = lastCtx;
1113
+ const p = (params ?? {}) as { subagent_type?: unknown; task?: unknown; prompt?: unknown; name?: unknown };
1114
+ const agentName = typeof p.subagent_type === "string" ? p.subagent_type : "";
1115
+ const taskInput = typeof p.task === "string" ? p.task : typeof p.prompt === "string" ? p.prompt : "";
1116
+ const task = taskInput.trim();
1117
+ const err = (text: string) => ({ content: [{ type: "text" as const, text }], details: {}, isError: true });
1118
+ if (!ctx) return err("The host session is not ready to spawn agents — retry shortly.");
1119
+ if (agentName.toLowerCase() === FORK_AGENT) return err('"fork" is only available to the main conversation — pick a named agent instead.');
1120
+ const agents = loadAgents(parentRecord.cwd);
1121
+ const agentDef = agents.find((a) => a.name.toLowerCase() === agentName.toLowerCase());
1122
+ if (!agentDef) {
1123
+ const catalog = agents.map((a) => `- ${a.name}: ${a.description || "(no description)"}${toolsClause(a)}`).join("\n");
1124
+ return err(`${agentName ? `Unknown agent "${agentName}"` : "`subagent_type` is required"}. Available agents:\n${catalog}`);
1125
+ }
1126
+ if (!task) return err("`task` is required: a complete, self-contained instruction for the agent (Claude Code names this parameter `prompt` — either is accepted).");
1127
+
1128
+ const { name, note: renamedNote } = resolveRunName(registry, agentDef.name, typeof p.name === "string" ? p.name : undefined);
1129
+ // Same parent-side model resolution as the main Agent tool, minus
1130
+ // per-call overrides: the agent's own model, else the configured
1131
+ // default, else the session model.
1132
+ const available = usableModels(ctx);
1133
+ const resolution = resolveSubagentModel({
1134
+ agentModel: agentDef.model,
1135
+ configuredDefault: applicableSubagentDefault(loadSubagentDefault(os.homedir()), ctx.model),
1136
+ sessionModel: ctx.model,
1137
+ available,
1138
+ });
1139
+ for (const notice of resolution.notices) notifyModelOnce(ctx, notice);
1140
+ const modelNotes = subagentModelNotes(resolution);
1141
+ const resolved = resolution.model ? modelSpec(resolution.model) : undefined;
1142
+
1143
+ const taskId = generateTaskId();
1144
+ const record: AgentRunRecord = {
1145
+ name,
1146
+ agent: agentDef.name,
1147
+ taskId,
1148
+ sessionSearchDir: runSessionDir(ctx, taskId) ?? "",
1149
+ // The SPAWNING agent's cwd, not the top-level session's: a worktree-
1150
+ // isolated parent's nested spawns must work in the same worktree.
1151
+ cwd: parentRecord.cwd,
1152
+ model: resolved,
1153
+ thinking: undefined,
1154
+ depth: parentDepth + 1,
1155
+ };
1156
+ registry.add(record); // SendMessage from main can reach the nested run too
1157
+
1158
+ // The main conversation's Agent calls are classified as delegations
1159
+ // (matcher DELEGATION_TOOLS); a child's nested spawn must be judged the
1160
+ // same way — a prompt-injected child could otherwise hand a task the
1161
+ // parent would never be allowed to a grandchild unclassified (review
1162
+ // S8). The child's own gate never sees this call (Agent is NEVER_GATE
1163
+ // there), so route it through the parent's bridge here. Fails closed.
1164
+ try {
1165
+ const bridge = getPermissionBridge();
1166
+ const verdict = bridge
1167
+ ? await bridge({ toolName: "Agent", input: { subagent_type: agentDef.name, prompt: task, description: name }, cwd: parentRecord.cwd, signal })
1168
+ : { block: true as const, reason: "no permission bridge is available to judge the delegation" };
1169
+ if (verdict?.block) {
1170
+ return {
1171
+ content: [{ type: "text" as const, text: `Nested Agent call refused: ${verdict.reason}` }],
1172
+ details: { agentRuns: [] as AgentRunRecord[] },
1173
+ isError: true,
1174
+ };
1175
+ }
1176
+ } catch (error) {
1177
+ return {
1178
+ content: [{ type: "text" as const, text: `Nested Agent call refused: permission check failed (${(error as Error).message}).` }],
1179
+ details: { agentRuns: [] as AgentRunRecord[] },
1180
+ isError: true,
1181
+ };
1182
+ }
1183
+
1184
+ const result = await executeRun(
1185
+ {
1186
+ request: {
1187
+ agent: agentDef.name,
1188
+ task,
1189
+ name,
1190
+ model: resolved,
1191
+ fallbackModel: spawnFallbackModel(resolved, resolution.source, ctx.model),
1192
+ },
1193
+ record,
1194
+ agentDef,
1195
+ },
1196
+ ctx,
1197
+ signal, // the spawning child aborting (stop/esc) kills the nested run
1198
+ undefined,
1199
+ { taskId: parentRecord.taskId, depth: parentDepth },
1200
+ );
1201
+ const notes = [...(renamedNote ? [renamedNote] : []), ...modelNotes];
1202
+ return {
1203
+ content: [
1204
+ {
1205
+ type: "text" as const,
1206
+ text: `${notes.length ? `${notes.join("\n")}\n\n` : ""}${result.output}\n\n(${formatStats(result.toolCalls, result.usage)})`,
1207
+ },
1208
+ ],
1209
+ details: { agentRuns: [record] },
1210
+ isError: result.failed ?? false,
1211
+ };
1212
+ },
1213
+ }) as ToolDefinition;
1214
+
1215
+ /**
1216
+ * The spawn tool a run is entitled to. THE one depth gate: it reads the
1217
+ * record's own depth (set once at creation), so every handout site —
1218
+ * fresh spawn, background resident, SendMessage resume — enforces
1219
+ * MAX_SPAWN_DEPTH identically. A resumed grandchild stays capped.
1220
+ */
1221
+ const spawnToolsFor = (record: AgentRunRecord): ToolDefinition[] => {
1222
+ const depth = record.depth ?? 0;
1223
+ return depth < MAX_SPAWN_DEPTH ? [childAgentTool(record, depth)] : [];
1224
+ };
1225
+
1226
+ /**
1227
+ * The text a child's first turn is prompted with: a fork gets the inherited-
1228
+ * context framing (plus, in a worktree, where its paths moved to); a named
1229
+ * agent gets the task as written. Shared by both spawn paths.
1230
+ */
1231
+ const frameTask = (request: RunRequest, worktree: Worktree | undefined, parentCwd: string): string =>
1232
+ request.fork ? forkTaskMessage(request.task, worktree ? { worktreePath: worktree.path, parentCwd } : undefined) : request.task;
1233
+
1234
+ /** Create a run's isolation worktree and point its record at it (both spawn paths). */
1235
+ const isolateInWorktree = async (ctx: ExtensionContext, record: AgentRunRecord, name: string): Promise<Worktree> => {
1236
+ const worktree = await createWorktree(ctx.cwd, name);
1237
+ record.cwd = worktree.path;
1238
+ record.worktree = true;
1239
+ return worktree;
1240
+ };
1241
+
1242
+ /**
1243
+ * Run one child to completion, blocking until it finishes — the shape a
1244
+ * NESTED spawn needs (a child's own Agent call has no notification surface,
1245
+ * so it waits for the result) and the one-shot modes need (a `-p` process
1246
+ * exits when the turn settles, so a background run would be orphaned).
1247
+ * Main-conversation runs in TUI/RPC sessions are background residents
1248
+ * instead. Creates a worktree first if asked and finalizes the record when
1249
+ * done. `parent` links the run under its parent in the panel tree.
1250
+ */
302
1251
  const executeRun = async (
303
1252
  prepared: PreparedRun,
304
1253
  ctx: ExtensionContext,
305
- forkFrom: string | undefined,
306
1254
  signal: AbortSignal | undefined,
307
- onProgress: (toolCalls: number, text: string, usage: UsageTotals) => void,
308
- onStarted?: (handle: ChildHandle, worktree?: Worktree) => void,
1255
+ forkFrom?: string,
1256
+ parent?: { taskId: string; depth: number },
1257
+ onProgress?: (toolCalls: number, text: string, usage: UsageTotals) => void,
309
1258
  ): Promise<TaskResult> => {
310
1259
  const { request, record, agentDef } = prepared;
311
1260
 
312
1261
  let worktree: Worktree | undefined;
313
1262
  if (request.worktree) {
314
1263
  try {
315
- worktree = await createWorktree(ctx.cwd, request.name);
316
- record.cwd = worktree.path;
1264
+ worktree = await isolateInWorktree(ctx, record, request.name);
317
1265
  } catch (error) {
318
1266
  return {
319
1267
  agent: request.agent,
@@ -328,25 +1276,36 @@ export default function subagentsExtension(pi: ExtensionAPI) {
328
1276
  }
329
1277
  }
330
1278
 
331
- runningNames.add(request.name);
332
- const handle = startChild({
1279
+ runningIds.add(record.taskId);
1280
+ const live = trackLiveRun(record, request, parent);
1281
+ const runtime = await getRuntime(ctx);
1282
+ const handle = runtime.run({
1283
+ name: request.name,
333
1284
  agent: agentDef,
334
- task: request.task,
1285
+ task: frameTask(request, worktree, ctx.cwd),
335
1286
  cwd: record.cwd,
336
1287
  forkFrom: request.fork ? forkFrom : undefined,
1288
+ parentSystemPrompt: request.fork ? ctx.getSystemPrompt() : undefined,
337
1289
  sessionDir: record.sessionSearchDir || undefined,
338
1290
  model: request.model,
1291
+ fallbackModel: request.fallbackModel,
339
1292
  thinking: request.thinking,
340
1293
  signal,
341
- onProgress,
342
- onMessageToMain: (message, summary) => notifyAgentMessage(request.name, message, summary),
1294
+ onProgress: (toolCalls, text, usage) => {
1295
+ live.progress(toolCalls, usage);
1296
+ onProgress?.(toolCalls, text, usage);
1297
+ },
1298
+ sink: live.sink,
1299
+ onMessageToMain: (message, summary) =>
1300
+ parent ? relayToParent(parent.taskId, request.name, message, summary) : notifyAgentMessage(request.name, message, summary),
1301
+ extraTools: spawnToolsFor(record),
343
1302
  });
344
- liveChildren.add(handle);
345
- onStarted?.(handle, worktree);
1303
+ liveHandles.set(record.taskId, handle);
346
1304
 
347
1305
  try {
348
1306
  const outcome: ChildOutcome = await handle.result;
349
1307
  registry.sessionFileFor(record); // resolve now that the child has written it
1308
+ live.finish(Boolean(outcome.failed));
350
1309
  let worktreeKept: boolean | undefined;
351
1310
  if (worktree) {
352
1311
  worktreeKept = !(await cleanupWorktree(ctx.cwd, worktree));
@@ -361,6 +1320,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
361
1320
  worktreeKept,
362
1321
  };
363
1322
  } catch (error) {
1323
+ live.finish(true);
364
1324
  if (worktree) await cleanupWorktree(ctx.cwd, worktree);
365
1325
  return {
366
1326
  agent: request.agent,
@@ -373,57 +1333,73 @@ export default function subagentsExtension(pi: ExtensionAPI) {
373
1333
  failed: true,
374
1334
  };
375
1335
  } finally {
376
- liveChildren.delete(handle);
377
- runningNames.delete(request.name);
1336
+ runningIds.delete(record.taskId);
378
1337
  }
379
1338
  };
380
1339
 
381
1340
  pi.registerTool({
382
- name: "subagent",
383
- label: "Subagent",
384
- ...ccToolRenderers<{ agent?: string; task?: string; tasks?: unknown[]; action?: string }>("Subagent", {
385
- title: (a) =>
386
- a?.tasks?.length
387
- ? `${a.agent ?? "agents"} × ${a.tasks.length}`
388
- : a
389
- ? [a.agent, a.task ?? a.action].filter(Boolean).join(": ")
390
- : undefined,
1341
+ name: "Agent",
1342
+ label: "Agent",
1343
+ ...ccToolRenderers<{ subagent_type?: string; task?: string; prompt?: string; description?: string; action?: string }>("Agent", {
1344
+ title: (a) => (a ? [a.subagent_type, a.description ?? a.task ?? a.prompt ?? a.action].filter(Boolean).join(": ") || undefined : undefined),
391
1345
  }),
392
1346
  description:
393
- "Delegate a task to a specialist agent that runs in its own context window and reports back. Use it for well-scoped work whose intermediate output you don't need — broad codebase searches, focused reviews, independent research. Give a complete, self-contained task: the agent cannot ask follow-up questions. The available agents are listed in the \"Available agents\" system reminder. Pass `tasks` to run several in parallel, `agent: \"fork\"` for a child that inherits this conversation (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), `isolation: \"worktree\"` when parallel agents will edit files, or `run_in_background: true` to keep working while it runs (completion arrives as a notification; manage with task_output/task_stop). Each run gets a name — continue a finished agent later with send_message. action:'list' re-prints the agent catalog.",
1347
+ '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' +
1348
+ "\n" +
1349
+ "## When to use\n" +
1350
+ "- Broad codebase searches or exploration where you need the conclusion, not the file dumps.\n" +
1351
+ "- Self-contained research, reviews, or verification whose intermediate output you won't need again.\n" +
1352
+ "- Independent questions that can run at once — issue multiple Agent tool calls in one message to run them in parallel.\n" +
1353
+ "\n" +
1354
+ "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" +
1355
+ "\n" +
1356
+ "## How agents run\n" +
1357
+ "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 system 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" +
1358
+ "\n" +
1359
+ "## Usage notes\n" +
1360
+ "- Give a complete, self-contained task: the agent cannot ask follow-up questions.\n" +
1361
+ "- 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" +
1362
+ '- `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' +
1363
+ '- `isolation: "worktree"` gives the agent its own git worktree when it will edit files.\n' +
1364
+ "- 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" +
1365
+ "- The agent's final report is not shown to the user, so relay what matters.",
394
1366
  promptSnippet: "Delegate scoped work to a specialist agent in its own context",
395
1367
  parameters: SubagentParams,
396
1368
  async execute(toolCallId, params, signal, onUpdate, ctx) {
397
1369
  const agents = loadAgents(ctx.cwd);
398
1370
 
399
- // A call carrying run options (a task, a name, run_in_background, a
400
- // model/thinking/isolation override, or action:"run") but no `agent`
401
- // or `tasks` is a run that forgot to name its agent. Fail loudly with a
402
- // diagnostic rather than silently returning the catalog: a weaker model
403
- // reads the catalog as a non-sequitur and invents wrong reasons for it,
404
- // instead of learning it omitted `agent`.
1371
+ // `task` is our name, `prompt` Claude Code's; a CC-trained model sends
1372
+ // the latter (H1). Resolve to one value here so the rest of execute is
1373
+ // unaffected.
1374
+ const taskText = params.task ?? params.prompt;
1375
+
1376
+ // A call carrying run options (a task, a name, a model/thinking/isolation
1377
+ // override, or action:"run") but no `subagent_type` is a run that forgot
1378
+ // to name its agent. Fail loudly with a diagnostic rather than silently
1379
+ // returning the catalog: a weaker model reads the catalog as a
1380
+ // non-sequitur and invents wrong reasons for it, instead of learning it
1381
+ // omitted `subagent_type`.
405
1382
  const wantsRun =
406
1383
  params.action === "run" ||
407
- params.task != null ||
1384
+ taskText != null ||
408
1385
  params.name != null ||
409
- params.run_in_background != null ||
410
1386
  params.model != null ||
411
1387
  params.isolation != null ||
412
1388
  params.thinking != null;
413
1389
 
414
- if (params.action === "list" || (!params.agent && !params.tasks && !wantsRun)) {
1390
+ if (params.action === "list" || (!params.subagent_type && !wantsRun)) {
415
1391
  return {
416
1392
  content: [{ type: "text", text: `Available agents:\n${describeAgents(ctx.cwd)}` }],
417
1393
  details: { agents: agents.map((a) => a.name) },
418
1394
  };
419
1395
  }
420
1396
 
421
- if (!params.agent && !params.tasks) {
1397
+ if (!params.subagent_type) {
422
1398
  return {
423
1399
  content: [
424
1400
  {
425
1401
  type: "text",
426
- text: `No \`agent\` given, but you passed run options — this looks like a run that forgot to name its agent. Set \`agent\` to one of the names below (or "fork" to clone this conversation), or pass \`tasks\` to run several. To only browse the catalog, call with action:"list".\n\nAvailable agents:\n${describeAgents(ctx.cwd)}`,
1402
+ 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)}`,
427
1403
  },
428
1404
  ],
429
1405
  details: {},
@@ -431,14 +1407,34 @@ export default function subagentsExtension(pi: ExtensionAPI) {
431
1407
  };
432
1408
  }
433
1409
 
434
- const taken = new Set(registry.names());
435
- const requested: RunRequest[] = (
436
- params.tasks?.length
437
- ? params.tasks
438
- : [{ agent: params.agent ?? "", task: params.task ?? "", name: params.name }]
439
- ).map((entry) => {
440
- const name = entry.name || nextRunName(taken, entry.agent);
441
- taken.add(name);
1410
+ // A run needs a task. A CC-shaped call that put its instruction in an
1411
+ // unrecognized key, or none at all, must fail loud rather than start a
1412
+ // child prompted with "" (H1). Fork included — cloning to do nothing is
1413
+ // never intended.
1414
+ if (!taskText || !taskText.trim()) {
1415
+ return {
1416
+ content: [{ type: "text", text: "`task` is required: a complete, self-contained instruction for the agent (Claude Code names this parameter `prompt` — either is accepted)." }],
1417
+ details: {},
1418
+ isError: true,
1419
+ };
1420
+ }
1421
+
1422
+ // Match `subagent_type` against the catalog case-insensitively and run
1423
+ // the agent under its canonical name: a CC-trained model writes
1424
+ // `Explore`/`Plan` where our bundled agents are `explore`/`plan` (H1).
1425
+ // "Fork" is canonicalized the same way so downstream fork detection
1426
+ // (entry.agent === FORK_AGENT) is case-insensitive too.
1427
+ const requestedType = params.subagent_type!;
1428
+ const canonicalType =
1429
+ requestedType.toLowerCase() === FORK_AGENT
1430
+ ? FORK_AGENT
1431
+ : (agents.find((a) => a.name.toLowerCase() === requestedType.toLowerCase())?.name ?? requestedType);
1432
+
1433
+ /** A requested name already used this session → the run gets a fresh one, and the result says so (M1). */
1434
+ const renamed: string[] = [];
1435
+ const requested: RunRequest[] = [{ agent: canonicalType, task: taskText, name: params.name }].map((entry) => {
1436
+ const { name, note } = resolveRunName(registry, entry.agent, entry.name);
1437
+ if (note) renamed.push(note);
442
1438
  return {
443
1439
  agent: entry.agent,
444
1440
  task: entry.task,
@@ -516,6 +1512,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
516
1512
  cwd: ctx.cwd,
517
1513
  model: request.model,
518
1514
  thinking: request.thinking,
1515
+ depth: 0,
519
1516
  },
520
1517
  };
521
1518
  });
@@ -527,20 +1524,41 @@ export default function subagentsExtension(pi: ExtensionAPI) {
527
1524
  * `provider/id`; anything surprising (a fallback, a provider crossing)
528
1525
  * is said out loud rather than happening silently.
529
1526
  */
530
- const available = ctx.modelRegistry.getAvailable();
1527
+ const available = usableModels(ctx);
531
1528
  const configuredDefault = applicableSubagentDefault(loadSubagentDefault(os.homedir()), ctx.model);
1529
+ /**
1530
+ * What the model is told about its own model choice: the same sentences
1531
+ * the user gets, plus the model each child actually runs on. Deduped —
1532
+ * a batch of spawns that all requested one unavailable alias says it
1533
+ * once. See `subagentModelNotes` for why the model needs this.
1534
+ */
1535
+ const modelNotes = new Set<string>();
532
1536
  for (const p of prepared) {
533
- const resolution = resolveSubagentModel({
534
- requested: p.request.model,
535
- agentModel: p.agentDef?.model,
536
- configuredDefault: configuredDefault?.spec,
537
- sessionModel: ctx.model,
538
- available,
539
- });
1537
+ // A fork inherits the parent transcript, so it must continue on THIS
1538
+ // conversation's exact model — never a configured default or the
1539
+ // automatic same-provider pick, which would move the inherited context
1540
+ // onto a different (often weaker) model. Leaving model undefined makes
1541
+ // the forked session restore its own model. (Per-call `model` on a fork
1542
+ // is already rejected above.)
1543
+ if (p.request.fork) continue;
1544
+ // No per-call or agent-frontmatter override → the automatic default,
1545
+ // which is stable for the session, so reuse the cached resolution
1546
+ // instead of re-ranking the catalog per spawn. An override varies per
1547
+ // call and always resolves fresh.
1548
+ const resolution =
1549
+ p.request.model || p.agentDef?.model
1550
+ ? resolveSubagentModel({
1551
+ requested: p.request.model,
1552
+ agentModel: p.agentDef?.model,
1553
+ configuredDefault: configuredDefault,
1554
+ sessionModel: ctx.model,
1555
+ available,
1556
+ })
1557
+ : resolveAutoDefault(ctx.model, available, configuredDefault);
540
1558
  if (resolution.unresolved) {
541
1559
  // The main model chose this string; the menu lets it retry.
542
1560
  const fallback = resolveSubagentModel({
543
- configuredDefault: configuredDefault?.spec,
1561
+ configuredDefault: configuredDefault,
544
1562
  sessionModel: ctx.model,
545
1563
  available,
546
1564
  });
@@ -565,7 +1583,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
565
1583
  const gate = expensiveModelGate(resolution, ctx.model, params.allow_expensive);
566
1584
  if (gate) {
567
1585
  const fallback = resolveSubagentModel({
568
- configuredDefault: configuredDefault?.spec,
1586
+ configuredDefault: configuredDefault,
569
1587
  sessionModel: ctx.model,
570
1588
  available,
571
1589
  });
@@ -590,219 +1608,271 @@ export default function subagentsExtension(pi: ExtensionAPI) {
590
1608
  };
591
1609
  }
592
1610
  for (const notice of resolution.notices) notifyModelOnce(ctx, notice);
593
- const resolved = resolution.model ? `${resolution.model.provider}/${resolution.model.id}` : undefined;
1611
+ for (const note of subagentModelNotes(resolution)) modelNotes.add(note);
1612
+ const resolved = resolution.model ? modelSpec(resolution.model) : undefined;
594
1613
  p.request.model = resolved;
1614
+ p.request.fallbackModel = spawnFallbackModel(resolved, resolution.source, ctx.model);
595
1615
  p.record.model = resolved;
596
1616
  }
597
1617
 
598
1618
  for (const p of prepared) registry.add(p.record);
599
1619
  const records = prepared.map((p) => p.record);
600
1620
 
601
- // --- Background: RPC children that stay resident after their run, so
602
- // send_message can reach them live (steer mid-turn, prompt when idle).
603
- if (params.run_in_background) {
604
- const lines: string[] = [];
605
- for (const p of prepared) {
606
- let worktree: Worktree | undefined;
607
- if (p.request.worktree) {
608
- try {
609
- worktree = await createWorktree(ctx.cwd, p.request.name);
610
- p.record.cwd = worktree.path;
611
- } catch (error) {
612
- lines.push(`✗ ${p.record.name}: could not create a worktree: ${(error as Error).message}`);
613
- continue;
614
- }
1621
+ // One-shot modes (`-p` / `--mode json`) exit when the turn settles, so
1622
+ // a background run would be orphaned with its report undelivered — run
1623
+ // blocking there and return the report in the tool result instead.
1624
+ if (!sessionOutlivesTurn(ctx.mode)) {
1625
+ // Stream progress through onUpdate (tool_execution_update) like the
1626
+ // old foreground path — a headless JSON/RPC consumer has no panel,
1627
+ // so this is its only signal between dispatch and result.
1628
+ let progress = { toolCalls: 0, text: "", usage: emptyUsage() };
1629
+ const report = () => {
1630
+ const stats = formatStats(progress.toolCalls, progress.usage);
1631
+ const line = `${progress.text ? "✓" : "⏳"} ${prepared[0].request.name} (${stats})${progress.text ? "" : " running…"}`;
1632
+ onUpdate?.({ content: [{ type: "text", text: line }], details: {} });
1633
+ };
1634
+ report();
1635
+ const result = await executeRun(prepared[0], ctx, signal, sessionFile ?? undefined, undefined, (toolCalls, text, usage) => {
1636
+ progress = { toolCalls, text, usage };
1637
+ report();
1638
+ });
1639
+ // Auto mode reviews what the child actually did once it returns; the
1640
+ // permission gate attaches the review to this tool_result.
1641
+ pi.events.emit(SUBAGENT_ACTIONS_CHANNEL, {
1642
+ toolCallId,
1643
+ actions: result.actions ?? [],
1644
+ } satisfies SubagentActionsPayload);
1645
+ const stats = formatStats(result.toolCalls, result.usage);
1646
+ const worktreeNote = result.worktreePath ? `\n\n(Changes left in worktree ${result.worktreePath} — review or merge them.)` : "";
1647
+ // The report arrives inline with no frame, no task id and no note, so a
1648
+ // model told to "retrieve the result with task_output" chased an id that
1649
+ // was never registered (WEAK-MODEL-REVIEW-2026-09-06 M3). Say it up
1650
+ // front, in bash's and monitor's shared wording.
1651
+ prepared[0].record.inline = true;
1652
+ const inlineNote =
1653
+ `${prepared[0].record.name} (task ${prepared[0].record.taskId}): ${oneShotNote("agent")} ` +
1654
+ "Its report follows here; there is no background task to poll, and task_output does not know this id.";
1655
+ // The background path reports renames and model fallbacks in its own
1656
+ // result; a one-shot run must say the same things or the model reads
1657
+ // the report believing it named the run and the model.
1658
+ const notes = [...renamed, ...modelNotes];
1659
+ return {
1660
+ content: [
1661
+ {
1662
+ type: "text",
1663
+ text: `${notes.length ? `${notes.join("\n")}\n\n` : ""}${inlineNote}\n\n${result.output}${worktreeNote}\n\n(${stats})`,
1664
+ },
1665
+ ],
1666
+ details: { results: [result], agentRuns: records },
1667
+ isError: result.failed ?? false,
1668
+ };
1669
+ }
1670
+
1671
+ // Every run is a background resident (Claude Code parity): the call
1672
+ // returns as soon as the child is launched, the report arrives as a
1673
+ // steered system notification, and the child stays resident so
1674
+ // SendMessage can reach it live (steer mid-turn, prompt when idle).
1675
+ const lines: string[] = [...renamed, ...modelNotes];
1676
+ const runtime = await getRuntime(ctx);
1677
+ for (const p of prepared) {
1678
+ let worktree: Worktree | undefined;
1679
+ if (p.request.worktree) {
1680
+ try {
1681
+ worktree = await isolateInWorktree(ctx, p.record, p.request.name);
1682
+ } catch (error) {
1683
+ lines.push(`✗ ${p.record.name}: could not create a worktree: ${(error as Error).message}`);
1684
+ continue;
615
1685
  }
1686
+ }
616
1687
 
617
- const logPath = p.record.sessionSearchDir ? join(p.record.sessionSearchDir, "output.log") : undefined;
618
- let finish!: () => void;
619
- const finished = new Promise<void>((resolve) => {
620
- finish = resolve;
621
- });
1688
+ const logPath = p.record.sessionSearchDir ? join(p.record.sessionSearchDir, "output.log") : undefined;
1689
+ let lastLogWrite = 0;
1690
+ let finish!: () => void;
1691
+ const finished = new Promise<void>((resolve) => {
1692
+ finish = resolve;
1693
+ });
622
1694
 
623
- // The child's final output. task.output() falls back to this
624
- // because handle.snapshot().text can be blank at a turn boundary —
625
- // the same reason the log write below uses `|| outcome.output`.
626
- // Keeps task_output consistent with the log and the completion
627
- // notification rather than showing an empty body in that case.
628
- let lastOutput = "";
629
- const resident: Resident = { handle: undefined as never, turnHandlers: [] };
630
- const worktreeNote = worktree
631
- ? `\n\n(Running in worktree ${worktree.path} — kept while the agent stays resident.)`
632
- : "";
633
- resident.turnHandlers.push((outcome) => {
634
- task.status = outcome.failed ? "failed" : "completed";
635
- task.finishedAt = Date.now();
636
- finish();
637
- const stats = formatStats(outcome.toolCalls, outcome.usage);
638
- notify(
639
- "subagent-result",
640
- systemNotification(`Background agent ${p.record.name} (${p.record.taskId}) ${outcome.failed ? "failed" : "completed"} (${stats}). It stays resident — message it with send_message.\n\n${outcome.output.slice(0, OUTPUT_CAP)}${worktreeNote}`),
641
- { taskId: p.record.taskId, name: p.record.name, failed: outcome.failed ?? false },
642
- );
643
- });
1695
+ // The child's final output. task.output() falls back to this
1696
+ // because handle.snapshot().text can be blank at a turn boundary —
1697
+ // the same reason the log write below uses `|| outcome.output`.
1698
+ // Keeps task_output consistent with the log and the completion
1699
+ // notification rather than showing an empty body in that case.
1700
+ let lastOutput = "";
1701
+ // The initial task's own report. task_output for THIS task must return the
1702
+ // first turn's reply — not the resident's growing multi-turn transcript —
1703
+ // the same way a SendMessage task returns one reply (review S12).
1704
+ let firstTurnOutput: string | undefined;
1705
+ const live = trackLiveRun(p.record, p.request);
1706
+ const resident: Resident = { handle: undefined as never, turnHandlers: [] };
1707
+ const worktreeNote = worktree
1708
+ ? `\n\n(Running in worktree ${worktree.path} — kept while the agent stays resident.)`
1709
+ : "";
1710
+ resident.turnHandlers.push((outcome, review) => {
1711
+ const stopped = stoppedTaskIds.has(p.record.taskId);
1712
+ task.status = stopped ? "stopped" : outcome.failed ? "failed" : "completed";
1713
+ task.finishedAt = Date.now();
1714
+ firstTurnOutput = outcome.output;
1715
+ finish();
1716
+ const stats = formatStats(outcome.toolCalls, outcome.usage);
1717
+ const verb = stopped ? "was stopped" : outcome.failed ? "failed" : "completed";
1718
+ notify(
1719
+ "subagent-result",
1720
+ systemNotification(
1721
+ withReview(
1722
+ `Agent ${p.record.name} (task ${p.record.taskId}) ${verb} (${stats}). It stays reachable with SendMessage.\n\n${bounded(outcome.output, `${p.record.taskId}-report`, OUTPUT_CAP)}${worktreeNote}`,
1723
+ review,
1724
+ ),
1725
+ ),
1726
+ { taskId: p.record.taskId, name: p.record.name, failed: stopped ? false : (outcome.failed ?? false), reviewed: review !== undefined },
1727
+ );
1728
+ });
644
1729
 
645
- const handle = startRpcChild({
646
- agent: p.agentDef,
647
- cwd: p.record.cwd,
648
- forkFrom: p.request.fork ? (sessionFile ?? undefined) : undefined,
649
- sessionDir: p.record.sessionSearchDir || undefined,
650
- model: p.request.model,
651
- thinking: p.request.thinking,
652
- onProgress: (_toolCalls, text) => {
653
- if (logPath && text) writeFileSync(logPath, text);
654
- },
655
- onMessageToMain: (message, summary) => notifyAgentMessage(p.record.name, message, summary),
656
- onTurnEnd: (outcome) => {
657
- registry.sessionFileFor(p.record);
658
- lastOutput = resident.handle.snapshot().text || outcome.output;
659
- if (logPath) writeFileSync(logPath, lastOutput);
660
- // Auto mode reviews a background/resident turn's action sequence as a
661
- // whole, like the foreground path (line ~758). There is no tool_result
662
- // to attach to here, so the gate reviews on receipt and notifies.
663
- if (outcome.actions?.length) {
664
- pi.events.emit(SUBAGENT_ACTIONS_CHANNEL, {
665
- toolCallId: p.record.taskId,
666
- actions: outcome.actions,
667
- background: true,
668
- agentName: p.record.name,
669
- } satisfies SubagentActionsPayload);
670
- }
671
- const handler = resident.turnHandlers.shift();
1730
+ // Idle reaper: every run is a resident now, so without this a long
1731
+ // session accumulates one live AgentSession (extension instances,
1732
+ // message array, MCP tool refs) per delegation for its whole life.
1733
+ // After RESIDENT_IDLE_MS idle the session is released quietly; the
1734
+ // agent stays reachable — SendMessage resumes it from its session
1735
+ // file. Worktree residents are exempt: release would remove the
1736
+ // worktree a resume still needs. Armed from every turn end.
1737
+ let reaper: ReturnType<typeof setTimeout> | undefined;
1738
+ const armReaper = () => {
1739
+ if (worktree) return;
1740
+ if (reaper) clearTimeout(reaper);
1741
+ reaper = setTimeout(() => {
1742
+ reaper = undefined;
1743
+ if (handle.exited()) return;
1744
+ if (handle.busy()) {
1745
+ armReaper();
1746
+ return;
1747
+ }
1748
+ handle.release();
1749
+ }, RESIDENT_IDLE_MS);
1750
+ reaper.unref?.();
1751
+ };
1752
+ // Captured as a string: onExit runs from a `.finally` long after this
1753
+ // turn's ctx may be stale (review S5).
1754
+ const parentCwd = ctx.cwd;
1755
+ const forkPrompt = p.request.fork ? ctx.getSystemPrompt() : undefined;
1756
+ if (forkPrompt !== undefined) persistForkPrompt(p.record, forkPrompt);
1757
+ // No `signal` here on purpose: a resident outlives the spawning turn and
1758
+ // is stopped through task_stop / the panel, not by the turn ending (S15).
1759
+ const handle = await runtime.runResident({
1760
+ name: p.record.name,
1761
+ agent: p.agentDef,
1762
+ cwd: p.record.cwd,
1763
+ forkFrom: p.request.fork ? (sessionFile ?? undefined) : undefined,
1764
+ parentSystemPrompt: forkPrompt,
1765
+ sessionDir: p.record.sessionSearchDir || undefined,
1766
+ model: p.request.model,
1767
+ fallbackModel: p.request.fallbackModel,
1768
+ thinking: p.request.thinking,
1769
+ onProgress: (toolCalls, text, usage) => {
1770
+ live.progress(toolCalls, usage);
1771
+ // Throttled: onProgress fires per tool call/message with the whole
1772
+ // turn text so far, and this path now carries EVERY run — an
1773
+ // unthrottled sync rewrite would be O(n²) bytes on the hot path.
1774
+ // onTurnEnd below flushes the final state, so the tail is never lost.
1775
+ if (logPath && text && Date.now() - lastLogWrite > LOG_WRITE_INTERVAL_MS) {
1776
+ lastLogWrite = Date.now();
1777
+ writeFileSync(logPath, text);
1778
+ }
1779
+ },
1780
+ sink: live.sink,
1781
+ onMessageToMain: (message, summary) => notifyAgentMessage(p.record.name, message, summary),
1782
+ extraTools: spawnToolsFor(p.record),
1783
+ onTurnEnd: (outcome) => {
1784
+ registry.sessionFileFor(p.record);
1785
+ live.settle();
1786
+ lastOutput = resident.handle.snapshot().text || outcome.output;
1787
+ if (logPath) writeFileSync(logPath, lastOutput);
1788
+ // The handler is claimed now (so a message arriving mid-review pairs
1789
+ // with the NEXT turn), but runs only once auto mode's hand-back
1790
+ // review of this turn's action sequence has answered, so the
1791
+ // verdict rides in the same notification as the report instead of
1792
+ // trailing it. The wait is bounded (hand-back-review.ts) and
1793
+ // answered synchronously when auto mode is off.
1794
+ const handler = resident.turnHandlers.shift();
1795
+ armReaper();
1796
+ void awaitHandBackReview(pi.events, p.record, outcome.actions).then((review) => {
672
1797
  if (handler) {
673
- handler(outcome);
1798
+ handler(outcome, review);
674
1799
  } else {
675
1800
  // A turn nobody is waiting on (e.g. a steer that raced past its
676
1801
  // target turn and ran on its own) must still surface.
677
1802
  notify(
678
1803
  "subagent-result",
679
- systemNotification(`Update from ${p.record.name}:\n\n${outcome.output.slice(0, OUTPUT_CAP)}`),
680
- { name: p.record.name, failed: outcome.failed ?? false },
1804
+ systemNotification(withReview(`Update from ${p.record.name}:\n\n${bounded(outcome.output, `${p.record.taskId}-update-${Date.now()}`, OUTPUT_CAP)}`, review)),
1805
+ { name: p.record.name, failed: outcome.failed ?? false, reviewed: review !== undefined },
681
1806
  );
682
1807
  }
683
- },
684
- onExit: () => {
685
- liveChildren.delete(handle);
686
- if (residents.get(p.record.name) === resident) residents.delete(p.record.name);
687
- if (worktree) void cleanupWorktree(ctx.cwd, worktree);
688
- },
689
- });
690
- resident.handle = handle;
691
- residents.set(p.record.name, resident);
692
- liveChildren.add(handle);
693
-
694
- const task: BackgroundTask = {
695
- id: p.record.taskId,
696
- kind: "subagent",
697
- description: `${p.record.name}: ${p.request.task.slice(0, 80)}`,
698
- status: "running",
699
- startedAt: Date.now(),
700
- logPath,
701
- output: () => handle.snapshot().text || lastOutput,
702
- stop: () => handle.kill(),
703
- resident: () => !handle.exited(),
704
- finished,
705
- };
706
- pi.events.emit(TASK_REGISTER_CHANNEL, task);
707
- handle.send(p.request.fork ? forkTaskMessage(p.request.task) : p.request.task);
708
-
709
- lines.push(
710
- `⏳ ${p.record.name} (task ${p.record.taskId}) running in background${logPath ? ` — interim output readable at ${logPath}` : ""}`,
711
- );
712
- }
713
- return {
714
- content: [
715
- {
716
- type: "text",
717
- text: `${lines.join("\n")}\n\nCompletion (with the agent's report) will arrive as a system notification on its own — you do not need to wait for it or poll; keep working. Call task_output only if your next step cannot proceed without the result (block=true waits). Stop with task_stop; send_message reaches the agent even while it runs (the message is steered into its current turn).`,
718
- },
719
- ],
720
- details: { agentRuns: records, background: true },
721
- };
722
- }
723
-
724
- // --- Foreground: bounded pool, live progress, blocking result.
725
- const progress = requested.map((r) => ({ name: r.name, toolCalls: 0, text: "", usage: emptyUsage() }));
726
- const report = () => {
727
- const lines = progress.map((p) => {
728
- const stats = formatStats(p.toolCalls, p.usage);
729
- return `${p.text ? "✓" : "⏳"} ${p.name} (${stats})${p.text ? "" : " running…"}`;
1808
+ });
1809
+ },
1810
+ onExit: () => {
1811
+ if (reaper) clearTimeout(reaper);
1812
+ live.finish(stoppedTaskIds.has(p.record.taskId) ? "stopped" : false);
1813
+ if (residents.get(p.record.taskId) === resident) residents.delete(p.record.taskId);
1814
+ liveHandles.delete(p.record.taskId);
1815
+ if (worktree) void cleanupWorktree(parentCwd, worktree);
1816
+ },
730
1817
  });
731
- onUpdate?.({ content: [{ type: "text", text: lines.join("\n") }], details: {} });
732
- };
733
- report();
734
-
735
- const results = await runPool(
736
- prepared.map((p, index) => ({ p, index })),
737
- MAX_PARALLEL,
738
- ({ p, index }) =>
739
- executeRun(p, ctx, sessionFile ?? undefined, signal, (toolCalls, text, usage) => {
740
- progress[index] = { name: p.request.name, toolCalls, text, usage };
741
- report();
742
- }),
743
- );
1818
+ resident.handle = handle;
1819
+ residents.set(p.record.taskId, resident);
1820
+ liveHandles.set(p.record.taskId, handle);
744
1821
 
745
- const text = results
746
- .map((r) => {
747
- const stats = formatStats(r.toolCalls, r.usage);
748
- const worktreeNote = r.worktreePath
749
- ? `\n\n(Changes left in worktree ${r.worktreePath} — review or merge them.)`
750
- : "";
751
- return results.length > 1
752
- ? `## ${r.name}${r.failed ? " (failed)" : ""} (${stats})\nTask: ${r.task}\n\n${r.output}${worktreeNote}`
753
- : `${r.output}${worktreeNote}\n\n(${stats})`;
754
- })
755
- .join("\n\n---\n\n");
756
-
757
- // Auto mode reviews what a child actually did once it returns, catching a
758
- // sequence whose individual steps each passed. Emitted rather than
759
- // checked here: the permission gate owns the classifier.
760
- pi.events.emit(SUBAGENT_ACTIONS_CHANNEL, {
761
- toolCallId,
762
- actions: results.flatMap((r) => r.actions ?? []),
763
- } satisfies SubagentActionsPayload);
1822
+ const task: BackgroundTask = {
1823
+ id: p.record.taskId,
1824
+ kind: "subagent",
1825
+ ownUI: true, // rendered live by the subagents panel's agent strip
1826
+ description: `${p.record.name}: ${p.request.task.slice(0, 80)}`,
1827
+ status: "running",
1828
+ startedAt: Date.now(),
1829
+ logPath,
1830
+ output: () => firstTurnOutput ?? (handle.snapshot().text || lastOutput),
1831
+ stop: () => handle.kill(),
1832
+ resident: () => !handle.exited(),
1833
+ finished,
1834
+ };
1835
+ pi.events.emit(TASK_REGISTER_CHANNEL, task);
1836
+ void handle.send(frameTask(p.request, worktree, parentCwd));
764
1837
 
1838
+ spawnedThisLoop.add(p.record.taskId);
1839
+ lines.push(
1840
+ `⏳ ${p.record.name} (task ${p.record.taskId}) running in background${logPath ? ` — interim output readable at ${logPath}` : ""}`,
1841
+ );
1842
+ }
765
1843
  return {
766
- content: [{ type: "text", text }],
767
- details: { results, agentRuns: records },
768
- isError: results.every((r) => r.failed),
1844
+ content: [
1845
+ {
1846
+ type: "text",
1847
+ text: `${lines.join("\n")}\n\nCompletion (with the agent's report) will arrive as a system notification on its own — you do not need to wait for it or poll; keep working. ${PENDING_RESULT_PROHIBITION} Call task_output only if your next step cannot proceed without the result (block=true waits). Stop with task_stop; SendMessage reaches the agent even while it runs (the message is steered into its current turn).`,
1848
+ },
1849
+ ],
1850
+ details: { agentRuns: records, background: true },
769
1851
  };
770
1852
  },
771
1853
  });
772
1854
 
773
- const isSubagentChild = process.env.PI_SUBAGENT_CHILD === "1";
774
1855
  pi.registerTool({
775
- name: "send_message",
1856
+ name: "SendMessage",
776
1857
  label: "Send Message",
777
1858
  ...ccToolRenderers<{ to?: string; summary?: string; message?: string }>("Send Message", {
778
1859
  title: (a) => (a?.to ? `to ${a.to}${a.summary ? `: ${a.summary}` : ""}` : undefined),
779
1860
  }),
780
1861
  description:
781
- 'Send a message to another agent. From the main conversation: address a previously spawned subagent by the name from its spawn result (or task id) — 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 file with full context; replies arrive as system notifications. From inside a subagent: use to: "main" to report progress, findings, or questions to the main conversation mid-run — your plain text output is NOT visible to it until you finish.',
782
- // A background child's model must know it can report back; the parent keeps
783
- // the tool deferred instead (see the DEFER emit below).
784
- promptSnippet: isSubagentChild
785
- ? 'Report progress or findings to the main conversation (to: "main")'
786
- : undefined,
1862
+ '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 system notifications. (A subagent reporting back to the main conversation uses its own SendMessage with to: "main".)',
787
1863
  parameters: Type.Object({
788
- to: Type.String({ description: 'Agent name (or task id) from a previous subagent run — or "main" from inside a subagent' }),
1864
+ to: Type.String({ description: "Agent name (or task id) from a previous Agent run" }),
789
1865
  message: Type.String({ description: "Plain text message for the agent" }),
790
1866
  summary: Type.Optional(Type.String({ description: "5-10 word preview shown in the UI" })),
791
1867
  }),
792
1868
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
793
1869
  if (params.to === "main") {
794
- if (!isSubagentChild) {
795
- return {
796
- content: [{ type: "text", text: 'You are the main conversation — "main" is only a valid recipient from inside a subagent.' }],
797
- details: {},
798
- isError: true,
799
- };
800
- }
801
- // No IPC needed: the parent reads this tool result off the child's
802
- // event stream (toMainMessage in rpc-turns.ts) and relays it.
1870
+ // The main conversation's SendMessage only addresses spawned agents; the
1871
+ // "main" recipient exists only on a subagent's own injected SendMessage.
803
1872
  return {
804
- content: [{ type: "text", text: "Message delivered to the main conversation." }],
805
- details: { toMain: true, message: params.message, summary: params.summary },
1873
+ content: [{ type: "text", text: 'You are the main conversation — "main" is only a valid recipient from inside a subagent.' }],
1874
+ details: {},
1875
+ isError: true,
806
1876
  };
807
1877
  }
808
1878
 
@@ -813,26 +1883,49 @@ export default function subagentsExtension(pi: ExtensionAPI) {
813
1883
  content: [
814
1884
  {
815
1885
  type: "text",
816
- text: `No run named "${params.to}". send_message only reaches subagent runs already started this session — address them by their run name or task id, not by a catalog agent name. Start one with the subagent tool first if you haven't. Known runs: ${known}.`,
1886
+ 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}.`,
817
1887
  },
818
1888
  ],
819
1889
  details: {},
820
1890
  isError: true,
821
1891
  };
822
1892
  }
823
- // Resident background agent: reach it live over its RPC channel.
824
- const resident = residents.get(record.name);
1893
+ // Resident background agent: reach its in-process session live.
1894
+ const resident = residents.get(record.taskId);
825
1895
  if (resident && !resident.handle.exited()) {
826
1896
  if (resident.handle.busy()) {
827
- resident.handle.send(params.message);
1897
+ let delivery: "started" | "steered";
1898
+ try {
1899
+ delivery = await resident.handle.send(params.message);
1900
+ } catch (error) {
1901
+ return {
1902
+ content: [{ type: "text", text: `Could not deliver the message to ${record.name}: ${(error as Error).message}` }],
1903
+ details: {},
1904
+ isError: true,
1905
+ };
1906
+ }
1907
+ if (delivery === "steered") {
1908
+ return {
1909
+ content: [
1910
+ {
1911
+ type: "text",
1912
+ 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.`,
1913
+ },
1914
+ ],
1915
+ details: { agentRuns: [record], steered: true },
1916
+ };
1917
+ }
1918
+ // The turn had just ended (settle window): the message rides the next
1919
+ // turn, whose reply nobody has claimed yet — announce it as an update.
1920
+ spawnedThisLoop.add(record.taskId); // arm the anti-fabrication backstop (WEAK-MODEL-REVIEW H1)
828
1921
  return {
829
1922
  content: [
830
1923
  {
831
1924
  type: "text",
832
- 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.`,
1925
+ text: `${record.name}'s turn had just finished; the message starts its next turn. The reply will arrive as a system notification ("Update from ${record.name}"). ${PENDING_RESULT_PROHIBITION}`,
833
1926
  },
834
1927
  ],
835
- details: { agentRuns: [record], steered: true },
1928
+ details: { agentRuns: [record] },
836
1929
  };
837
1930
  }
838
1931
 
@@ -849,6 +1942,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
849
1942
  const task: BackgroundTask = {
850
1943
  id: taskId,
851
1944
  kind: "subagent",
1945
+ ownUI: true, // rendered live by the subagents panel's agent strip
852
1946
  description: `message to ${record.name}${params.summary ? `: ${params.summary}` : ""}`,
853
1947
  status: "running",
854
1948
  startedAt: Date.now(),
@@ -857,7 +1951,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
857
1951
  resident: () => !resident.handle.exited(),
858
1952
  finished,
859
1953
  };
860
- resident.turnHandlers.push((outcome) => {
1954
+ resident.turnHandlers.push((outcome, review) => {
861
1955
  task.status = outcome.failed ? "failed" : "completed";
862
1956
  task.finishedAt = Date.now();
863
1957
  replyOutput = outcome.output;
@@ -865,24 +1959,28 @@ export default function subagentsExtension(pi: ExtensionAPI) {
865
1959
  const stats = formatStats(outcome.toolCalls, outcome.usage);
866
1960
  notify(
867
1961
  "subagent-result",
868
- systemNotification(`Reply from ${record.name} (${stats}):\n\n${outcome.output.slice(0, OUTPUT_CAP)}`),
869
- { taskId, name: record.name, failed: outcome.failed ?? false },
1962
+ systemNotification(withReview(`Reply from ${record.name} (${stats}):\n\n${bounded(outcome.output, `${taskId}-reply`, OUTPUT_CAP)}`, review)),
1963
+ { taskId, name: record.name, failed: outcome.failed ?? false, reviewed: review !== undefined },
870
1964
  );
871
1965
  });
872
1966
  pi.events.emit(TASK_REGISTER_CHANNEL, task);
873
- resident.handle.send(params.message);
1967
+ void resident.handle.send(params.message);
1968
+ // Same anti-fabrication guard as a fresh spawn (WEAK-MODEL-REVIEW H1):
1969
+ // the reply arrives as a notification, so forbid predicting it and arm
1970
+ // the agent_end backstop (keyed by the agent's persistent id).
1971
+ spawnedThisLoop.add(record.taskId);
874
1972
  return {
875
1973
  content: [
876
1974
  {
877
1975
  type: "text",
878
- text: `Message sent to resident agent ${record.name} (task ${taskId}). The reply will arrive as a system notification; inspect with task_output.`,
1976
+ text: `Message sent to resident agent ${record.name} (task ${taskId}). The reply will arrive as a system notification on its own; inspect with task_output. ${PENDING_RESULT_PROHIBITION}`,
879
1977
  },
880
1978
  ],
881
1979
  details: { agentRuns: [record], taskId },
882
1980
  };
883
1981
  }
884
1982
 
885
- if (runningNames.has(record.name)) {
1983
+ if (runningIds.has(record.taskId)) {
886
1984
  return {
887
1985
  content: [
888
1986
  { type: "text", text: `Agent ${record.name} is still running — wait for its completion notification, then resend.` },
@@ -892,6 +1990,22 @@ export default function subagentsExtension(pi: ExtensionAPI) {
892
1990
  };
893
1991
  }
894
1992
  const sessionFile = registry.sessionFileFor(record);
1993
+ // A fork has no agent definition: its identity is the parent's prompt at
1994
+ // spawn time, persisted beside its session. Without it a resume would run
1995
+ // on pi's stock prompt and default tools — refuse instead (review S6).
1996
+ const forkPrompt = record.agent === FORK_AGENT ? readForkPrompt(record) : undefined;
1997
+ if (record.agent === FORK_AGENT && forkPrompt === undefined) {
1998
+ return {
1999
+ content: [
2000
+ {
2001
+ type: "text",
2002
+ 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.`,
2003
+ },
2004
+ ],
2005
+ details: {},
2006
+ isError: true,
2007
+ };
2008
+ }
895
2009
  if (!sessionFile) {
896
2010
  return {
897
2011
  content: [
@@ -902,27 +2016,60 @@ export default function subagentsExtension(pi: ExtensionAPI) {
902
2016
  };
903
2017
  }
904
2018
 
2019
+ // A worktree run whose agent left no changes had its worktree removed at
2020
+ // exit, but the record (persisted in the tool result) still points there.
2021
+ // Resume in the parent cwd instead of a deleted directory, say so in the
2022
+ // reply, and fix the record so later messages don't repeat the note (M2).
2023
+ let relocationNote = "";
2024
+ if (!existsSync(record.cwd)) {
2025
+ 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`;
2026
+ record.cwd = ctx.cwd;
2027
+ record.worktree = undefined;
2028
+ }
2029
+
905
2030
  const taskId = generateTaskId();
906
2031
  let finish!: () => void;
907
2032
  const finished = new Promise<void>((resolve) => {
908
2033
  finish = resolve;
909
2034
  });
910
2035
 
911
- runningNames.add(record.name);
912
- const handle = startChild({
2036
+ runningIds.add(record.taskId);
2037
+ // Re-enter the panel: the finished run's entry flips back to running (or
2038
+ // registers fresh after a session resume) and streams this turn live.
2039
+ const live = trackLiveRun(record, {
2040
+ agent: record.agent,
2041
+ name: record.name,
2042
+ task: params.message,
2043
+ model: record.model,
2044
+ thinking: record.thinking,
2045
+ });
2046
+ const runtime = await getRuntime(ctx);
2047
+ const handle = runtime.run({
2048
+ name: record.name,
2049
+ agent: loadAgents(ctx.cwd).find((a) => a.name === record.agent),
913
2050
  task: params.message,
914
2051
  cwd: record.cwd,
915
2052
  sessionFile,
2053
+ parentSystemPrompt: forkPrompt,
916
2054
  model: record.model,
2055
+ // Resume degrades to the session model if the recorded model has become
2056
+ // unavailable since the original run, rather than failing the resume
2057
+ // outright (the note surfaces the swap). Undefined for a fork (record.model
2058
+ // unset — it inherits) or when the record already is the session model.
2059
+ fallbackModel:
2060
+ record.model && ctx.model && record.model !== modelSpec(ctx.model) ? modelSpec(ctx.model) : undefined,
917
2061
  thinking: record.thinking,
918
- onProgress: () => {},
2062
+ onProgress: (toolCalls, _text, usage) => live.progress(toolCalls, usage),
2063
+ sink: live.sink,
919
2064
  onMessageToMain: (message, summary) => notifyAgentMessage(record.name, message, summary),
2065
+ extraTools: spawnToolsFor(record),
920
2066
  });
921
- liveChildren.add(handle);
2067
+ liveHandles.set(record.taskId, handle);
922
2068
 
923
2069
  const task: BackgroundTask = {
924
2070
  id: taskId,
925
2071
  kind: "subagent",
2072
+ ownUI: true, // rendered live by the subagents panel's agent strip
926
2073
  description: `message to ${record.name}${params.summary ? `: ${params.summary}` : ""}`,
927
2074
  status: "running",
928
2075
  startedAt: Date.now(),
@@ -933,37 +2080,86 @@ export default function subagentsExtension(pi: ExtensionAPI) {
933
2080
  pi.events.emit(TASK_REGISTER_CHANNEL, task);
934
2081
 
935
2082
  void handle.result.then((outcome) => {
936
- liveChildren.delete(handle);
937
- runningNames.delete(record.name);
2083
+ runningIds.delete(record.taskId);
2084
+ live.finish(Boolean(outcome.failed));
938
2085
  task.status = outcome.failed ? "failed" : "completed";
939
2086
  task.finishedAt = Date.now();
940
2087
  finish();
941
2088
  const stats = formatStats(outcome.toolCalls, outcome.usage);
942
2089
  notify(
943
2090
  "subagent-result",
944
- systemNotification(`Reply from ${record.name} (${stats}):\n\n${outcome.output.slice(0, OUTPUT_CAP)}`),
2091
+ systemNotification(`Reply from ${record.name} (${stats}):\n\n${relocationNote}${bounded(outcome.output, `${taskId}-reply`, OUTPUT_CAP)}`),
945
2092
  { taskId, name: record.name, failed: outcome.failed ?? false },
946
2093
  );
947
2094
  });
948
2095
 
2096
+ spawnedThisLoop.add(record.taskId); // arm the anti-fabrication backstop (WEAK-MODEL-REVIEW H1)
949
2097
  return {
950
2098
  content: [
951
2099
  {
952
2100
  type: "text",
953
- text: `Message sent to ${record.name} (task ${taskId}). The reply will arrive as a system notification; inspect with task_output.`,
2101
+ text: `Message sent to ${record.name} (task ${taskId}). The reply will arrive as a system notification on its own; inspect with task_output. ${PENDING_RESULT_PROHIBITION}`,
954
2102
  },
955
2103
  ],
956
2104
  details: { agentRuns: [record], taskId },
957
2105
  };
958
2106
  },
959
2107
  });
960
- if (!isSubagentChild) {
961
- pi.events.emit(DEFER_CHANNEL, { name: "send_message", keywords: ["message", "agent", "resume", "continue", "teammate"] });
962
- }
2108
+ pi.events.emit(DEFER_CHANNEL, { name: "SendMessage", keywords: ["message", "agent", "resume", "continue", "teammate"] });
2109
+
2110
+ // Same precedence as SendMessage's own dispatch (a live resident is reachable
2111
+ // even while a blocking run of the same name is in flight): resident-live
2112
+ // first, then running, then a finished run resumable from its session.
2113
+ const agentStatus = (record: AgentRunRecord) => {
2114
+ const resident = residents.get(record.taskId);
2115
+ if (resident && !resident.handle.exited()) return "resident (reachable live)";
2116
+ if (runningIds.has(record.taskId)) return "running";
2117
+ // A one-shot run returned its report in the tool result and never registered
2118
+ // a background task, so pointing at task_output here sends the model chasing
2119
+ // an unknown id (WEAK-MODEL-REVIEW-2026-09-06 M3).
2120
+ if (record.inline) return "finished (one-shot run, report was returned inline)";
2121
+ return "finished (resume with SendMessage)";
2122
+ };
2123
+
2124
+ pi.registerTool({
2125
+ name: "list_agents",
2126
+ label: "List Agents",
2127
+ ...ccToolRenderers("List Agents"),
2128
+ description:
2129
+ "List the agents spawned in this session — their names, types, and current status — so you know which ones SendMessage can reach. `running` is executing now; `resident` is idle but reachable live; `finished` is resumable from its session. This lists running/spawned instances, not the agent catalog (use Agent action:'list' for that).",
2130
+ parameters: Type.Object({}),
2131
+ async execute() {
2132
+ const runs = registry.list();
2133
+ if (runs.length === 0) {
2134
+ return {
2135
+ content: [{ type: "text", text: "No agents have been spawned in this session yet. Start one with the Agent tool." }],
2136
+ details: { agents: [] as unknown[] },
2137
+ };
2138
+ }
2139
+ const agents = runs.map((r) => ({ name: r.name, agent: r.agent, taskId: r.taskId, status: agentStatus(r) }));
2140
+ const lines = agents.map((a) => `- ${a.name} [${a.agent}] — ${a.status} (task ${a.taskId})`);
2141
+ return {
2142
+ content: [{ type: "text", text: `Agents spawned this session:\n${lines.join("\n")}` }],
2143
+ details: { agents },
2144
+ };
2145
+ },
2146
+ });
2147
+ pi.events.emit(DEFER_CHANNEL, { name: "list_agents", keywords: ["agents", "list", "running", "spawned", "subagents", "who"] });
963
2148
 
964
2149
  pi.registerCommand("agents", {
965
- description: "List available subagents",
2150
+ description: "Open the live subagent panel, or list available agents",
966
2151
  handler: async (_args, ctx) => {
2152
+ // With live children, focus the strip on the newest and open its view
2153
+ // (Claude Code's agent panel); otherwise there is nothing running, so
2154
+ // list the catalog the way the tool's action:"list" does.
2155
+ if (ctx.hasUI && panel.rowCount() > 1) {
2156
+ panel.setFocus(1); // row 0 is `main`; 1 is the newest live child
2157
+ const run = panel.selectedRun();
2158
+ if (run) {
2159
+ openView(ctx, run.taskId);
2160
+ return;
2161
+ }
2162
+ }
967
2163
  ctx.ui.notify(`Available agents:\n${describeAgents(ctx.cwd)}`, "info");
968
2164
  },
969
2165
  });
@@ -971,9 +2167,9 @@ export default function subagentsExtension(pi: ExtensionAPI) {
971
2167
  const showSubagentModelStatus = (ctx: ExtensionContext) => {
972
2168
  const configured = loadSubagentDefault(os.homedir());
973
2169
  const applicable = applicableSubagentDefault(configured, ctx.model);
974
- const available = ctx.modelRegistry.getAvailable();
2170
+ const available = usableModels(ctx);
975
2171
  const resolution = resolveSubagentModel({
976
- configuredDefault: applicable?.spec,
2172
+ configuredDefault: applicable,
977
2173
  sessionModel: ctx.model,
978
2174
  available,
979
2175
  });
@@ -1007,15 +2203,15 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1007
2203
  return;
1008
2204
  }
1009
2205
  emitModelStatus(ctx);
1010
- ctx.ui.notify('Subagent default set to the session model ("inherit", saved to ~/.claude/settings.json).', "info");
2206
+ ctx.ui.notify('Subagent default set to the session model ("inherit", saved to ~/.onecode/settings.json).', "info");
1011
2207
  return;
1012
2208
  }
1013
2209
 
1014
- const available = ctx.modelRegistry.getAvailable();
2210
+ const available = usableModels(ctx);
1015
2211
  const resolution = resolveSubagentModel({ requested: spec, sessionModel: ctx.model, available });
1016
2212
  if (resolution.unresolved) {
1017
2213
  const fallback = resolveSubagentModel({
1018
- configuredDefault: applicableSubagentDefault(loadSubagentDefault(os.homedir()), ctx.model)?.spec,
2214
+ configuredDefault: applicableSubagentDefault(loadSubagentDefault(os.homedir()), ctx.model),
1019
2215
  sessionModel: ctx.model,
1020
2216
  available,
1021
2217
  });
@@ -1040,13 +2236,17 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1040
2236
  }
1041
2237
  }
1042
2238
  try {
1043
- persistSubagentModel(spec, os.homedir());
2239
+ // Stamp the session's containment identity (provider for direct vendors,
2240
+ // provider:route:vendor on gateways) so a later session on a different
2241
+ // provider/vendor can treat this cross-provider choice as stale (a
2242
+ // deliberate choice made here stays honored; see resolveSubagentModel).
2243
+ persistSubagentModel(spec, os.homedir(), ctx.model ? modelIdentity(ctx.model).containment : undefined);
1044
2244
  } catch (error) {
1045
2245
  ctx.ui.notify("Could not save subagent model: " + (error as Error).message, "error");
1046
2246
  return;
1047
2247
  }
1048
2248
  emitModelStatus(ctx);
1049
- ctx.ui.notify(`Subagent default set to "${spec}" (saved to ~/.claude/settings.json).`, "info");
2249
+ ctx.ui.notify(`Subagent default set to "${spec}" (saved to ~/.onecode/settings.json).`, "info");
1050
2250
  };
1051
2251
 
1052
2252
  pi.registerCommand("subagent", {
@@ -1089,7 +2289,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1089
2289
  showSubagentModelStatus(ctx);
1090
2290
  return;
1091
2291
  }
1092
- const available = ctx.modelRegistry.getAvailable();
2292
+ const available = usableModels(ctx);
1093
2293
  if (available.length === 0) {
1094
2294
  ctx.ui.notify("No models are available — authenticate a provider first.", "warning");
1095
2295
  return;
@@ -1104,8 +2304,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
1104
2304
  entries,
1105
2305
  current,
1106
2306
  title: "Select the default subagent model",
1107
- subtitle:
1108
- "Default for subagent/workflow runs unless overridden · type to filter · ↑/↓ · enter · esc",
2307
+ subtitle: "Default for subagent/workflow runs unless overridden",
1109
2308
  },
1110
2309
  tui,
1111
2310
  theme,