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
@@ -1,99 +1,515 @@
1
1
  /**
2
- * Which system-prompt register the active model gets. Claude Code ships a
3
- * capability-tiered prompt — a terse one for frontier models, a much longer,
4
- * more explicit one for smaller models (Anthropic removed 80%+ of the prompt for
2
+ * Which system-prompt register + tool surface the active model gets. Claude Code
3
+ * ships a capability-tiered prompt — a terse one for Opus, a much longer, more
4
+ * explicit one for Sonnet/Haiku (Anthropic removed 80%+ of the prompt for
5
5
  * Claude-5-gen models with no measured loss). One Code serves every provider, so
6
- * its non-frontier audience is dominated by models weaker than Haiku; a single
7
- * lean prompt under-instructs them. This module maps a model to one of three
8
- * registers; `extensions/system-prompt` picks the prompt text from the result.
6
+ * its non-frontier audience extends *below* Haiku; a single lean prompt
7
+ * under-instructs it. This module maps a model to one of FOUR tiers:
9
8
  *
10
- * The classification obeys the repo convention against id-substring matching
11
- * (see `auto-mode/model-select.ts`): the only id parsing is structural version
12
- * parsing on first-party Anthropic ids (a namespace we control); for every other
13
- * provider the decision rests on price + containment, with a name hint as a
14
- * corroborating signal, never the lead.
9
+ * frontier Opus/Fable only — terse register (cc-opus), bash covers search
10
+ * workhorse Sonnet-class + capable third-party — verbose register (cc-sonnet)
11
+ * cheap Haiku-class — verbose register (cc-haiku), matches CC exactly
12
+ * tiny sub-Haiku models — verbose + weak-model scaffolding + grep/find/ls
13
+ *
14
+ * `extensions/system-prompt` picks the prompt text from the tier;
15
+ * `extensions/search-tools` activates grep/find/ls only for `tiny`. Rationale +
16
+ * rejected alternatives: `docs/decisions/model-tiers.md`.
17
+ *
18
+ * The classification obeys the repo convention against id-substring matching as
19
+ * the LEAD signal (see `auto-mode/model-select.ts`): frontier is a version-gated
20
+ * first-party Anthropic allowlist. Below it, a curated anchor map or the name
21
+ * class sets the tier and the model's GENERATION (models.dev release dates,
22
+ * `model-facts.ts`) demotes it — price is not consulted once facts are known, so
23
+ * an unpriced but current flagship on a hosted catalog keeps the workhorse
24
+ * register. Rows without facts fall back to the name-class cap, the "pro"-class
25
+ * hint and the absolute price floor, where unpriced/opaque means maximum
26
+ * scaffolding. The Artificial Analysis index never sets a register; it feeds the
27
+ * optional measured SELECTION floor (`capability-index.ts`) — see
28
+ * `classifyModelTier` and `docs/decisions/model-tiers.md`.
15
29
  */
16
30
 
17
31
  import type { Api, Model } from "@earendil-works/pi-ai";
18
- import { modelIdentity, pricedInput } from "./model-policy.ts";
32
+ import {
33
+ capabilityFloor,
34
+ type CapabilitySnapshot,
35
+ type FloorRole,
36
+ loadCapabilitySnapshot,
37
+ referenceScore,
38
+ REGISTER_CHEAP_RATIO,
39
+ REGISTER_TINY_RATIO,
40
+ scoreFor,
41
+ } from "./capability-index.ts";
42
+ import { isPriorGeneration, lacksToolCalls, modelGeneration } from "./model-facts.ts";
43
+ import { baseModelId, isDatedDuplicate, modelIdentity, modelsContainedToSession, modelSpec, pricedInput } from "./model-policy.ts";
44
+ import { oneCodeStateDir } from "./paths.ts";
45
+
46
+ export type PromptTier = "frontier" | "workhorse" | "cheap" | "tiny";
47
+
48
+ /** More-scaffolded = higher rank. Used to combine the price floor with the name cap. */
49
+ const TIER_RANK: Record<PromptTier, number> = { frontier: 0, workhorse: 1, cheap: 2, tiny: 3 };
50
+ function moreScaffolded(a: PromptTier, b: PromptTier): PromptTier {
51
+ return TIER_RANK[a] >= TIER_RANK[b] ? a : b;
52
+ }
19
53
 
20
- export type PromptTier = "frontier" | "mid" | "low";
54
+ /**
55
+ * Cost-preference order for automatic secondary-model selection: cheapest
56
+ * *capable* tier first, stepping UP only when a tier is empty. `tiny` is absent
57
+ * on purpose — automatic selection never lands there (see
58
+ * `economicalContainedCandidates`).
59
+ */
60
+ const COST_PREFERENCE: Record<Exclude<PromptTier, "tiny">, number> = { cheap: 0, workhorse: 1, frontier: 2 };
21
61
 
