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
@@ -16,15 +16,30 @@
16
16
  * child is spawned with a concrete `provider/id`:
17
17
  *
18
18
  * - Claude Code aliases (`sonnet`/`opus`/`haiku`/`fable`) resolve **within the
19
- * session's provider** (and contained route/model family on gateways); where the name
20
- * matches nothing, the session model serves and the parent says so.
21
- * - An exact model reference resolves anywhere — the user (or an agent file the
22
- * user installed) naming a model is choosing it — but crossing providers is
23
- * announced, never silent.
24
- * - A configured default that cannot resolve degrades to the session model with
25
- * a notice naming the knob; without one, a reviewed same-provider role profile
26
- * chooses an economical default before the session fallback. Only a bad
27
- * *per-call* request errors, because the model can read the menu and retry.
19
+ * session's provider** (and contained route/model family on gateways): by
20
+ * name where a model carries it, else as the Claude Code *tier* the alias
21
+ * names — `haiku` the cheapest capable contained model, `sonnet` the cheapest
22
+ * workhorse-class one, `opus`/`fable` the session model — and the parent says
23
+ * so (2026-09-11; before, an off-family alias landed on the session model,
24
+ * the most expensive answer available, and a global "use Sonnet for
25
+ * subagents" instruction did exactly that on every non-Anthropic provider).
26
+ * - An exact per-call reference (the main model's `model` field) or the user's
27
+ * own `subagentModel` setting resolves anywhere — naming a model is choosing
28
+ * it — but crossing providers is announced, never silent.
29
+ * - An `.claude/agents` model field is a Claude Code convention: honored on a
30
+ * Claude-family session, but on any other provider a cross-provider one is
31
+ * *not* honored (a fork inherits the parent transcript, so subagents stay on
32
+ * the session's provider) — the configured default or the automatic
33
+ * same-provider profile serves instead.
34
+ * - A configured default or agent-file model that cannot resolve degrades, with
35
+ * a notice naming the knob, to the automatic same-provider pick — the
36
+ * cheapest contained model at the session's capability floor, the SAME floor
37
+ * the auto-mode classifier applies (`capableContainedCandidates`) — and only
38
+ * then to the session model. Only an explicit `inherit` names the session
39
+ * model outright. A bad *per-call* request first falls through to the agent's
40
+ * own model and the configured default (running the subagent on its intended
41
+ * model, with a notice); only when nothing downstream resolves does it error,
42
+ * so the model can read the menu and retry.
28
43
  *
29
44
  * The menu keeps the main model informed without dumping a 300-model gateway
30
45
  * catalog into every request: vendor-contained, variant- and unpriced-filtered,
@@ -33,16 +48,19 @@
33
48
  */
34
49
 
35
50
  import type { Api, Model } from "@earendil-works/pi-ai";
51
+ import type { SubagentDefault } from "./default-model.ts";
36
52
  import {
37
- allowsDynamicSubagentSelection,
53
+ crossesProvider,
38
54
  findConfigured,
39
- findRoleProfileModel,
40
- hasSmallModelName,
41
- hintRank,
42
- modelIdentity,
55
+ isClaudeFamilyModel,
56
+ isDatedDuplicate,
57
+ isSnapshotDatedId,
58
+ isStaleContainmentStamp,
43
59
  modelsContainedToSession,
60
+ modelSpec as spec,
44
61
  pricedInput,
45
62
  } from "../lib/model-policy.ts";
63
+ import { atLeastTier, capableContainedCandidates, economicalContainedCandidates, intrinsicTier, type PromptTier } from "../lib/model-tier.ts";
46
64
 
47
65
  /**
48
66
  * Cross-extension channel carrying the resolved default subagent model, so the
@@ -82,8 +100,13 @@ export function subagentStatusModel(
82
100
  return { model: spec(resolution.model), via };
83
101
  }
84
102
 
85
- /** Claude Code's Agent-tool aliases, resolved as name matches within the session's provider. */
86
- const CLAUDE_CODE_ALIASES = new Set(["sonnet", "opus", "haiku", "fable"]);
103
+ /**
104
+ * Claude Code's Agent-tool aliases, read as the tiers they name: `haiku` the
105
+ * cheap line, `sonnet` the workhorse line, `opus`/`fable` the frontier. An alias
106
+ * resolves by NAME within the session's provider family when a model carries it
107
+ * (an Anthropic session), else as its tier there — see `resolveAlias`.
108
+ */
109
+ const ALIAS_TIER: Record<string, PromptTier> = { haiku: "cheap", sonnet: "workhorse", opus: "frontier", fable: "frontier" };
87
110
 
88
111
  export type SubagentModelSource = "call" | "agent" | "default" | "automatic" | "session";
89
112
 
@@ -92,8 +115,14 @@ export interface ResolveInput {
92
115
  requested?: string;
93
116
  /** The agent definition's frontmatter `model`. */
94
117
  agentModel?: string;
95
- /** CLAUDE_CODE_SUBAGENT_MODEL / `subagentModel` setting, user or managed scope. */
96
- configuredDefault?: string;
118
+ /**
119
+ * The `subagentModel` setting / `CLAUDE_CODE_SUBAGENT_MODEL`, with its knob
120
+ * (`source`) and, for the setting, the session provider it was stamped for
121
+ * (`setForProvider`). A cross-provider setting stamped for a provider since
122
+ * left — or unstamped (hand-edited) — is stale and skipped so a same-provider
123
+ * model runs; see the resolve loop.
124
+ */
125
+ configuredDefault?: SubagentDefault;
97
126
  sessionModel?: Model<Api>;
98
127
  available: Model<Api>[];
99
128
  }
@@ -111,28 +140,81 @@ export interface SubagentModelResolution {
111
140
  notices: string[];
112
141
  }
113
142
 
114
- const spec = (model: Model<Api>): string => `${model.provider}/${model.id}`;
143
+ /**
144
+ * The model-facing form of a resolution's notices, for the spawn tool's result.
145
+ *
146
+ * Every notice above says what was NOT honored ("falling back to the agent's
147
+ * configured model or the session default"); none of them names where the walk
148
+ * actually landed, because it is written before the chain finishes. The user
149
+ * can see the answer in the banner, but the model that asked for `sonnet` could
150
+ * not: on a non-Anthropic session — where a global "use Sonnet for subagents"
151
+ * memory shared with Claude Code makes that the standing request — it spawned
152
+ * children believing they ran on Sonnet and reasoned about their cost and
153
+ * capability on that basis (WEAK-MODEL-REVIEW-2026-09-06 L4).
154
+ *
155
+ * So the concrete spec is appended whenever there is a notice at all. It is
156
+ * stated rather than inferred even when a notice already mentions that spec:
157
+ * the notices name models in every position — the session ("no sonnet model in
158
+ * this session's provider family (openai/gpt-5.1)"), the one refused, the one
159
+ * honored — and a reader cannot tell which mention is the answer.
160
+ *
161
+ * The source rides along because it is the question the answer raises. The
162
+ * notices used to end by listing which knob might serve next; naming the one
163
+ * that actually did is the same information after the fact, and true.
164
+ */
165
+ const SOURCE_LABEL: Record<SubagentModelSource, string> = {
166
+ call: "the model this call named",
167
+ agent: "the agent's own model",
168
+ default: "the configured default",
169
+ automatic: "the automatic smaller default",
170
+ session: "this session's model",
171
+ };
172
+
173
+ export function subagentModelNotes(resolution: Pick<SubagentModelResolution, "model" | "notices" | "source">): string[] {
174
+ if (resolution.notices.length === 0) return [];
175
+ if (!resolution.model) return [...resolution.notices];
176
+ return [...resolution.notices, `This subagent runs on ${spec(resolution.model)} (${SOURCE_LABEL[resolution.source]}).`];
177
+ }
115
178
 
116
- const isDated = (id: string): boolean => /-20\d{6}$/.test(id);
179
+ interface AliasResolution {
180
+ model: Model<Api>;
181
+ /** "name": a contained model carries the alias; "tier": the alias's Claude Code tier, resolved here. */
182
+ how: "name" | "tier";
183
+ }
117
184
 
118
185
  /**
119
- * Resolve a Claude Code alias by name within the contained set, preferring
120
- * undated alias ids and then the newest id. Returns undefined off-family —
121
- * "sonnet" on a Groq session names nothing, and inventing a mapping would be
122
- * choosing a model the user never described.
186
+ * Resolve a Claude Code alias within the session's provider family: by name
187
+ * first (preferring undated alias ids, then the newest id), else as the tier
188
+ * the alias names — the cheapest contained model at or above it
189
+ * (`economicalContainedCandidates` order: never tiny, prior-generation or
190
+ * tool-less; cheap → workhorse → frontier, then price), `frontier` meaning the
191
+ * session model itself, the strongest model the user chose on this provider.
192
+ * Never off-family: "sonnet" on a Codex session is the cheapest workhorse-class
193
+ * Codex model, not Anthropic's Sonnet. The aliases ARE Claude Code's tier
194
+ * names, so the tier reading honors what the alias meant rather than
195
+ * substituting a model nobody described. Undefined only when the provider has
196
+ * nothing capable to offer (unpriced/opaque) or there is no session model.
123
197
  */
124
- function resolveAlias(alias: string, contained: Model<Api>[]): Model<Api> | undefined {
198
+ function resolveAlias(
199
+ alias: string,
200
+ contained: Model<Api>[],
201
+ available: Model<Api>[],
202
+ sessionModel: Model<Api> | undefined,
203
+ ): AliasResolution | undefined {
125
204
  const matches = contained.filter((model) => model.id.toLowerCase().includes(alias));
126
- if (matches.length === 0) return undefined;
127
- const undated = matches.filter((model) => !isDated(model.id));
128
- const pool = undated.length > 0 ? undated : matches;
129
- return pool.sort((a, b) => b.id.localeCompare(a.id))[0];
130
- }
131
-
132
- /** Whether an explicit model leaves the session's provider/route/family boundary. */
133
- export function crossesProvider(model: Model<Api>, sessionModel: Model<Api>): boolean {
134
- if (model.provider !== sessionModel.provider) return true;
135
- return modelIdentity(model).containment !== modelIdentity(sessionModel).containment;
205
+ if (matches.length > 0) {
206
+ const undated = matches.filter((model) => !isSnapshotDatedId(model.id));
207
+ const pool = undated.length > 0 ? undated : matches;
208
+ return { model: pool.sort((a, b) => b.id.localeCompare(a.id))[0], how: "name" };
209
+ }
210
+ if (!sessionModel) return undefined;
211
+ const tier = ALIAS_TIER[alias];
212
+ if (tier === "frontier") return { model: sessionModel, how: "tier" };
213
+ // Name-class only, deliberately: an explicit alias asks for a *class* of model,
214
+ // so a measured pass (which lets a strong flash serve the automatic default)
215
+ // does not lift a lean-named model into "sonnet" here.
216
+ const byTier = economicalContainedCandidates(available, sessionModel, contained).find((model) => atLeastTier(intrinsicTier(model), tier));
217
+ return byTier ? { model: byTier, how: "tier" } : undefined;
136
218
  }
137
219
 
138
220
  export function resolveSubagentModel(input: ResolveInput): SubagentModelResolution {
@@ -143,11 +225,23 @@ export function resolveSubagentModel(input: ResolveInput): SubagentModelResoluti
143
225
  if (input.requested) chain.push({ value: input.requested, source: "call", knob: "the model field" });
144
226
  if (input.agentModel) chain.push({ value: input.agentModel, source: "agent", knob: "the agent file's model" });
145
227
  if (input.configuredDefault) {
146
- chain.push({ value: input.configuredDefault, source: "default", knob: "the configured subagent default" });
228
+ chain.push({ value: input.configuredDefault.spec, source: "default", knob: "the configured subagent default" });
147
229
  }
148
230
 
149
231
  const contained = sessionModel ? modelsContainedToSession(available, sessionModel) : [];
150
232
  let suppressAutomatic = false;
233
+ // A per-call `model` that did not resolve — deferred, not errored (see the
234
+ // "per-call request" paragraph in the module doc above).
235
+ let callFailed: string | undefined;
236
+
237
+ // The `subagentModel` setting is stale when it was stamped for a different
238
+ // provider than this session (or never stamped — a hand-edited setting): the
239
+ // user switched the session's provider since setting it, so a cross-provider
240
+ // value is not honored and a same-provider model runs instead. A deliberate
241
+ // choice made on this provider (stamp matches) is still honored + announced.
242
+ const settingIsStale =
243
+ input.configuredDefault?.source === "subagentModel setting" &&
244
+ isStaleContainmentStamp(input.configuredDefault.setForContainment, sessionModel);
151
245
 
152
246
  for (const entry of chain) {
153
247
  const wanted = entry.value.trim();
@@ -157,19 +251,57 @@ export function resolveSubagentModel(input: ResolveInput): SubagentModelResoluti
157
251
  }
158
252
 
159
253
  const alias = wanted.toLowerCase();
160
- if (CLAUDE_CODE_ALIASES.has(alias)) {
161
- const resolved = resolveAlias(alias, contained);
162
- if (resolved) return { model: resolved, source: entry.source, notices };
163
- notices.push(
164
- `No "${alias}" model exists within ${sessionModel ? spec(sessionModel) : "this session"} — the session model runs this subagent instead.`,
165
- );
166
- suppressAutomatic = true;
167
- break;
254
+ if (alias in ALIAS_TIER) {
255
+ const resolved = resolveAlias(alias, contained, available, sessionModel);
256
+ if (resolved?.how === "name") return { model: resolved.model, source: entry.source, notices };
257
+ // SCOPE the claim. "No sonnet model exists" is false at machine level
258
+ // whenever Anthropic is also authenticated, and a model relaying it tells
259
+ // the user "Sonnet is not available" — wrong, and the everyday case given
260
+ // a global "use Sonnet for subagents" memory on another provider. What is
261
+ // true is narrower: the alias cannot leave this session's containment.
262
+ // "Provider family" covers both a plain provider and a gateway, where
263
+ // containment is the model-creator namespace (`modelsContainedToSession`).
264
+ const family = sessionModel ? ` (${spec(sessionModel)})` : "";
265
+ if (resolved) {
266
+ // The tier reading: what the alias meant, on this provider. Stated so
267
+ // the model that asked for "sonnet" knows which model actually runs
268
+ // (WEAK-MODEL-REVIEW-2026-09-06 L4) — `subagentModelNotes` repeats the
269
+ // spec, but this line says WHY it is not Sonnet.
270
+ notices.push(`No "${alias}" model in this session's provider family${family}; read as its Claude Code tier, "${alias}" resolves to ${spec(resolved.model)} here.`);
271
+ return { model: resolved.model, source: entry.source, notices };
272
+ }
273
+ // Nothing capable contained at all (an unpriced/opaque provider, or no
274
+ // session model): keep walking the chain — the agent's model / configured
275
+ // default may still resolve — then the automatic pick or the session
276
+ // model serve below. Never `unresolved`: unlike a literal typo, an
277
+ // off-family alias is a naming mismatch, not a retryable mistake.
278
+ notices.push(`No "${alias}" model in this session's provider family${family}.`);
279
+ continue;
168
280
  }
169
281
 
170
282
  const resolved = findConfigured(available, wanted);
171
283
  if (resolved) {
172
284
  if (sessionModel && crossesProvider(resolved, sessionModel)) {
285
+ // An `.claude/agents` model is a Claude Code convention; on a
286
+ // non-Claude session it does not get to move a subagent (which
287
+ // inherits the parent transcript) onto another provider. Skip it and
288
+ // let the configured default or the automatic same-provider profile
289
+ // serve. Per-call `model` and the user's own `subagentModel` setting
290
+ // are explicit choices, so their crossings are still honored + announced.
291
+ if (entry.source === "agent" && !isClaudeFamilyModel(sessionModel)) {
292
+ notices.push(
293
+ `The agent file's model ${spec(resolved)} is a different provider than this session (${spec(sessionModel)}); ` +
294
+ "on a non-Claude session subagents stay on this provider, so a same-provider model runs this subagent instead.",
295
+ );
296
+ continue;
297
+ }
298
+ if (entry.source === "default" && settingIsStale) {
299
+ notices.push(
300
+ `The configured subagent default ${spec(resolved)} was set for a different provider than this session (${spec(sessionModel)}); ` +
301
+ "a same-provider model runs this subagent instead. Re-set it with /subagent on this session to use it here.",
302
+ );
303
+ continue;
304
+ }
173
305
  notices.push(
174
306
  `Subagent model ${spec(resolved)} is a different provider/family than this session (${spec(sessionModel)}) — it was named via ${entry.knob}, so it is honored.`,
175
307
  );
@@ -178,45 +310,53 @@ export function resolveSubagentModel(input: ResolveInput): SubagentModelResoluti
178
310
  }
179
311
 
180
312
  if (entry.source === "call") {
181
- // The main model chose this string; give it the menu and let it retry.
182
- return { source: "call", unresolved: wanted, notices };
313
+ // Defer to the rest of the chain (see the module doc): record the
314
+ // failure and keep walking, erroring after the loop only if nothing
315
+ // downstream resolves.
316
+ callFailed = wanted;
317
+ notices.push(
318
+ `Requested subagent model "${wanted}" is not available — falling back to the agent's configured model or the session default.`,
319
+ );
320
+ continue;
183
321
  }
184
- // An explicit choice failed; automatic selection would substitute a model
185
- // nobody described, so the remaining chain and the session model serve.
186
- suppressAutomatic = true;
322
+ // An explicit choice failed: the rest of the chain, then the automatic
323
+ // same-provider pick, then the session model serve. The automatic pick is
324
+ // no more "a model nobody described" than the session model is, and it is
325
+ // the cheaper of the two (2026-09-11; before, this dropped straight to the
326
+ // session model).
187
327
  notices.push(
188
328
  entry.source === "agent"
189
- ? `Subagent model "${wanted}" (from ${entry.knob}) is not available — falling back to the configured default or the session model.`
190
- : `Subagent model "${wanted}" (from ${entry.knob}) is not available — the session model runs this subagent instead.`,
329
+ ? `Subagent model "${wanted}" (from ${entry.knob}) is not available — falling back to the configured default, the automatic same-provider pick, or the session model.`
330
+ : `Subagent model "${wanted}" (from ${entry.knob}) is not available — the automatic same-provider pick or the session model runs this subagent instead.`,
191
331
  );
192
332
  }
193
333
 
334
+ // Per-call model failed and nothing downstream resolved: surface it so the
335
+ // caller shows the menu and the model retries, rather than silently dropping
336
+ // to the automatic/session pick for a model the main model named.
337
+ if (callFailed) return { source: "call", unresolved: callFailed, notices };
338
+
194
339
  // Automatic selection is a cost optimisation, so it needs price evidence:
195
- // with either price unknown there is no demonstrable saving, and the profile
196
- // order alone could silently *upgrade* a cheap session (haiku → sonnet).
197
- const sessionPrice = sessionModel ? pricedInput(sessionModel) : undefined;
198
- if (sessionModel && sessionPrice !== undefined && !suppressAutomatic) {
199
- const isDifferentAndCheaper = (model: Model<Api>) => {
200
- if (spec(model) === spec(sessionModel)) return false;
201
- const candidatePrice = pricedInput(model);
202
- return candidatePrice !== undefined && candidatePrice < sessionPrice;
203
- };
204
- const profiled = findRoleProfileModel(available, sessionModel, "subagent", isDifferentAndCheaper);
205
- if (profiled) return { model: profiled, source: "automatic", notices };
206
-
207
- // Price-ranked fallback for a stale profile. A name hint is required and
208
- // ranks first: raw cheapest would pick nano/lite-tier (or wholly unknown)
209
- // models as the default *coding* worker, so mini-class wins over a cheaper
210
- // nano, and a model nobody labelled small never wins at all.
211
- if (allowsDynamicSubagentSelection(sessionModel)) {
212
- const cheaper = contained
213
- .map((model) => ({ model, price: pricedInput(model), rank: hintRank(model) }))
214
- .filter((entry): entry is { model: Model<Api>; price: number; rank: number } => entry.price !== undefined)
215
- .filter((entry) => spec(entry.model) !== spec(sessionModel))
216
- .filter((entry) => entry.price < sessionPrice && hasSmallModelName(entry.model))
217
- .sort((a, b) => a.rank - b.rank || a.price - b.price)[0]?.model;
218
- if (cheaper) return { model: cheaper, source: "automatic", notices };
219
- }
340
+ // with the session price unknown there is no demonstrable saving, and picking
341
+ // a cheap-tier model could silently *upgrade* an (unpriced) cheap session. The
342
+ // floor-gated selector — the cheapest same-provider model at the session's
343
+ // capability floor (workhorse-or-better for a workhorse-or-better session,
344
+ // Claude Code's min(main, sonnet); never `tiny`) — is the SAME one the
345
+ // auto-mode classifier uses, so a session screens and delegates on one
346
+ // model. A delegated worker writes code and calls tools for many turns; a
347
+ // weaker one spends the saving on retries (2026-09-11 — before, subagents
348
+ // took the cheapest capable model with no floor). It excludes unpriced/opaque
349
+ // providers by construction, which subsumes the old dynamic-selection gate;
350
+ // those degrade to the session model below.
351
+ if (sessionModel && !suppressAutomatic) {
352
+ // `strict` requires a genuinely cheaper model and yields nothing when the
353
+ // session price is unknown, so a cheap-tier pick never silently upgrades an
354
+ // unpriced session. Reuse the `contained` set computed above for the hot path.
355
+ // With an Artificial Analysis snapshot (lib/capability-index.ts) the floor
356
+ // is measured — coding index ≥ min(session, Sonnet 5), no tolerance —
357
+ // and passers rank by price; unscored candidates are judged by name-class tier.
358
+ const cheaper = capableContainedCandidates(available, sessionModel, "subagent", { strict: true, contained })[0];
359
+ if (cheaper) return { model: cheaper, source: "automatic", notices };
220
360
  }
221
361
 
222
362
  if (sessionModel) return { model: sessionModel, source: "session", notices };
@@ -259,6 +399,13 @@ export interface MenuOptions {
259
399
  defaultSource?: SubagentModelSource;
260
400
  /** How many cheaper-option lines to include. */
261
401
  maxCheaper?: number;
402
+ /**
403
+ * The configured default and which knob set it, so the reminder can tell the
404
+ * main model when the user has *manually* pinned the subagent model via the
405
+ * `subagentModel` setting (as opposed to Claude Code's env var or automatic
406
+ * selection). Only the setting triggers the notice.
407
+ */
408
+ configured?: SubagentDefault;
262
409
  }
263
410
 
264
411
  const price = (model: Model<Api>): string => {
@@ -297,11 +444,7 @@ export function subagentModelMenu({ available, sessionModel, defaultModel, defau
297
444
  .map((model) => ({ model, input: pricedInput(model) }))
298
445
  .filter((entry): entry is { model: Model<Api>; input: number } => entry.input !== undefined)
299
446
  // Collapse dated duplicates when the undated alias is also present.
300
- .filter(
301
- (entry) =>
302
- !isDated(entry.model.id) ||
303
- !contained.some((other) => other.id !== entry.model.id && entry.model.id.startsWith(other.id)),
304
- )
447
+ .filter((entry) => !isDatedDuplicate(entry.model, contained))
305
448
  .sort((a, b) => a.input - b.input);
306
449
  const sessionPrice = pricedInput(sessionModel);
307
450
  for (const entry of priced) {
@@ -332,15 +475,42 @@ export function defaultCostsMoreWarning({ sessionModel, defaultModel }: MenuOpti
332
475
  );
333
476
  }
334
477
 
478
+ /**
479
+ * One line informing the main model when the user has manually pinned the
480
+ * subagent default via the `subagentModel` setting — so the model knows what
481
+ * subagents run on, and (when that model is a different provider than the
482
+ * session) that it can keep a subagent's inherited transcript on this provider
483
+ * by passing a listed same-provider model. Silent for the automatic default and
484
+ * for Claude Code's env var, which are not the user's manual per-harness choice.
485
+ */
486
+ export function settingOverrideNotice({ sessionModel, defaultModel, defaultSource, configured }: MenuOptions): string | undefined {
487
+ // Only when the setting is actually in effect: a stale cross-provider setting
488
+ // that was overridden resolves to an automatic/session model (defaultSource
489
+ // != "default"), and its user-facing "set for a different provider" warning
490
+ // already explains the override — no "runs on it" line for the model then.
491
+ if (configured?.source !== "subagentModel setting" || defaultSource !== "default" || !defaultModel) return undefined;
492
+ const cross = sessionModel && crossesProvider(defaultModel, sessionModel);
493
+ return (
494
+ `The user pinned the subagent default to ${spec(defaultModel)} via the subagentModel setting; ` +
495
+ "subagents run on it unless you pass a different model in the `model` field" +
496
+ (cross
497
+ ? ` — it is a different provider than this session (${spec(sessionModel as Model<Api>)}), so a subagent's inherited transcript goes to that provider; pass a listed same-provider model to keep it here.`
498
+ : ".")
499
+ );
500
+ }
501
+
335
502
  /** The every-turn reminder body. Kept short: its tokens are paid on every call. */
336
503
  export function subagentModelsReminder(options: MenuOptions): string {
337
504
  const menu = subagentModelMenu(options);
505
+ const setting = settingOverrideNotice(options);
338
506
  const warning = defaultCostsMoreWarning(options);
339
507
  return [
340
508
  "Models for the subagent/workflow `model` field (omit it to use the default; set the default with /subagent):",
341
509
  ...menu,
510
+ ...(setting ? [setting] : []),
342
511
  ...(warning ? [warning] : []),
343
- 'Aliases sonnet|opus|haiku|fable resolve within this session\'s provider; "inherit" means the session model. ' +
512
+ "Aliases sonnet|opus|haiku|fable resolve within this session's provider — by name when a model carries it, else as the tier it names " +
513
+ '(haiku: the cheapest capable model, sonnet: the cheapest workhorse-class model, opus/fable: this session\'s model); "inherit" means the session model. ' +
344
514
  "Any exact provider/model-id the user asked for also works, even from another provider (that is announced to the user) or unlisted here — this is a menu, not a whitelist.",
345
515
  ].join("\n");
346
516
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Shared result/handle contracts for a subagent run, plus the fork framing.
3
+ * (These outlived child.ts, the retired process-spawning runner; the runner is
4
+ * now in-process — see runner.ts.)
5
+ */
6
+
7
+ import type { ChildAction } from "../auto-mode/actions.ts";
8
+ import type { UsageTotals } from "./usage.ts";
9
+
10
+ export const OUTPUT_CAP = 50_000;
11
+
12
+ /** A fork running in an isolation worktree: where it is, and where the inherited paths pointed. */
13
+ export interface ForkIsolation {
14
+ worktreePath: string;
15
+ parentCwd: string;
16
+ }
17
+
18
+ /**
19
+ * Framing wrapped around a fork child's task. A fork inherits the parent's
20
+ * entire transcript; without this, a weaker model tends to abandon its assigned
21
+ * task and continue (or confabulate about) the inherited topic — then its output
22
+ * returns to the parent looking like independent confirmation. See
23
+ * docs/features/tools/records/tool-ambiguity-hardening.md (fork confabulation).
24
+ *
25
+ * With `isolation`, the fork is told that its working directory moved: the
26
+ * inherited system prompt and transcript name the parent's checkout in every
27
+ * path, so without this line a worktree fork edits the main tree (the guard in
28
+ * lib/worktree-isolation.ts then refuses the write, but the model needs to know
29
+ * where to write instead — SUBAGENT-REVIEW H3).
30
+ */
31
+ export function forkTaskMessage(task: string, isolation?: ForkIsolation): string {
32
+ const lines = [
33
+ "You are a forked subagent. The conversation above is inherited context, for reference only — do NOT continue its open threads, verify its claims, or act on its plans. Do ONLY the task below; your final message is returned to the parent conversation verbatim, as data. You cannot see the parent's background tasks: its task ids are not addressable from here.",
34
+ ];
35
+ if (isolation) {
36
+ lines.push(
37
+ "",
38
+ `You are isolated in your own git worktree: your working directory is now ${isolation.worktreePath}, a checkout of the same repository at HEAD. Every path under ${isolation.parentCwd} in the inherited context refers to the shared checkout, which you must NOT modify — read, edit and run things at the corresponding path under ${isolation.worktreePath} instead (writes into the shared checkout are refused). Your changes stay on this worktree's branch for the parent to review.`,
39
+ );
40
+ }
41
+ lines.push("", "Task:", task);
42
+ return lines.join("\n");
43
+ }
44
+
45
+ export interface ChildOutcome {
46
+ output: string;
47
+ toolCalls: number;
48
+ usage: UsageTotals;
49
+ failed?: boolean;
50
+ /**
51
+ * What the child actually did, names and short subjects only. Auto mode
52
+ * reviews this when the child returns, to catch a sequence whose individual
53
+ * steps each passed. Never carries tool output — see auto-mode/actions.ts.
54
+ */
55
+ actions: ChildAction[];
56
+ }
57
+
58
+ /** A blocking run handle: await the result, or kill/snapshot it while it runs. */
59
+ export interface ChildHandle {
60
+ result: Promise<ChildOutcome>;
61
+ kill(): void;
62
+ snapshot(): { toolCalls: number; text: string; usage: UsageTotals };
63
+ }
64
+
65
+ /** A resident (background) agent handle: message it live, inspect it, stop it. */
66
+ export interface RpcChildHandle {
67
+ /**
68
+ * Deliver a message. "started" = the agent was idle and this began a new turn
69
+ * (resolves as soon as the turn is dispatched, not when it ends); "steered" =
70
+ * the agent was mid-turn and the message joined it. Rejects when the message
71
+ * could not be queued at all (pi refuses to steer an extension command), so
72
+ * the caller never reports a delivery that did not happen.
73
+ */
74
+ send(message: string): Promise<"started" | "steered">;
75
+ busy(): boolean;
76
+ exited(): boolean;
77
+ /**
78
+ * Abort any in-flight turn (reported as terminated) and dispose the session.
79
+ * Resolves once the abort has settled and the session is disposed (so a
80
+ * shutdown can wait for the exit-time cleanup, e.g. worktree removal).
81
+ */
82
+ kill(): Promise<void>;
83
+ /**
84
+ * Dispose an IDLE session quietly — no turn outcome, no notification. A
85
+ * no-op mid-turn. Its persisted session file remains, so SendMessage still
86
+ * reaches the agent by resuming from disk instead of live.
87
+ */
88
+ release(): void;
89
+ snapshot(): { toolCalls: number; text: string; usage: UsageTotals };
90
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Pure key decoding for the below-editor panel — Claude Code's agent-tree
3
+ * keys. Their meaning depends on whether a transcript view is open (index.ts
4
+ * owns that split): with no view open, arrows select and Enter opens the
5
+ * selected agent's transcript; while a view IS open the panel is in "read"
6
+ * mode, where arrows scroll it a line at a time, PageUp/PageDown scroll a page,
7
+ * `switch` (Tab) retargets to the next agent, `left` (←) closes the transcript
8
+ * back to agent selection, and Enter closes it. `x` stops, the `ctrl+x ctrl+k`
9
+ * chord stops all, esc leaves. `left` also serves the shell-panel stages (back)
10
+ * and `space` (close); the agents branch uses `left` in read mode (above) and
11
+ * treats `space` like typing. Raw terminal bytes in, intents out — no side
12
+ * effects, fully unit-testable.
13
+ */
14
+
15
+ const UP = new Set(["\x1b[A", "\x1bOA"]);
16
+ const DOWN = new Set(["\x1b[B", "\x1bOB"]);
17
+ const LEFT = new Set(["\x1b[D", "\x1bOD"]);
18
+
19
+ export type StripKey = "up" | "down" | "left" | "space" | "switch" | "open" | "leave" | "stop" | "stopAll" | "pageUp" | "pageDown";
20
+
21
+ /**
22
+ * Decoder with chord state: `ctrl+x` (\x18) arms the chord (consumed, no key);
23
+ * a following `ctrl+k` (\x0b) completes it into `stopAll`; any other key
24
+ * cancels the chord and is decoded normally. A byte that decodes to nothing
25
+ * (typing) is the caller's signal to drop focus and let the byte through.
26
+ */
27
+ export function decodeStripKey(data: string, chordArmed: boolean): { key?: StripKey; chordArmed: boolean } {
28
+ if (chordArmed && data === "\x0b") return { key: "stopAll", chordArmed: false };
29
+ if (data === "\x18") return { chordArmed: true };
30
+ if (UP.has(data)) return { key: "up", chordArmed: false };
31
+ if (DOWN.has(data)) return { key: "down", chordArmed: false };
32
+ if (LEFT.has(data)) return { key: "left", chordArmed: false };
33
+ if (data === "\t") return { key: "switch", chordArmed: false };
34
+ if (data === " ") return { key: "space", chordArmed: false };
35
+ if (data === "\r" || data === "\n") return { key: "open", chordArmed: false };
36
+ if (data === "\x1b") return { key: "leave", chordArmed: false };
37
+ if (data === "x" || data === "X") return { key: "stop", chordArmed: false };
38
+ if (data === "\x1b[5~") return { key: "pageUp", chordArmed: false };
39
+ if (data === "\x1b[6~") return { key: "pageDown", chordArmed: false };
40
+ return { chordArmed: false };
41
+ }