one-code-extension 0.1.3 → 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 -202
  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
@@ -0,0 +1,674 @@
1
+ /**
2
+ * In-process subagent execution. Replaces the per-subagent `pi` process (the
3
+ * retired child.ts) with a real `createAgentSession()` in this process — the
4
+ * same technique the workflow runner uses, extended with the subagent tool's
5
+ * needs: named/persisted runs (for SendMessage resume), fork (inherit the
6
+ * parent transcript), and the child-only SendMessage→main tool.
7
+ *
8
+ * One SubagentRuntime is built lazily per extension and shared across all runs:
9
+ * a single ModelRuntime. Each run gets its own AgentSession — and its own
10
+ * resource loader, since disposing a session kills the loader it ran on
11
+ * (lib/agent-loader.ts) — backed by a persisted SessionManager under
12
+ * `<sessionDir>/subagents/<taskId>/`, so a finished run can be resumed from disk.
13
+ */
14
+
15
+ import { createHash } from "node:crypto";
16
+ import { dirname, join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { type AgentSession, type ExtensionError, getAgentDir, SessionManager, type ToolDefinition } from "@earendil-works/pi-coding-agent";
19
+ import type { Api, Model } from "@earendil-works/pi-ai";
20
+ import { whenAborted } from "../lib/abort.ts";
21
+ import { type AgentLoaderOptions, buildAgentLoader, createSharedModelRuntime, openChildSession } from "../lib/agent-loader.ts";
22
+ import { agentPromptIdentity, PrefixWarmGate, prefixWarmKey, type Release } from "../lib/prefix-warm-gate.ts";
23
+ import type { PermissionBridge } from "../permissions/subagent-gate.ts";
24
+ import type { HookBridge } from "../hooks/subagent-bridge.ts";
25
+ import { findConfigured, modelSpec } from "../lib/model-policy.ts";
26
+ import { isModelUnavailableError } from "../auto-mode/model-select.ts";
27
+ import { type AgentDefinition, childToolAllowlist, usableAllowlistedTools } from "./agents.ts";
28
+ import type { ChildHandle, ChildOutcome, RpcChildHandle } from "./outcome.ts";
29
+ import { sendToMainTool } from "./send-to-main-tool.ts";
30
+ import { SessionTurnTracker } from "./session-turns.ts";
31
+ import type { UsageTotals } from "./usage.ts";
32
+ import { streamingText, type TranscriptBlock } from "./live-runs.ts";
33
+ import { cutPlainText, firstNonEmptyLine, stripReminderBlocks, summarizeArgs, textContent } from "../lib/tui-render.ts";
34
+
35
+ /**
36
+ * Optional live sink for the subagent panel: a one-line activity string from
37
+ * the latest tool call, transcript blocks (assistant text, tool calls, tool
38
+ * results) as they settle, and the in-flight assistant message per streaming
39
+ * delta (whole-so-far reference — the consumer extracts text lazily at paint
40
+ * time, spinner-style, to stay O(1) per delta). Purely observational — never
41
+ * affects the run.
42
+ */
43
+ export interface LiveSink {
44
+ onActivity?(toolName: string | undefined, args: unknown, lastText: string): void;
45
+ onBlock?(block: TranscriptBlock): void;
46
+ onStreaming?(message: { content?: unknown } | undefined): void;
47
+ /** One child assistant message's usage, for the all-in footer cost. */
48
+ onUsage?(usage: unknown): void;
49
+ }
50
+
51
+ /** Wall-clock cap on a single run/turn — a hung session shares the main event loop. */
52
+ const WALL_CLOCK_CAP_MS = 30 * 60 * 1000;
53
+
54
+ /**
55
+ * Tools the runtime injects into a child; the permission gate must never gate
56
+ * them. `Agent` is the injected child-spawn tool (index.ts) — the spawn itself
57
+ * is free (matching the main conversation's auto-allowed Agent tool); every
58
+ * tool call the spawned grandchild makes still runs through the real gate.
59
+ */
60
+ const NEVER_GATE = new Set(["structured_output", "SendMessage", "Agent"]);
61
+
62
+ /**
63
+ * The curated extensions loaded into a subagent session (via additionalExtensionPaths,
64
+ * which the loader loads even under noExtensions). Broadly Claude Code's model — a
65
+ * subagent gets project context + freshness + a working toolset, but NOT the frontier
66
+ * chrome (banner/spinner/recap) or the orchestration EXTENSIONS. Nested spawning
67
+ * (CC parity: subagents spawn subagents) is instead an injected `Agent` custom tool
68
+ * (index.ts childAgentTool, passed via extraTools) that delegates to the PARENT
69
+ * extension's runtime — one registry, one panel, one permission gate — and is
70
+ * depth-capped there.
71
+ *
72
+ * `lsp` is deliberately NOT here (matching CC, findings §17.3): a child session is torn
73
+ * down with the raw AgentSession.dispose(), which never fires session_shutdown, so lsp's
74
+ * cleanup would never run and any language server it started would leak for the life of
75
+ * the parent session. MCP is also not listed: its
76
+ * tools are shared in from the parent as customTools (getMcpTools), not reconnected.
77
+ *
78
+ * Order mirrors the package's load order: reminder/deferral sinks (system-reminder,
79
+ * tool-search) first, before anything emitting on their channels (the bus does not replay).
80
+ */
81
+ const EXTENSIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
82
+ const CHILD_EXTENSIONS = ["system-reminder", "tool-search", "claude-context", "file-tracker", "search-tools", "skill", "web", "web-fetch", "notebook"];
83
+ const CHILD_EXTENSION_PATHS = CHILD_EXTENSIONS.map((name) => join(EXTENSIONS_DIR, name, "index.ts"));
84
+
85
+ type Session = AgentSession;
86
+
87
+ /** The fields buildChildSession needs — the common subset of a blocking and a resident run. */
88
+ interface ChildSessionSpec {
89
+ cwd: string;
90
+ /** The run's name, shown in the permission prompts its tool calls bubble to the user. */
91
+ name?: string;
92
+ agent?: AgentDefinition;
93
+ /** Parent session file — present for a fork run (inherit the parent transcript). */
94
+ forkFrom?: string;
95
+ /** The parent's current system prompt, applied to a fork so it continues as the parent. */
96
+ parentSystemPrompt?: string;
97
+ /** Existing persisted session to resume (SendMessage to a finished agent). */
98
+ sessionFile?: string;
99
+ /** Where a new run's persisted session lands; falls back to an in-memory session. */
100
+ sessionDir?: string;
101
+ /** Resolved concrete model as `provider/id` (undefined for a fork — restored from the session). */
102
+ model?: string;
103
+ /**
104
+ * Session model to fall back to when `model` (an automatic pick) cannot spawn.
105
+ * Set only for non-per-call picks: a per-call model surfaces its error instead
106
+ * of being silently swapped. See buildChildSession.
107
+ */
108
+ fallbackModel?: string;
109
+ thinking?: string;
110
+ /** The child called SendMessage {to: "main"} — relay it to the main conversation. */
111
+ onMessageToMain?: (message: string, summary?: string) => void;
112
+ /** Extra injected tools (e.g. the child-spawn Agent tool for depth-0 children). */
113
+ extraTools?: ToolDefinition[];
114
+ }
115
+
116
+ export interface SubagentRunOptions extends ChildSessionSpec {
117
+ task: string;
118
+ signal?: AbortSignal;
119
+ onProgress: (toolCalls: number, lastText: string, usage: UsageTotals) => void;
120
+ /** Optional live sink for the subagent panel (activity + transcript blocks). */
121
+ sink?: LiveSink;
122
+ }
123
+
124
+ export interface ResidentRunOptions extends Omit<ChildSessionSpec, "sessionFile"> {
125
+ onProgress: (toolCalls: number, lastText: string, usage: UsageTotals) => void;
126
+ /** Fires at the end of EVERY turn (initial task and later messages alike). */
127
+ onTurnEnd: (outcome: ChildOutcome) => void;
128
+ onExit?: () => void;
129
+ /** Optional live sink for the subagent panel (activity + transcript blocks). */
130
+ sink?: LiveSink;
131
+ }
132
+
133
+ /** Prepend a one-time spawn note (e.g. a model fallback) to an outcome's output. */
134
+ function withNote(outcome: ChildOutcome, note: string | undefined): ChildOutcome {
135
+ return note ? { ...outcome, output: `${note}\n\n${outcome.output}` } : outcome;
136
+ }
137
+
138
+ export class SubagentRuntime {
139
+ private readonly modelRuntime: Awaited<ReturnType<typeof createSharedModelRuntime>>;
140
+ private readonly baseCwd: string;
141
+ /**
142
+ * Live MCP tools shared in from the parent session (empty when no MCP
143
+ * servers). May be async: the parent's MCP connect runs in the background, so
144
+ * an early spawn awaits the settled set instead of snapshotting a
145
+ * still-connecting (possibly empty) one.
146
+ */
147
+ private readonly getMcpTools: () => ToolDefinition[] | Promise<ToolDefinition[]>;
148
+ /**
149
+ * The parent's permission decision closure (undefined until permissions
150
+ * publishes it), wrapped so every call carries the asking run's name: the
151
+ * gate inside a child only knows its session id (one bridge wrapper serves
152
+ * every child, so the name cannot be baked in), and the parent's prompt
153
+ * title needs the name once two children ask back-to-back.
154
+ */
155
+ private readonly getPermissionBridge: () => PermissionBridge | undefined;
156
+ /** The parent hooks extension's bridge (undefined until hooks publishes it); the child's tool hooks. */
157
+ private readonly getHookBridge: () => HookBridge | undefined;
158
+ /** A child extension handler threw (pi swallows it otherwise); surfaced to the user by index.ts. */
159
+ private readonly onExtensionError: (runName: string | undefined, error: ExtensionError) => void;
160
+ private readonly onModelUnusable: (model: string, reason: string) => void;
161
+ /** Child session id → run name, for the bridge wrapper above. */
162
+ private readonly runNames = new Map<string, string>();
163
+ /** Child session id → agent type (`explore`, …), for the hook payload's `agent_type`. */
164
+ private readonly agentTypes = new Map<string, string>();
165
+ /**
166
+ * Parallel children with the same request prefix (same agent prompt and
167
+ * model; forks share the parent's) let the first one start streaming before
168
+ * the rest go, so the fan-out writes the shared prefix once and reads it
169
+ * N-1 times (lib/prefix-warm-gate.ts).
170
+ */
171
+ private readonly warmGate = new PrefixWarmGate();
172
+
173
+ private constructor(
174
+ modelRuntime: SubagentRuntime["modelRuntime"],
175
+ baseCwd: string,
176
+ getMcpTools: () => ToolDefinition[] | Promise<ToolDefinition[]>,
177
+ getPermissionBridge: () => PermissionBridge | undefined,
178
+ getHookBridge: () => HookBridge | undefined,
179
+ onExtensionError: (runName: string | undefined, error: ExtensionError) => void,
180
+ onModelUnusable: (model: string, reason: string) => void,
181
+ ) {
182
+ this.modelRuntime = modelRuntime;
183
+ this.baseCwd = baseCwd;
184
+ this.getMcpTools = getMcpTools;
185
+ this.getHookBridge = getHookBridge;
186
+ this.onExtensionError = onExtensionError;
187
+ this.onModelUnusable = onModelUnusable;
188
+ // One stable wrapper (the gate calls the getter per tool call; allocating a
189
+ // closure each time would be waste). A bridge that vanished between the
190
+ // getter and the call throws, which the gate turns into a fail-closed deny.
191
+ const namedBridge: PermissionBridge = (call) => {
192
+ const bridge = getPermissionBridge();
193
+ if (!bridge) throw new Error("the parent's permission bridge is no longer available");
194
+ return bridge({ ...call, agent: call.agent ?? (call.sessionId ? this.runNames.get(call.sessionId) : undefined) });
195
+ };
196
+ this.getPermissionBridge = () => (getPermissionBridge() ? namedBridge : undefined);
197
+ }
198
+
199
+ static async create(
200
+ cwd: string,
201
+ getMcpTools: () => ToolDefinition[] | Promise<ToolDefinition[]> = () => [],
202
+ getPermissionBridge: () => PermissionBridge | undefined = () => undefined,
203
+ getHookBridge: () => HookBridge | undefined = () => undefined,
204
+ onExtensionError: (runName: string | undefined, error: ExtensionError) => void = () => {},
205
+ onModelUnusable: (model: string, reason: string) => void = () => {},
206
+ ): Promise<SubagentRuntime> {
207
+ const modelRuntime = await createSharedModelRuntime(getAgentDir());
208
+ // Prime the catalog once; spawns read the live snapshot (see resolveModel).
209
+ await modelRuntime.getAvailable();
210
+ return new SubagentRuntime(modelRuntime, cwd, getMcpTools, getPermissionBridge, getHookBridge, onExtensionError, onModelUnusable);
211
+ }
212
+
213
+ /**
214
+ * Resolve a `provider/id` spec against the child runtime's CURRENT catalog.
215
+ * The parent resolved the spec against its own live registry; this runtime
216
+ * has a second one, and they drift (a /login mid-session, a models.json edit,
217
+ * a provider that appeared later). Passing an unresolved model to
218
+ * createAgentSession would make pi fall back to the user's SAVED DEFAULT
219
+ * silently — so an unknown spec throws in the shape the fallback path
220
+ * recognises (`isModelUnavailableError`), which reports the swap instead of
221
+ * hiding it (SUBAGENT-REVIEW M6).
222
+ */
223
+ private async resolveModel(spec: string): Promise<Model<Api>> {
224
+ let found = findConfigured([...this.modelRuntime.getAvailableSnapshot()], spec);
225
+ if (!found) found = findConfigured([...(await this.modelRuntime.getAvailable())], spec);
226
+ if (!found) throw new Error(`model ${spec} not found in the subagent runtime's catalog`);
227
+ return found;
228
+ }
229
+
230
+ /**
231
+ * The loader options for one child session. A fork inherits the parent's
232
+ * system prompt (also on a RESUME of a finished fork, read from the file
233
+ * persisted beside its session — without it the resume ran on pi's stock
234
+ * prompt and default tools, review S6); a named agent carries its own; a
235
+ * plain run gets pi's base prompt. Built fresh per session (agent-loader.ts).
236
+ */
237
+ private childLoaderOptions(spec: ChildSessionSpec): AgentLoaderOptions {
238
+ const systemPrompt = SubagentRuntime.isFork(spec) ? spec.parentSystemPrompt : spec.agent?.systemPrompt;
239
+ return {
240
+ cwd: this.baseCwd,
241
+ agentDir: getAgentDir(),
242
+ systemPrompt,
243
+ neverGate: NEVER_GATE,
244
+ extraExtensionPaths: CHILD_EXTENSION_PATHS,
245
+ // claude-context (above) injects # claudeMd on the child's session_start;
246
+ // pi must not append the same files to the system prompt as well.
247
+ noContextFiles: true,
248
+ getPermissionBridge: this.getPermissionBridge,
249
+ getHookBridge: this.getHookBridge,
250
+ agentTypeOf: (sessionId) => (sessionId ? this.agentTypes.get(sessionId) : undefined),
251
+ };
252
+ }
253
+
254
+ /** A fork run, or a resume of one (its inherited prompt comes back from the persisted file). */
255
+ private static isFork(spec: Pick<ChildSessionSpec, "forkFrom" | "parentSystemPrompt">): boolean {
256
+ return Boolean(spec.forkFrom) || spec.parentSystemPrompt !== undefined;
257
+ }
258
+
259
+ /**
260
+ * The system-prompt identity of a run: a fork (keyed by the parent prompt it
261
+ * inherits, which varies turn to turn — two forks from one message share it,
262
+ * forks from different turns do not), a named agent, or pi's base prompt.
263
+ */
264
+ private static promptKey(spec: Pick<ChildSessionSpec, "forkFrom" | "parentSystemPrompt" | "agent">): string {
265
+ if (SubagentRuntime.isFork(spec)) {
266
+ return `fork:${createHash("sha1").update(spec.parentSystemPrompt ?? "").digest("hex").slice(0, 16)}`;
267
+ }
268
+ return agentPromptIdentity(spec.agent?.name);
269
+ }
270
+
271
+ /**
272
+ * Hold a run until a sibling with the same request prefix has started
273
+ * streaming (see `warmGate`): same prompt identity, same cwd (the child's
274
+ * `# claudeMd` block names its paths) and same model. Resolves to the release
275
+ * for the caller's `finally`.
276
+ */
277
+ private admitPrefix(spec: Pick<ChildSessionSpec, "cwd" | "forkFrom" | "parentSystemPrompt" | "agent">, session: Session): Promise<Release> {
278
+ const model = session.model;
279
+ const key = prefixWarmKey(SubagentRuntime.promptKey(spec), spec.cwd, model ? modelSpec(model) : undefined);
280
+ return this.warmGate.admitOnFirstToken(key, session);
281
+ }
282
+
283
+ /** Subscribe a turn tracker to a session, feeding progress and (optionally) turn-settle. Returns the unsubscribe. */
284
+ private wireTracker(
285
+ session: Session,
286
+ tracker: SessionTurnTracker,
287
+ onProgress: (toolCalls: number, lastText: string, usage: UsageTotals) => void,
288
+ onSettled?: () => void,
289
+ sink?: LiveSink,
290
+ ): () => void {
291
+ return session.subscribe((event) => {
292
+ try {
293
+ const settled = tracker.process(event as never);
294
+ const e = event as { type?: string; toolName?: string; args?: unknown; isError?: boolean; result?: unknown; message?: { role?: string; content?: unknown } };
295
+ if (e.type === "tool_execution_start") {
296
+ sink?.onBlock?.({ kind: "call", tool: e.toolName ?? "tool", text: summarizeArgs(e.args) });
297
+ sink?.onActivity?.(e.toolName, e.args, tracker.turnText);
298
+ } else if (e.type === "tool_execution_end") {
299
+ // Display only: the child's persisted reminders (file-tracker, hook context) stay with the model.
300
+ const text = cutPlainText(
301
+ firstNonEmptyLine(textContent(stripReminderBlocks(e.result as { content?: Array<{ type: string; text?: string }> }))),
302
+ 200,
303
+ );
304
+ sink?.onBlock?.({ kind: "result", tool: e.toolName ?? "tool", text: text || (e.isError ? "error" : "done"), isError: e.isError });
305
+ } else if (e.type === "message_update" && e.message?.role === "assistant") {
306
+ sink?.onStreaming?.(e.message);
307
+ } else if (e.type === "message_end" && e.message?.role === "assistant") {
308
+ sink?.onStreaming?.(undefined);
309
+ sink?.onUsage?.((e.message as { usage?: unknown }).usage);
310
+ // The provider refused this model for this account ("not supported
311
+ // with a ChatGPT account", 404 on the model): the catalog offered it,
312
+ // the account cannot run it. Tell the parent so selection skips it
313
+ // from now on (lib/model-unusable.ts) — the same class of error the
314
+ // auto-mode classifier steps over (`isModelUnavailableError`).
315
+ const reply = e.message as { stopReason?: string; errorMessage?: string; provider?: string; model?: string };
316
+ if (reply.stopReason === "error" && reply.errorMessage && reply.provider && reply.model && isModelUnavailableError(reply.errorMessage)) {
317
+ this.onModelUnusable(modelSpec({ provider: reply.provider, id: reply.model }), reply.errorMessage);
318
+ }
319
+ const text = streamingText(e.message).trim();
320
+ if (text) {
321
+ sink?.onBlock?.({ kind: "text", text });
322
+ sink?.onActivity?.(undefined, undefined, text);
323
+ }
324
+ }
325
+ if (event.type === "tool_execution_start" || event.type === "message_end") {
326
+ onProgress(tracker.toolCalls, tracker.turnText, tracker.usage);
327
+ }
328
+ if (settled) onSettled?.();
329
+ } catch {
330
+ // A bad event shape must never throw into the agent's emit loop.
331
+ }
332
+ });
333
+ }
334
+
335
+ /**
336
+ * Build the child session, falling back to the session model when an
337
+ * *automatically chosen* model cannot spawn (not entitled, withdrawn). The
338
+ * automatic pick is a cost optimisation we made, not a model the user or the
339
+ * main model named, so a broken pick must degrade to the always-working
340
+ * session model rather than fail the whole subagent (CC parity). A per-call
341
+ * model carries no `fallbackModel`, so it surfaces its error to the main model
342
+ * to retry — never a silent swap of a model it chose. The returned `note` is
343
+ * surfaced to the user/main model so the fallback is never silent.
344
+ *
345
+ * The loader is built once per call, alongside the MCP-tools wait, and is
346
+ * reused across the fallback attempt (a failed `createAgentSession` leaves no
347
+ * session, so nothing has invalidated it — agent-loader.ts); the session
348
+ * manager is created fresh per attempt, since one reused after a failed
349
+ * create could be half-initialized. Resume reopens a finished session; fork
350
+ * inherits the parent transcript + system prompt; a fresh named run gets the
351
+ * agent's own prompt and toolset.
352
+ */
353
+ private async buildChildSession(spec: ChildSessionSpec): Promise<{ session: Session; note?: string }> {
354
+ const [loader, mcpTools] = await Promise.all([buildAgentLoader(this.childLoaderOptions(spec)), this.getMcpTools()]);
355
+ const newSessionManager = () =>
356
+ // The resume cwd is passed explicitly: a worktree run's persisted cwd may
357
+ // be gone by the time it is messaged (index.ts substitutes the parent cwd).
358
+ spec.sessionFile
359
+ ? SessionManager.open(spec.sessionFile, undefined, spec.cwd)
360
+ : spec.forkFrom
361
+ ? SessionManager.forkFrom(spec.forkFrom, spec.cwd, spec.sessionDir)
362
+ : spec.sessionDir
363
+ ? SessionManager.create(spec.cwd, spec.sessionDir)
364
+ : SessionManager.inMemory(spec.cwd);
365
+ // A fork keeps the parent's toolset; only a named agent carries an allowlist.
366
+ const allowlist = spec.forkFrom ? undefined : spec.agent?.tools;
367
+ const make = async (model: string | undefined): Promise<Session> => {
368
+ const resolvedModel = model ? await this.resolveModel(model) : undefined;
369
+ const session = await openChildSession({
370
+ loader,
371
+ session: {
372
+ cwd: spec.cwd,
373
+ agentDir: getAgentDir(),
374
+ modelRuntime: this.modelRuntime,
375
+ model: resolvedModel as never,
376
+ thinkingLevel: spec.thinking as never,
377
+ tools: childToolAllowlist(allowlist),
378
+ // Denylist grants (CC's "All tools except …" shape) — filters built-ins,
379
+ // extension tools, and injected customTools alike.
380
+ excludeTools: spec.forkFrom ? undefined : spec.agent?.excludeTools,
381
+ customTools: [sendToMainTool((m, s) => spec.onMessageToMain?.(m, s)), ...(spec.extraTools ?? []), ...mcpTools],
382
+ sessionManager: newSessionManager(),
383
+ },
384
+ startReason: spec.sessionFile ? "resume" : spec.forkFrom ? "fork" : "startup",
385
+ onError: (error) => this.onExtensionError(spec.name, error),
386
+ });
387
+ // An allowlist that matched nothing real would run a tool-less agent
388
+ // (pi drops unknown names silently). Fail loud with the fix named.
389
+ if (
390
+ allowlist &&
391
+ usableAllowlistedTools(
392
+ session.getAllTools().map((t) => t.name),
393
+ allowlist,
394
+ ).length === 0
395
+ ) {
396
+ this.discard(session);
397
+ throw new Error(
398
+ `Agent "${spec.agent?.name}" lists tools (${allowlist.join(", ")}) that match no available tool. ` +
399
+ "Use Claude Code names (Read, Edit, Write, Bash, Grep, Glob, WebFetch, WebSearch, NotebookEdit, Skill, Agent, SendMessage) or pi names (read, edit, write, bash, grep, find, ls, …) in the agent file's `tools` list.",
400
+ );
401
+ }
402
+ if (spec.name) this.runNames.set(session.sessionManager.getSessionId(), spec.name);
403
+ if (spec.agent) this.agentTypes.set(session.sessionManager.getSessionId(), spec.agent.name);
404
+ return session;
405
+ };
406
+ try {
407
+ return { session: await make(spec.model) };
408
+ } catch (error) {
409
+ const message = (error as Error).message;
410
+ if (spec.fallbackModel && spec.fallbackModel !== spec.model && isModelUnavailableError(message)) {
411
+ return {
412
+ session: await make(spec.fallbackModel),
413
+ note: `Subagent model ${spec.model} was unavailable (${message}); ran on the session model ${spec.fallbackModel} instead.`,
414
+ };
415
+ }
416
+ throw error;
417
+ }
418
+ }
419
+
420
+ /** Dispose a child session and forget its run-name mapping. */
421
+ private discard(session: Session): void {
422
+ this.runNames.delete(session.sessionManager.getSessionId());
423
+ this.agentTypes.delete(session.sessionManager.getSessionId());
424
+ session.dispose();
425
+ }
426
+
427
+ /**
428
+ * A blocking run (nested spawn, SendMessage resume, one-shot-mode spawn): one
429
+ * prompt, awaited, then disposed. The caller frames a fork's task
430
+ * (`forkTaskMessage`) — it knows the worktree the fork may be isolated in.
431
+ */
432
+ run(options: SubagentRunOptions): ChildHandle {
433
+ const tracker = new SessionTurnTracker();
434
+ let session: Session | undefined;
435
+
436
+ const result: Promise<ChildOutcome> = (async () => {
437
+ let unsubscribe: (() => void) | undefined;
438
+ let spawnNote: string | undefined;
439
+ // Every way this turn can be cut short marks the tracker first, so the
440
+ // outcome read after prompt() resolves reports "terminated", never the
441
+ // partial text as a clean completion (M3).
442
+ const abortWith = (reason?: string) => {
443
+ tracker.markAborted(reason);
444
+ void session?.abort();
445
+ };
446
+ const unhookAbort = whenAborted(options.signal, () => abortWith());
447
+ const wallClock = setTimeout(() => abortWith("terminated: turn hit the wall-clock cap"), WALL_CLOCK_CAP_MS);
448
+ wallClock.unref?.();
449
+
450
+ try {
451
+ // Construction happens INSIDE the try: a failure here (bad model,
452
+ // corrupt session file, loader error) must resolve to a failed
453
+ // outcome, never reject handle.result — call sites like the
454
+ // SendMessage resume path consume it with a bare .then().
455
+ const built = await this.buildChildSession(options);
456
+ session = built.session;
457
+ spawnNote = built.note;
458
+ unsubscribe = this.wireTracker(session, tracker, options.onProgress, undefined, options.sink);
459
+ // A resume continues a unique history; only a fresh run shares a prefix.
460
+ const releasePrefix = options.sessionFile ? undefined : await this.admitPrefix(options, session);
461
+ try {
462
+ // The abort listener calls session.abort(), a no-op while nothing runs
463
+ // yet: a cancellation that landed during the gate wait must stop the
464
+ // turn from starting at all, not let it run to completion unnoticed.
465
+ if (options.signal?.aborted) throw new Error("cancelled before the first request was sent");
466
+ // No template/command expansion: a task starting with "/" is text, not
467
+ // a command lookup (pi's own sendUserMessage does the same — L2).
468
+ await session.prompt(options.task, { expandPromptTemplates: false });
469
+ } finally {
470
+ releasePrefix?.(false);
471
+ }
472
+ return withNote(tracker.turnOutcome(), spawnNote);
473
+ } catch (error) {
474
+ // Keep the fallback note if the model was swapped before the turn failed.
475
+ return withNote(
476
+ { output: `Subagent failed: ${(error as Error).message}`, toolCalls: tracker.toolCalls, usage: tracker.usage, actions: tracker.actions, failed: true },
477
+ spawnNote,
478
+ );
479
+ } finally {
480
+ clearTimeout(wallClock);
481
+ unhookAbort();
482
+ unsubscribe?.();
483
+ if (session) this.discard(session);
484
+ }
485
+ })();
486
+
487
+ return {
488
+ result,
489
+ kill: () => {
490
+ tracker.markAborted();
491
+ void session?.abort();
492
+ },
493
+ snapshot: () => ({ toolCalls: tracker.toolCalls, text: tracker.turnText, usage: tracker.usage }),
494
+ };
495
+ }
496
+
497
+ /**
498
+ * A resident session that stays alive after its first turn so SendMessage can
499
+ * reach it live: steer into a running turn, or start a new turn when idle. The
500
+ * in-process reentrancy of AgentSession.prompt()/steer() removes the boot-lag
501
+ * buffering the spawned RPC child needed. The initial task is delivered by the
502
+ * caller via `send()` (fork runs wrap it with forkTaskMessage first). Each turn
503
+ * carries its own wall-clock cap so a hung turn can't tie up the loop forever,
504
+ * while an idle resident lives until task_stop / session shutdown.
505
+ */
506
+ async runResident(options: ResidentRunOptions): Promise<RpcChildHandle> {
507
+ const built = await this.buildChildSession(options);
508
+ const session = built.session;
509
+ let spawnNote = built.note;
510
+ const tracker = new SessionTurnTracker();
511
+ let exited = false;
512
+ let turnActive = false;
513
+ let firstTurn = true;
514
+ let turnTimer: ReturnType<typeof setTimeout> | undefined;
515
+ const clearTurnCap = () => {
516
+ if (turnTimer) clearTimeout(turnTimer);
517
+ turnTimer = undefined;
518
+ };
519
+ /** Deliver a turn's outcome exactly once, whether it settled normally or the turn failed to run. */
520
+ const finishTurn = (outcome: ChildOutcome) => {
521
+ if (!turnActive) return;
522
+ turnActive = false;
523
+ clearTurnCap();
524
+ // Surface a one-time model-fallback note on the first turn that reports.
525
+ options.onTurnEnd(withNote(outcome, spawnNote));
526
+ spawnNote = undefined;
527
+ };
528
+
529
+ // On settle: report the turn, then dispatch a message held over the settle
530
+ // window (pendingSend below). The dispatch runs on the settle EVENT, not
531
+ // inside finishTurn, because a capped turn reports early from its timer
532
+ // while the session is still aborting — the next turn must not start
533
+ // until the session is really idle.
534
+ this.wireTracker(
535
+ session,
536
+ tracker,
537
+ options.onProgress,
538
+ () => {
539
+ finishTurn(tracker.turnOutcome());
540
+ releasePending();
541
+ },
542
+ options.sink,
543
+ );
544
+ // `this` inside the handle's methods is the handle, so bind these here.
545
+ const admitPrefix = () => this.admitPrefix(options, session);
546
+ const discard = () => this.discard(session);
547
+
548
+ /**
549
+ * A message that arrived in the settle window: pi has already cleared
550
+ * `_isAgentRunActive` (so `session.isIdle` is true) but our subscribe
551
+ * listener has not yet run `finishTurn` for the turn that just ended. Starting
552
+ * a turn there would reset the tracker under the unreported outcome, and
553
+ * steering would queue against an idle agent (delivered only with the NEXT
554
+ * prompt while the tool result claimed "steered"). Instead the message is held
555
+ * and dispatched by finishTurn as the next turn (SUBAGENT-REVIEW L6).
556
+ */
557
+ let pendingSend: string | undefined;
558
+ const startTurn = (message: string): void => {
559
+ tracker.beginTurn();
560
+ turnActive = true;
561
+ // The cap measures the turn's own work, so it is armed when the prompt
562
+ // actually starts — after any wait behind the prefix gate.
563
+ const armTurnCap = () => {
564
+ clearTurnCap();
565
+ turnTimer = setTimeout(() => {
566
+ // Same rationale as kill(): the abort settles through the normal
567
+ // turn path, which would report the capped turn as a completion.
568
+ tracker.markAborted("terminated: turn hit the wall-clock cap");
569
+ finishTurn(tracker.turnOutcome());
570
+ void session.abort();
571
+ }, WALL_CLOCK_CAP_MS);
572
+ turnTimer.unref?.();
573
+ };
574
+ // No template/command expansion: a task starting with "/" is text (L2).
575
+ const prompt = () => session.prompt(message, { expandPromptTemplates: false });
576
+ // The first turn waits for a sibling with the same prefix to start
577
+ // streaming (admitPrefix); later turns continue a unique history.
578
+ let prompting: Promise<void>;
579
+ if (firstTurn) {
580
+ prompting = admitPrefix().then((release) => {
581
+ // kill() may have aborted and disposed the session while this
582
+ // turn was queued behind the gate; prompting it now would fail
583
+ // silently (finishTurn already ran). Hand the lead on instead.
584
+ if (exited) {
585
+ release(false);
586
+ return;
587
+ }
588
+ armTurnCap();
589
+ return prompt().finally(() => release(false));
590
+ });
591
+ } else {
592
+ armTurnCap();
593
+ prompting = prompt();
594
+ }
595
+ firstTurn = false;
596
+ // A prompt that can't even start (no API key, bad model) rejects; surface
597
+ // it as a failed turn instead of leaving the caller waiting forever.
598
+ void prompting.catch((error) => {
599
+ finishTurn({
600
+ output: `Subagent could not start the turn: ${(error as Error).message}`,
601
+ toolCalls: tracker.toolCalls,
602
+ usage: tracker.usage,
603
+ actions: tracker.actions,
604
+ failed: true,
605
+ });
606
+ });
607
+ };
608
+ const releasePending = () => {
609
+ if (pendingSend === undefined || exited || turnActive || !session.isIdle) return;
610
+ const message = pendingSend;
611
+ pendingSend = undefined;
612
+ startTurn(message);
613
+ };
614
+
615
+ return {
616
+ async send(message: string): Promise<"started" | "steered"> {
617
+ // Gate on our own synchronous turnActive flag, not session.isIdle alone:
618
+ // isIdle only flips deep inside prompt() after real awaits, so two rapid
619
+ // sends could both see an idle session and issue concurrent prompts.
620
+ if (!turnActive && session.isIdle) {
621
+ startTurn(message);
622
+ return "started";
623
+ }
624
+ if (turnActive && session.isIdle) {
625
+ // Settle window (see pendingSend). Only one message can ride it; a
626
+ // second one joins the held one as the same next turn.
627
+ pendingSend = pendingSend === undefined ? message : `${pendingSend}\n\n${message}`;
628
+ return "started";
629
+ }
630
+ // Mid-turn: joins the running turn. pi refuses to queue an extension
631
+ // command; the rejection reaches the caller instead of being swallowed.
632
+ await session.steer(message);
633
+ return "steered";
634
+ },
635
+ busy: () => turnActive || !session.isIdle,
636
+ exited: () => exited,
637
+ kill: () => {
638
+ if (exited) return Promise.resolve();
639
+ exited = true;
640
+ pendingSend = undefined;
641
+ // Report an in-flight turn as terminated, not as a normal completion:
642
+ // the settle path can't tell an abort's partial text from success, so
643
+ // without this a killed run notifies as if it finished cleanly.
644
+ tracker.markAborted();
645
+ finishTurn(tracker.turnOutcome());
646
+ // onExit only after the abort settles: it triggers worktree cleanup,
647
+ // which must not race an aborting session still writing files. The
648
+ // returned promise lets session_shutdown wait for exactly that.
649
+ return session
650
+ .abort()
651
+ .catch(() => undefined)
652
+ .finally(() => {
653
+ discard();
654
+ options.onExit?.();
655
+ });
656
+ },
657
+ release: () => {
658
+ if (exited || turnActive || !session.isIdle) return;
659
+ exited = true;
660
+ discard();
661
+ options.onExit?.();
662
+ },
663
+ snapshot: () => {
664
+ const text =
665
+ !session.isIdle && tracker.turnText
666
+ ? tracker.transcript
667
+ ? `${tracker.transcript}\n\n---\n\n${tracker.turnText}`
668
+ : tracker.turnText
669
+ : tracker.transcript || tracker.turnText;
670
+ return { toolCalls: tracker.toolCalls, text, usage: tracker.usage };
671
+ },
672
+ };
673
+ }
674
+ }