22
62
  /**
23
- * `CC_PROMPT_TIER=frontier|mid|low` forces a register; `auto`/unset classifies.
24
- * Env only — a project must not be able to downgrade its own scaffolding, the
25
- * same reasoning that keeps `autoMode` out of project settings.
63
+ * `CC_PROMPT_TIER=frontier|workhorse|cheap|tiny` forces a register; `auto`/unset
64
+ * classifies. Env only — a project must not be able to downgrade its own
65
+ * scaffolding, the same reasoning that keeps `autoMode` out of project settings.
26
66
  */
27
67
  export function tierOverride(env: NodeJS.ProcessEnv = process.env): PromptTier | undefined {
28
68
  const raw = env.CC_PROMPT_TIER?.trim().toLowerCase();
29
- return raw === "frontier" || raw === "mid" || raw === "low" ? raw : undefined;
69
+ return raw === "frontier" || raw === "workhorse" || raw === "cheap" || raw === "tiny" ? raw : undefined;
30
70
  }
31
71
 
32
72
  /**
33
- * Anthropic first-party frontier gate: Opus/Fable ≥ 4.8 or Sonnet ≥ 5. Adapted
34
- * from pi-ai's `defaultSupportsToolReferences` — never Haiku, and the
35
- * `length < 8` guard stops a dated suffix (`claude-opus-4-8-20251101`) being read
36
- * as the minor version. Version parse, not price: `claude-opus-4-1` ($15/M) costs
37
- * more than `opus-4-8`/`opus-5` ($5/M), so cost ranking would misclassify it.
73
+ * Anthropic first-party frontier gate: Opus/Fable ≥ 4.7 ONLY (Sonnet is
74
+ * deliberately excluded — CC gives only Opus the terse register, and the
75
+ * intelligence index would wrongly promote flash models to frontier at max
76
+ * effort; see `docs/decisions/model-tiers.md`). Adapted from pi-ai's
77
+ * `defaultSupportsToolReferences` — never Haiku, and the `length < 8` guard stops
78
+ * a dated suffix (`claude-opus-4-8-20251101`) being read as the minor version.
79
+ * Version parse, not price: `claude-opus-4-1` ($15/M) costs more than
80
+ * `opus-4-8`/`opus-5` ($5/M), so cost ranking would misclassify it.
38
81
  */
39
82
  function isAnthropicFrontier(model: Model<Api>): boolean {
40
83
  if (model.provider !== "anthropic" || model.id.includes("haiku")) return false;
41
- const version = model.id.match(/^claude-(opus|sonnet|fable)-(\d+)(?:-(\d+))?(?:-|$)/);
84
+ const version = parseClaudeVersion(model.id);
42
85
  if (!version) return false;
43
- const family = version[1];
44
- const major = Number(version[2]);
45
- const minor = version[3] && version[3].length < 8 ? Number(version[3]) : 0;
46
- return family === "sonnet" ? major >= 5 : major > 4 || (major === 4 && minor >= 8);
86
+ if (version.family === "sonnet") return false; // Sonnet is workhorse, never frontier
87
+ return version.major > 4 || (version.major === 4 && version.minor >= 7);
88
+ }
89
+
90
+ /**
91
+ * Structural parse of a first-party Claude model id (`claude-opus-4-8`,
92
+ * `claude-sonnet-5`, `claude-opus-4-5-20251101`): family, major, minor. A
93
+ * dated suffix is never read as the minor version (the `length < 8` guard).
94
+ * Undefined for anything else. Shared by the frontier gate here and the
95
+ * tool-reference gate in `deferred.ts`.
96
+ */
97
+ export function parseClaudeVersion(id: string): { family: "opus" | "sonnet" | "fable"; major: number; minor: number } | undefined {
98
+ const version = id.match(/^claude-(opus|sonnet|fable)-(\d+)(?:-(\d+))?(?:-|$)/);
99
+ if (!version) return undefined;
100
+ return {
101
+ family: version[1] as "opus" | "sonnet" | "fable",
102
+ major: Number(version[2]),
103
+ minor: version[3] && version[3].length < 8 ? Number(version[3]) : 0,
104
+ };
47
105
  }
48
106
 
49
107
  /**
50
- * Small-model name words that justify the low tier, matched as delimited tokens.
51
- * Delimiting matters: a bare `/mini/` matches inside "geMINI", which would drag
52
- * every Gemini model (pro included) to low. Deliberately narrower than
53
- * model-policy's `NAME_HINTS`, which also counts "haiku"/"sonnet" — those are
54
- * mid-class, and a gateway-proxied `…/claude-sonnet-5` reaching this branch must
55
- * not be demoted to the weakest prompt on the strength of its name.
108
+ * Curated anchor map for well-known third-party families, checked in order
109
+ * (specific before general). A vetted allowlist — distinct from heuristic
110
+ * substring matching — that fixes cases where price/name alone misroute a
111
+ * flagship, and encodes the ratified overrides (GPT-5-mini→cheap,
112
+ * GPT-5.6-Luna→cheap, GPT-5.x-codex-spark→cheap; the GPT-5-full→workhorse one
113
+ * was retired 2026-09-11 once the coding index contradicted it). A match here is authoritative for the
114
+ * name class (the human already accounted for the name and its generation), so
115
+ * it wins outright — only the two-generations-behind rule still demotes it to
116
+ * tiny (`classifyModelTier`). Shrink it whenever the catalog-wide snapshot
117
+ * (`test/unit/model-tier-catalog.test.ts`) shows the generic rules reproduce an
118
+ * entry; refresh the reference table with `tools/model-tiers/model_tiers.py`.
56
119
  */
57
- const LOW_TIER_NAME_HINT = /(?:^|[-/.])(?:flash|mini|nano|small|lite|instant)(?:[-/.]|$)/i;
120
+ const ANCHOR_MAP: Array<[RegExp, PromptTier]> = [
121
+ // OpenAI GPT-5 family — order matters: variant suffixes before the generic.
122
+ [/(?:^|[-/.])gpt-5[.\d]*-?nano/i, "tiny"],
123
+ [/(?:^|[-/.])gpt-5[.\d]*-?mini/i, "cheap"], // override: price would say tiny
124
+ [/(?:^|[-/.])gpt-5[.\d]*-?luna/i, "cheap"], // override: OpenAI's cheap line despite a high benchmark
125
+ // Spark: OpenAI's fast/small Codex line; no Artificial Analysis row either way, so it
126
+ // is placed with the other lean lines rather than inheriting the family's workhorse
127
+ // name class (2026-09-11 — it had been the cheapest "workhorse" on every Codex session).
128
+ [/(?:^|[-/.])gpt-5[.\d]*-codex-spark/i, "cheap"],
129
+ // No blanket `gpt-5 → workhorse` anchor any more (2026-09-11): it existed to guard
130
+ // the base GPT-5 variant against a benchmark collision, but the coding index now
131
+ // reads base gpt-5 at 37.8 and gpt-5.1 at 49.4 — the anchor was the lie, not the
132
+ // score. Unsuffixed gpt-5.x rows take the name class (workhorse) and let their
133
+ // generation and measured score demote them.
134
+ [/(?:^|[-/.])o[34](?:[-/.]|$)/i, "cheap"], // o3, o3-pro, o4-mini
135
+ [/(?:^|[-/.])gpt-4/i, "tiny"], // gpt-4o, gpt-4.1, gpt-4-turbo — prior generation
136
+ // Anthropic reaching this path is gateway-proxied (first-party handled above);
137
+ // a proxied model can't be version-verified for frontier, so Opus/Sonnet → workhorse.
138
+ [/claude[-/.].*haiku/i, "cheap"],
139
+ [/claude[-/.].*(?:sonnet|opus)/i, "workhorse"],
140
+ // Google Gemini needs no anchor: `lite` is a tiny name, `pro` a capable one, and
141
+ // bare flash is cheap by name class (generation decides 2.5 vs 3.x) — removed
142
+ // 2026-09-10 once the catalog-wide snapshot showed the generic rules reproduce it.
143
+ // Other capable flagships whose price may dip below the workhorse floor.
144
+ [/(?:^|[-/.])grok-4/i, "workhorse"],
145
+ // DeepSeek — priced ~10x below the vendors the price floors were calibrated
146
+ // on, so on the no-facts path the floors alone put every V4 Flash row in tiny
147
+ // and let R1-0528 ($0.50, on the cheap boundary) win automatic selection
148
+ // (2026-09-10). With release-date facts the generic rules reproduce the V4
149
+ // rows; the block stays for rows models.dev does not know yet (V4.1 Flash on
150
+ // its release day) and for R1/V3.x/`deepseek-chat`, which a one-generation
151
+ // demotion would leave at cheap rather than tiny.
152
+ [/(?:^|[-/.])deepseek[-.]v4[.\d]*-flash/i, "cheap"],
153
+ [/(?:^|[-/.])deepseek[-.]v4[.\d]*-pro/i, "workhorse"],
154
+ [/(?:^|[-/.])deepseek[-.](?:r1|chat|v3)(?:[-.:]|$)/i, "tiny"], // `deepseek.v3` is Bedrock's spelling
155
+ ];
156
+
157
+ function anchorTier(id: string): PromptTier | undefined {
158
+ const base = baseModelId(id);
159
+ for (const [pattern, tier] of ANCHOR_MAP) if (pattern.test(base)) return tier;
160
+ return undefined;
161
+ }
58
162
 
59
163
  /**
60
- * A "pro"-class name marks the capable variant of a family (deepseek-v4-pro,
61
- * gemini-3-pro, gpt-5-pro) — a capability-up signal that keeps a cheap or
62
- * unpriced flagship in mid rather than low. Matched as a delimited token so it
63
- * fires on `-pro`/`pro-` but not on `prometheus`, `improved`, or `proxy`.
164
+ * Name-class CAP: a lean/fast model class raises scaffolding (never lowers it).
165
+ * Delimited tokens — a bare `/mini/` matches inside "geMINI", dragging every
166
+ * Gemini model to cheap. `\d+b` catches size tags (8b, 70b) as the strongest
167
+ * (tiny) signal. Returns the ceiling tier the name justifies.
64
168
  */
65
- const CAPABLE_NAME_HINT = /(?:^|[-/.])pro(?:[-/.]|$)/i;
169
+ const TINY_NAME_HINT = /(?:^|[-/.])(?:nano|micro|lite|tiny|xs|distill|instant|\d+b)(?:[-/.]|$)/i;
170
+ const CHEAP_NAME_HINT = /(?:^|[-/.])(?:flash|mini|small|air)(?:[-/.]|$)/i;
171
+ function nameClassCap(id: string): PromptTier {
172
+ const base = baseModelId(id); // `flash:batch` must still read as flash
173
+ if (TINY_NAME_HINT.test(base)) return "tiny";
174
+ if (CHEAP_NAME_HINT.test(base)) return "cheap";
175
+ return "workhorse";
176
+ }
66
177
 
67
178
  /**
68
- * Below this input price (USD per million tokens) a model with no other signal
69
- * is treated as small. Calibrated against the live catalogs: capable flagships
70
- * sit at ≥ $1/M (gpt-5 1.25, grok 1.25, gemini-pro 1.25–2), while mini/nano/flash
71
- * classes sit well under $0.5. The boundary is intentionally biased toward `low`
72
- * (more scaffolding never hurts a cheap model), per the "unknown → low" decision.
179
+ * A capable-class name marks the flagship of a family (deepseek-v4-pro,
180
+ * qwen3-max) — keeps a cheap or unpriced flagship at workhorse rather than being
181
+ * dragged down by the price floor. Delimited so it fires on `-pro`/`max-` but not
182
+ * `prometheus`/`improved`. Only applies when no lean-name cap contradicts it.
73
183
  */
74
- const LOW_TIER_MAX_INPUT_COST = 0.5;
184
+ const CAPABLE_NAME_HINT = /(?:^|[-/.])(?:pro|max|ultra|large)(?:[-/.]|$)/i;
185
+
186
+ /** Input price (USD/1M) floors: capable flagships sit ≥ $1/M; mini/flash under $0.5. */
187
+ const WORKHORSE_MIN_INPUT_COST = 1.0;
188
+ const CHEAP_MIN_INPUT_COST = 0.5;
189
+
190
+ /** Price/containment floor for an unanchored third-party model, biased low. */
191
+ function priceFloorTier(model: Model<Api>): PromptTier {
192
+ if (modelIdentity(model).confidence === "opaque") return "tiny"; // local/self-hosted → max scaffolding
193
+ const price = pricedInput(model);
194
+ if (price === undefined) return "tiny"; // unpriced/unknown → maximum scaffolding
195
+ if (price >= WORKHORSE_MIN_INPUT_COST) return "workhorse";
196
+ if (price >= CHEAP_MIN_INPUT_COST) return "cheap";
197
+ return "tiny";
198
+ }
199
+
200
+ /** One step more scaffolded: workhorse → cheap → tiny (frontier never enters). */
201
+ function demote(tier: PromptTier): PromptTier {
202
+ return tier === "workhorse" ? "cheap" : "tiny";
203
+ }
75
204
 
76
- export function resolveModelTier(
77
- model: Model<Api> | undefined,
78
- env: NodeJS.ProcessEnv = process.env,
79
- ): PromptTier {
205
+ export interface TierClassification {
206
+ tier: PromptTier;
207
+ /** Which rule decided, for the catalog-wide snapshot and the doctor report. */
208
+ reason: string;
209
+ }
210
+
211
+ /**
212
+ * The tier and the rule that produced it. `resolveModelTier` is the plain form.
213
+ *
214
+ * Non-frontier, non-first-party models are classified in this order:
215
+ * 1. the curated anchor map (verifiable providers only) sets the name-class tier;
216
+ * 2. otherwise the name class does (pro/max/unmarked → workhorse, flash/mini →
217
+ * cheap, nano/lite/size-tag → tiny) — when the model has release-date facts.
218
+ * Then its GENERATION demotes: one tier when it trails its vendor family's
219
+ * newest release by more than a year (name-class path only — an anchor is
220
+ * human-reviewed), to tiny beyond two for both. Price is not
221
+ * consulted: it encodes neither capability nor generation (an old model
222
+ * keeps its old price), and absolute dollar floors misread every vendor
223
+ * priced below the US majors — `model-facts.ts`.
224
+ * 3. without facts, the pre-facts heuristics: name-class cap, the capable-name
225
+ * hint (verifiable providers only), and the absolute price floor, combined
226
+ * as the more scaffolded of the two.
227
+ */
228
+ export function classifyModelTier(model: Model<Api> | undefined, env: NodeJS.ProcessEnv = process.env): TierClassification {
229
+ const named = classifyByName(model, env);
230
+ // The measured cap: a name can only lie UPWARD about capability that a score
231
+ // then corrects (gpt-5 at 37.8 is not a Sonnet-class model whatever its
232
+ // anchor said), and downward it is already the safer register, so the score
233
+ // only ever adds scaffolding. Frontier (a first-party allowlist), Anthropic
234
+ // first-party (the scale's own anchors), a forced tier and an already-tiny
235
+ // row are left alone.
236
+ if (!model || named.reason === "CC_PROMPT_TIER" || named.tier === "frontier" || named.tier === "tiny" || model.provider === "anthropic") return named;
237
+ const measured = measuredRegisterCap(model);
238
+ if (!measured) return named;
239
+ const tier = moreScaffolded(named.tier, measured.cap);
240
+ if (tier === named.tier) return named;
241
+ return { tier, reason: `${named.reason} · measured coding ${measured.coding} below the ${measured.cap} line (${measured.threshold.toFixed(1)})` };
242
+ }
243
+
244
+ /**
245
+ * The register cap the Artificial Analysis snapshot supports for a model, if
246
+ * any: `cheap` below REGISTER_CHEAP_RATIO × Sonnet 5, `tiny` below
247
+ * REGISTER_TINY_RATIO × Sonnet 5, judged on the default (thinking-on) variant.
248
+ * Undefined without a snapshot, a confirmed score, or a reference score — the
249
+ * name-led tier then stands, as before the snapshot existed.
250
+ */
251
+ export function measuredRegisterCap(model: Model<Api>): { cap: "cheap" | "tiny"; coding: number; threshold: number } | undefined {
252
+ const snapshot = currentCapabilitySnapshot();
253
+ if (!snapshot) return undefined;
254
+ const score = scoreFor(snapshot, model, "default");
255
+ const reference = referenceScore(snapshot, "default");
256
+ if (!score || !reference) return undefined;
257
+ const tinyLine = reference.coding * REGISTER_TINY_RATIO;
258
+ if (score.coding < tinyLine) return { cap: "tiny", coding: score.coding, threshold: tinyLine };
259
+ const cheapLine = reference.coding * REGISTER_CHEAP_RATIO;
260
+ if (score.coding < cheapLine) return { cap: "cheap", coding: score.coding, threshold: cheapLine };
261
+ return undefined;
262
+ }
263
+
264
+ /** The name-led classification (anchors, name class, generation, price) before the measured cap. */
265
+ function classifyByName(model: Model<Api> | undefined, env: NodeJS.ProcessEnv): TierClassification {
80
266
  const forced = tierOverride(env);
81
- if (forced) return forced;
82
- if (!model) return "low"; // unknown model → maximum scaffolding
267
+ if (forced) return { tier: forced, reason: "CC_PROMPT_TIER" };
268
+ if (!model) return { tier: "tiny", reason: "no model" }; // unknown model → maximum scaffolding
83
269
 
84
- if (isAnthropicFrontier(model)) return "frontier";
85
- if (model.provider === "anthropic") return "mid"; // Haiku, Sonnet < 5, Opus 4.1–4.7
270
+ if (isAnthropicFrontier(model)) return { tier: "frontier", reason: "anthropic frontier allowlist" };
271
+ if (model.provider === "anthropic") {
272
+ // First-party non-frontier: Haiku → cheap; Sonnet / Opus 4.1–4.6 / other →
273
+ // workhorse. Version-named ids already encode generation, and the tiny
274
+ // register would be wrong for a still-sold Opus, so no date demotion here;
275
+ // automatic selection still skips prior-generation rows.
276
+ return model.id.includes("haiku") ? { tier: "cheap", reason: "anthropic haiku" } : { tier: "workhorse", reason: "anthropic first-party" };
277
+ }
86
278
 
87
- // A "pro"-class flagship stays mid even when cheap/unpriced, unless the id
88
- // also carries a small-model word (contradictory — let the small path win).
89
- if (CAPABLE_NAME_HINT.test(model.id) && !LOW_TIER_NAME_HINT.test(model.id)) return "mid";
279
+ // A curated anchor is authoritative for the NAME CLASS — but only for a
280
+ // verifiable provider. A local/self-hosted (opaque) provider's id could be
281
+ // anything, so it must NOT reach the anchor map (a model aliased
282
+ // "claude-sonnet-5" on ollama is not the real thing) nor the capable-name
283
+ // hint; it falls through to the generic heuristics, where the opaque check
284
+ // biases it to maximum scaffolding.
285
+ const opaque = modelIdentity(model).confidence === "opaque";
286
+ const anchor = opaque ? undefined : anchorTier(model.id);
90
287
 
91
- // Otherwise low wins on any positive smallness signal.
92
- const price = pricedInput(model);
93
- const small =
94
- LOW_TIER_NAME_HINT.test(model.id) ||
95
- modelIdentity(model).confidence === "opaque" || // local/self-hosted providers
96
- price === undefined || // unpriced/unknown → low
97
- price < LOW_TIER_MAX_INPUT_COST;
98
- return small ? "low" : "mid";
288
+ const generation = opaque ? undefined : modelGeneration(model);
289
+ if (generation !== undefined) {
290
+ const nameTier = anchor ?? nameClassCap(model.id); // never frontier: anchors and name classes stop at workhorse
291
+ if (generation === "ancient") return { tier: "tiny", reason: `${anchor ? "anchor" : "name class"} · two generations behind` };
292
+ // A curated anchor already weighed the model's generation (it is reviewed
293
+ // against the catalog diff), so only the name-class path is demoted here.
294
+ if (generation === "prior" && !anchor) return { tier: demote(nameTier), reason: "name class · one generation behind" };
295
+ return { tier: nameTier, reason: anchor ? "anchor" : "name class" };
296
+ }
297
+ if (anchor) return { tier: anchor, reason: "anchor" };
298
+
299
+ const cap = nameClassCap(model.id);
300
+ // A "pro"/"max"-class name keeps a cheap/unpriced flagship at workhorse; a lean
301
+ // name (cap below workhorse) always wins, so only consult it when cap allows.
302
+ if (!opaque && cap === "workhorse" && CAPABLE_NAME_HINT.test(baseModelId(model.id))) return { tier: "workhorse", reason: "capable name, no facts" };
303
+ const floor = priceFloorTier(model);
304
+ const tier = moreScaffolded(floor, cap);
305
+ return { tier, reason: opaque ? "opaque provider" : pricedInput(model) === undefined ? "unpriced, no facts" : "price floor, no facts" };
306
+ }
307
+
308
+ export function resolveModelTier(model: Model<Api> | undefined, env: NodeJS.ProcessEnv = process.env): PromptTier {
309
+ return classifyModelTier(model, env).tier;
310
+ }
311
+
312
+
313
+ /**
314
+ * The ordered candidate chain for an automatic *secondary* model — the same
315
+ * mechanism the auto-mode classifier and subagents both consume, so a session
316
+ * screens and delegates on one economical same-provider model.
317
+ *
318
+ * The chain is:
319
+ * - **contained** to the session's provider (and route/family on gateways):
320
+ * the classifier reads the user's prompts + CLAUDE.md and subagents inherit
321
+ * the parent transcript, so leaving the session's provider silently is a
322
+ * privacy leak — only an explicit setting may cross (see the two model-select
323
+ * modules);
324
+ * - never `tiny`: a sub-Haiku model is a weak security boundary and a weak
325
+ * coding worker, so automatic selection stops at `cheap` and steps UP
326
+ * (cheap → workhorse → frontier), never down. This is the capability floor
327
+ * `docs/decisions/auto-mode.md` recorded as still-missing;
328
+ * - current generation only, and able to call tools: with models.dev facts
329
+ * known (`model-facts.ts`), a row more than a year behind its vendor family's
330
+ * newest release is excluded whatever its price or name, as is a row marked
331
+ * as lacking tool calls. Rows without facts pass this gate unchanged;
332
+ * - priced only: an unpriced/opaque row is treated as `tiny` by
333
+ * `resolveModelTier`, so this excludes it too. On a provider with no usable
334
+ * prices the chain is empty and callers degrade to the session model
335
+ * (correct, merely not cheap).
336
+ *
337
+ * Sorted by cost preference (cheapest capable *tier* first) then input price, so
338
+ * the head is "the cheapest capable model this provider offers." The session
339
+ * model may appear in the chain; the budget-gated `cheaperContainedCandidates`
340
+ * drops it. Budget ceilings and strictly-cheaper-than-session are caller policy —
341
+ * this function ranks, it does not gate. A caller that already computed the
342
+ * containment set may pass it as `contained` to skip the O(catalog) recompute.
343
+ */
344
+ export function economicalContainedCandidates(
345
+ available: Model<Api>[],
346
+ sessionModel: Model<Api>,
347
+ contained?: Model<Api>[],
348
+ ): Model<Api>[] {
349
+ const pool = contained ?? modelsContainedToSession(available, sessionModel);
350
+ return pool
351
+ // A dated snapshot whose undated alias is also listed is the same model
352
+ // twice; rank the alias so every automatic pick (classifier, subagent,
353
+ // reader, presets) names the model the way the user sees it in /model.
354
+ .filter((model) => !isDatedDuplicate(model, pool))
355
+ // Facts, when known: a model one generation behind its family is never
356
+ // picked automatically however it prices (the R1-over-V4-Flash incident),
357
+ // and a subagent or screener must be able to call tools at all.
358
+ .filter((model) => !isPriorGeneration(model) && !lacksToolCalls(model))
359
+ // Classify by the model's INTRINSIC tier — never `process.env`: CC_PROMPT_TIER
360
+ // forces the *session's* prompt-scaffolding register, and honoring it here
361
+ // would collapse every candidate to one tier and let a `tiny` model through
362
+ // the security floor. Selection must judge each model on its own merits.
363
+ .map((model) => ({ model, tier: resolveModelTier(model, INTRINSIC_TIER_ENV), price: pricedInput(model) }))
364
+ .filter(
365
+ (entry): entry is { model: Model<Api>; tier: Exclude<PromptTier, "tiny">; price: number } =>
366
+ entry.tier !== "tiny" && entry.price !== undefined,
367
+ )
368
+ .sort((a, b) => COST_PREFERENCE[a.tier] - COST_PREFERENCE[b.tier] || a.price - b.price)
369
+ .map((entry) => entry.model);
370
+ }
371
+
372
+ /** No CC_PROMPT_TIER override: automatic model *selection* always uses intrinsic tiers. */
373
+ const INTRINSIC_TIER_ENV: NodeJS.ProcessEnv = Object.freeze({}) as NodeJS.ProcessEnv;
374
+
375
+ /** A model's own tier, CC_PROMPT_TIER ignored — what selection judges a candidate by. */
376
+ export function intrinsicTier(model: Model<Api>): PromptTier {
377
+ return resolveModelTier(model, INTRINSIC_TIER_ENV);
378
+ }
379
+
380
+ /** Whether `tier` is at least as capable as `floor` (frontier ≥ workhorse ≥ cheap ≥ tiny). */
381
+ export function atLeastTier(tier: PromptTier, floor: PromptTier): boolean {
382
+ return TIER_RANK[tier] <= TIER_RANK[floor];
383
+ }
384
+
385
+ /**
386
+ * The budget-gated form the automatic secondary-model pickers all share: same as
387
+ * `economicalContainedCandidates`, minus the session model itself and anything
388
+ * dearer than it. `strict` requires *strictly* cheaper (subagents never upgrade a
389
+ * cheap session); non-strict allows equal price (the classifier and reader
390
+ * tolerate a same-price screener). With the session price unknown there is no
391
+ * demonstrable saving, so `strict` yields nothing while non-strict keeps the
392
+ * tier-ranked list. `contained` is forwarded to skip the containment recompute.
393
+ */
394
+ export function cheaperContainedCandidates(
395
+ available: Model<Api>[],
396
+ sessionModel: Model<Api>,
397
+ opts: { strict?: boolean; contained?: Model<Api>[]; role?: FloorRole } = {},
398
+ ): Model<Api>[] {
399
+ const sessionSpec = modelSpec(sessionModel);
400
+ const sessionPrice = pricedInput(sessionModel);
401
+ const cheaper = economicalContainedCandidates(available, sessionModel, opts.contained).filter((model) => {
402
+ if (modelSpec(model) === sessionSpec) return false;
403
+ if (sessionPrice === undefined) return !opts.strict;
404
+ const price = pricedInput(model);
405
+ return price !== undefined && (opts.strict ? price < sessionPrice : price <= sessionPrice);
406
+ });
407
+ return opts.role ? rankByCapability(cheaper, sessionModel, opts.role).map((entry) => entry.model) : cheaper;
408
+ }
409
+
410
+ /**
411
+ * The weakest tier an AUTOMATIC pick may run on for a session: workhorse when
412
+ * the session itself is workhorse or frontier (Claude Code's `min(main, sonnet)`
413
+ * classifier rule), cheap otherwise (a cheap session has nothing cheaper and
414
+ * capable; `tiny` is excluded upstream regardless). Shared by the auto-mode
415
+ * classifier and the subagent default since 2026-09-11: a delegated worker
416
+ * writes code and calls tools for many turns, and a weak one spends the saving
417
+ * on retries — so both roles are held to one floor and, on most providers, one
418
+ * model (docs/decisions/model-policy.md).
419
+ */
420
+ export function automaticTierFloor(sessionModel: Model<Api>): PromptTier {
421
+ const tier = intrinsicTier(sessionModel);
422
+ return tier === "frontier" || tier === "workhorse" ? "workhorse" : "cheap";
423
+ }
424
+
425
+ /**
426
+ * The floor-gated form the classifier and the subagent default share: the
427
+ * cheaper contained candidates (`cheaperContainedCandidates`) that either
428
+ * measurably reach the role's capability floor (an Artificial Analysis
429
+ * snapshot, when one exists — `capability-index.ts`) or, unscored, sit at or
430
+ * above `automaticTierFloor(sessionModel)` by name class. Measured failures are
431
+ * already dropped upstream. The head is "the cheapest model this provider
432
+ * offers that is capable enough for the session".
433
+ */
434
+ export function capableContainedCandidates(
435
+ available: Model<Api>[],
436
+ sessionModel: Model<Api>,
437
+ role: FloorRole,
438
+ opts: { strict?: boolean; contained?: Model<Api>[] } = {},
439
+ ): Model<Api>[] {
440
+ const floor = automaticTierFloor(sessionModel);
441
+ return rankedContainedCandidates(available, sessionModel, role, opts)
442
+ .filter(({ model, measured }) => measured === "pass" || atLeastTier(intrinsicTier(model), floor))
443
+ .map((entry) => entry.model);
444
+ }
445
+
446
+ interface RankedCandidate {
447
+ model: Model<Api>;
448
+ /** "pass": measurably reaches the floor; "unscored": no snapshot or no confirmed score (the caller's name-class rule decides). */
449
+ measured: "pass" | "unscored";
450
+ }
451
+
452
+ /**
453
+ * `cheaperContainedCandidates` with the measured verdict kept on each entry, so a
454
+ * caller that must tell a measured pass from an unscored candidate (the
455
+ * classifier's name-class fallback) reads it instead of recomputing it.
456
+ */
457
+ function rankedContainedCandidates(
458
+ available: Model<Api>[],
459
+ sessionModel: Model<Api>,
460
+ role: FloorRole,
461
+ opts: { strict?: boolean; contained?: Model<Api>[] } = {},
462
+ ): RankedCandidate[] {
463
+ return rankByCapability(cheaperContainedCandidates(available, sessionModel, opts), sessionModel, role);
464
+ }
465
+
466
+ /**
467
+ * The measured capability floor (`capability-index.ts`) over a tier-ranked
468
+ * candidate list: candidates the Artificial Analysis snapshot shows reaching
469
+ * min(session, Sonnet 5) come first, cheapest first — capability per dollar is
470
+ * the question here, and a flash-class model that measurably matches the
471
+ * session model should beat a dearer "workhorse"-named one. Measured failures
472
+ * are dropped. Unscored candidates (no key, no confirmed match) follow in their
473
+ * tier order, for the caller's name-class rule to judge. The score never admits
474
+ * anything the tier gate already refused (tiny, prior generation, no tools).
475
+ */
476
+ function rankByCapability(candidates: Model<Api>[], sessionModel: Model<Api>, role: FloorRole): RankedCandidate[] {
477
+ const snapshot = currentCapabilitySnapshot();
478
+ if (!snapshot) return candidates.map((model) => ({ model, measured: "unscored" }));
479
+ const measured: RankedCandidate[] = [];
480
+ const unscored: RankedCandidate[] = [];
481
+ for (const model of candidates) {
482
+ const verdict = capabilityFloor(snapshot, model, sessionModel, role).verdict;
483
+ if (verdict === "pass") measured.push({ model, measured: "pass" });
484
+ else if (verdict === "unscored") unscored.push({ model, measured: "unscored" });
485
+ }
486
+ const price = (entry: RankedCandidate) => pricedInput(entry.model) ?? Number.POSITIVE_INFINITY;
487
+ return [...measured.sort((a, b) => price(a) - price(b)), ...unscored];
488
+ }
489
+
490
+ /** The cached Artificial Analysis snapshot, if a key has ever produced one (`capability-index.ts`). */
491
+ export function currentCapabilitySnapshot(): CapabilitySnapshot | undefined {
492
+ return loadCapabilitySnapshot(oneCodeStateDir());
493
+ }
494
+
495
+ export interface EconomicalModelChoice {
496
+ model: Model<Api>;
497
+ via: "tier" | "session";
498
+ }
499
+
500
+ /**
501
+ * The cheapest capable same-provider model, else the session model itself. Used
502
+ * for low-stakes one-shot jobs over untrusted content (the web_fetch reader,
503
+ * the recap) — the content goes to the model, so containment applies. Never
504
+ * dearer than the session model, and never a `tiny`-tier model
505
+ * (`economicalContainedCandidates` enforces both). Not used for compaction,
506
+ * which runs on the session model to reuse the provider prompt cache.
507
+ */
508
+ export function pickEconomicalContainedModel(
509
+ available: Model<Api>[],
510
+ sessionModel: Model<Api> | undefined,
511
+ ): EconomicalModelChoice | undefined {
512
+ if (!sessionModel) return undefined;
513
+ const cheaper = cheaperContainedCandidates(available, sessionModel, { role: "reader" })[0];
514
+ return cheaper ? { model: cheaper, via: "tier" } : { model: sessionModel, via: "session" };
99
515
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Models this account cannot use, learned at runtime and shared across the
3
+ * extensions that pick models automatically.
4
+ *
5
+ * The catalog says what a provider *offers*; only a request says what this
6
+ * account may *run* (`openai-codex/gpt-5.3-codex-spark` is "not supported when
7
+ * using Codex with a ChatGPT account"). The auto-mode classifier already learns
8
+ * that on its first call and steps to the next candidate (`ClassifierState.
9
+ * rejected`). Since subagents share the classifier's selection floor
10
+ * (docs/decisions/model-policy.md, 2026-09-11) they land on the same first
11
+ * pick — so what one role learns must reach the other, or every session's
12
+ * first subagent fails on a model the classifier already gave up on.
13
+ *
14
+ * Extensions are jiti-isolated (no shared module state), so the fact travels on
15
+ * `pi.events`: whoever learns it emits `MODEL_UNUSABLE_CHANNEL`; the auto-mode
16
+ * gate adds it to its `rejected` set, the subagent and workflow extensions drop
17
+ * it from the catalog they resolve against. Session-scoped by construction
18
+ * (process memory) — an entitlement can change between sessions.
19
+ */
20
+
21
+ import type { Api, Model } from "@earendil-works/pi-ai";
22
+ import { modelSpec } from "./model-policy.ts";
23
+
24
+ export const MODEL_UNUSABLE_CHANNEL = "one-code:model-unusable";
25
+
26
+ export interface ModelUnusableEvent {
27
+ /** `provider/id` of the model the account cannot run. */
28
+ model: string;
29
+ /** The provider's own words, for the notice. */
30
+ reason: string;
31
+ }
32
+
33
+ /** The catalog minus the models this session has found unusable. */
34
+ export function withoutUnusable(available: Model<Api>[], unusable: ReadonlySet<string>): Model<Api>[] {
35
+ if (unusable.size === 0) return available;
36
+ return available.filter((model) => !unusable.has(modelSpec(model)));
37
+ }