magus-cli 0.0.0-placeholder → 7.1.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 (330) hide show
  1. package/README.md +309 -0
  2. package/bin/magus.js +170 -0
  3. package/package.json +67 -3
  4. package/scripts/build-binaries.ts +113 -0
  5. package/scripts/capture-builtin.ts +412 -0
  6. package/scripts/check-optional-deps.ts +49 -0
  7. package/scripts/test-isolated.ts +49 -0
  8. package/scripts/verify-binary.ts +95 -0
  9. package/scripts/verify-community-registry.ts +272 -0
  10. package/src/__tests__/active-closure.test.ts +184 -0
  11. package/src/__tests__/alias-adopt.test.ts +365 -0
  12. package/src/__tests__/alias-parser.test.ts +417 -0
  13. package/src/__tests__/alias-shell-writer.test.ts +681 -0
  14. package/src/__tests__/alias-store.test.ts +163 -0
  15. package/src/__tests__/binary-signing.test.ts +120 -0
  16. package/src/__tests__/blackbox-active-closure.test.ts +119 -0
  17. package/src/__tests__/blackbox-convergence.test.ts +334 -0
  18. package/src/__tests__/blackbox-parity.test.ts +725 -0
  19. package/src/__tests__/blackbox-plugin-state.test.ts +545 -0
  20. package/src/__tests__/blackbox-tool-state.test.ts +669 -0
  21. package/src/__tests__/cache-provenance.test.ts +84 -0
  22. package/src/__tests__/capture-builtin.test.ts +92 -0
  23. package/src/__tests__/catalog-cache-store.test.ts +309 -0
  24. package/src/__tests__/catalog-notice.test.ts +155 -0
  25. package/src/__tests__/cli-ansi.test.ts +297 -0
  26. package/src/__tests__/cli-apply-seams.test.ts +288 -0
  27. package/src/__tests__/cli-live.test.ts +391 -0
  28. package/src/__tests__/cli-router.test.ts +240 -0
  29. package/src/__tests__/cli-tool-commands.test.ts +239 -0
  30. package/src/__tests__/cli-tools-adapter.test.ts +294 -0
  31. package/src/__tests__/cli-update-view.test.ts +524 -0
  32. package/src/__tests__/community-fetch.test.ts +545 -0
  33. package/src/__tests__/community-registry.test.ts +269 -0
  34. package/src/__tests__/community-staleness.test.ts +720 -0
  35. package/src/__tests__/component-badge.test.ts +98 -0
  36. package/src/__tests__/component-icons.test.ts +94 -0
  37. package/src/__tests__/content-drift.test.ts +353 -0
  38. package/src/__tests__/conventions-integration.test.ts +830 -0
  39. package/src/__tests__/conventions-manager.test.ts +1236 -0
  40. package/src/__tests__/doctor-bins.test.ts +43 -0
  41. package/src/__tests__/doctor.test.ts +463 -0
  42. package/src/__tests__/dotenv.test.ts +195 -0
  43. package/src/__tests__/enabled-not-installed.test.ts +94 -0
  44. package/src/__tests__/file-locking.test.ts +208 -0
  45. package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
  46. package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
  47. package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
  48. package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
  49. package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
  50. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
  51. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
  52. package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
  53. package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
  54. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
  55. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
  56. package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
  57. package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
  58. package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
  59. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
  60. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
  61. package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
  62. package/src/__tests__/footer-hints.test.ts +40 -0
  63. package/src/__tests__/format-command.test.ts +78 -0
  64. package/src/__tests__/git-worktree.test.ts +108 -0
  65. package/src/__tests__/github-budget.test.ts +206 -0
  66. package/src/__tests__/gitignore-check.test.ts +71 -0
  67. package/src/__tests__/gitignore-detector.test.ts +176 -0
  68. package/src/__tests__/gitignore-fixer.test.ts +317 -0
  69. package/src/__tests__/gitignore-resolver.test.ts +193 -0
  70. package/src/__tests__/gitignore-service.test.ts +137 -0
  71. package/src/__tests__/go-module.test.ts +43 -0
  72. package/src/__tests__/hook-import-policy.test.ts +90 -0
  73. package/src/__tests__/hook-process.test.ts +256 -0
  74. package/src/__tests__/hook-registration.test.ts +224 -0
  75. package/src/__tests__/install-command.test.ts +81 -0
  76. package/src/__tests__/install-plan.test.ts +104 -0
  77. package/src/__tests__/launcher-signal.test.ts +308 -0
  78. package/src/__tests__/local-marketplace-scan.test.ts +248 -0
  79. package/src/__tests__/manifest.test.ts +281 -0
  80. package/src/__tests__/markdown-renderer.test.ts +142 -0
  81. package/src/__tests__/marketplace-badge.test.ts +140 -0
  82. package/src/__tests__/marketplace-refresh.test.ts +340 -0
  83. package/src/__tests__/marketplace-version.test.ts +84 -0
  84. package/src/__tests__/marketplaces.test.ts +128 -0
  85. package/src/__tests__/mate-availability.test.ts +156 -0
  86. package/src/__tests__/mate-catalog.test.ts +295 -0
  87. package/src/__tests__/mcp-registry.test.ts +158 -0
  88. package/src/__tests__/model-visuals.test.tsx +2466 -0
  89. package/src/__tests__/models-adapter.test.ts +332 -0
  90. package/src/__tests__/models-cli.test.ts +273 -0
  91. package/src/__tests__/models-core.test.ts +802 -0
  92. package/src/__tests__/models-manager.test.ts +500 -0
  93. package/src/__tests__/models-presets-marketplace.test.ts +168 -0
  94. package/src/__tests__/models-screen-state.test.ts +315 -0
  95. package/src/__tests__/open-file.test.ts +59 -0
  96. package/src/__tests__/owned-marketplaces.test.ts +93 -0
  97. package/src/__tests__/plugin-checkout.test.ts +66 -0
  98. package/src/__tests__/plugin-cli-argv.test.ts +109 -0
  99. package/src/__tests__/plugin-contents.test.ts +371 -0
  100. package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
  101. package/src/__tests__/plugin-releases.test.ts +98 -0
  102. package/src/__tests__/plugin-requires.test.ts +187 -0
  103. package/src/__tests__/plugin-row-text.test.ts +345 -0
  104. package/src/__tests__/plugin-setup.test.ts +1016 -0
  105. package/src/__tests__/profile-adopt.test.ts +177 -0
  106. package/src/__tests__/profile-command.test.ts +110 -0
  107. package/src/__tests__/profile-materializer.test.ts +238 -0
  108. package/src/__tests__/profile-sync.test.ts +154 -0
  109. package/src/__tests__/registry-version-resolution.test.ts +88 -0
  110. package/src/__tests__/resolve-executable.test.ts +42 -0
  111. package/src/__tests__/resolver.test.ts +351 -0
  112. package/src/__tests__/scope-action.test.ts +134 -0
  113. package/src/__tests__/scope-squares.test.tsx +164 -0
  114. package/src/__tests__/selection-scope.test.ts +100 -0
  115. package/src/__tests__/settings-file.test.ts +179 -0
  116. package/src/__tests__/shell-script-callers.test.ts +92 -0
  117. package/src/__tests__/skill-install-files.test.ts +136 -0
  118. package/src/__tests__/skills-catalog-client.test.ts +48 -0
  119. package/src/__tests__/style-wrap.test.ts +220 -0
  120. package/src/__tests__/styles-manager.test.ts +1098 -0
  121. package/src/__tests__/styles-origins.test.ts +414 -0
  122. package/src/__tests__/styles-screen-state.test.ts +457 -0
  123. package/src/__tests__/styles-status-line.test.ts +72 -0
  124. package/src/__tests__/styles-sync.test.ts +452 -0
  125. package/src/__tests__/symlink-manager.test.ts +180 -0
  126. package/src/__tests__/tabbar-layout.test.ts +100 -0
  127. package/src/__tests__/terminology-filler.test.ts +214 -0
  128. package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
  129. package/src/__tests__/theme-env.test.ts +310 -0
  130. package/src/__tests__/theme-resolve.test.ts +380 -0
  131. package/src/__tests__/tool-probe.test.ts +1400 -0
  132. package/src/__tests__/toolchain.test.ts +232 -0
  133. package/src/__tests__/ui-version-writers.test.ts +88 -0
  134. package/src/__tests__/update-apply.test.ts +637 -0
  135. package/src/__tests__/update-plan.test.ts +1396 -0
  136. package/src/__tests__/uppercase-keybindings.test.ts +101 -0
  137. package/src/__tests__/useGitignoreModal.test.ts +79 -0
  138. package/src/__tests__/version-check-refresh.test.ts +45 -0
  139. package/src/__tests__/version-snapshot.test.ts +212 -0
  140. package/src/__tests__/version-unknown-state.test.ts +403 -0
  141. package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
  142. package/src/cli/ansi.ts +528 -0
  143. package/src/cli/bootstrap.ts +134 -0
  144. package/src/cli/doctor.ts +218 -0
  145. package/src/cli/hook.ts +129 -0
  146. package/src/cli/install.ts +382 -0
  147. package/src/cli/live.ts +374 -0
  148. package/src/cli/models.ts +297 -0
  149. package/src/cli/profile.ts +197 -0
  150. package/src/cli/prompt.ts +32 -0
  151. package/src/cli/router.ts +264 -0
  152. package/src/cli/update-view.ts +717 -0
  153. package/src/cli/update.ts +600 -0
  154. package/src/cli/upgrade.ts +97 -0
  155. package/src/data/alias-flags.ts +301 -0
  156. package/src/data/cli-tools.ts +146 -0
  157. package/src/data/community-styles.ts +531 -0
  158. package/src/data/gitignore-defaults.ts +33 -0
  159. package/src/data/gitignore-reasons.ts +93 -0
  160. package/src/data/gitignore-templates.ts +25 -0
  161. package/src/data/marketplaces.ts +278 -0
  162. package/src/data/mcp-servers.ts +51 -0
  163. package/src/data/models-presets.ts +329 -0
  164. package/src/data/predefined-profiles.ts +252 -0
  165. package/src/data/settings-catalog.ts +714 -0
  166. package/src/data/skill-repos.ts +91 -0
  167. package/src/data/styles/asd-ste100.md +38 -0
  168. package/src/data/styles/calibrated.md +24 -0
  169. package/src/data/styles/direct.md +23 -0
  170. package/src/data/styles/evidence-first.md +22 -0
  171. package/src/data/styles/explanatory.md +23 -0
  172. package/src/data/styles/index.ts +57 -0
  173. package/src/data/styles/no-slop.md +32 -0
  174. package/src/data/styles/plain-language.md +23 -0
  175. package/src/data/styles/structured.md +30 -0
  176. package/src/data/styles/terminology.md +28 -0
  177. package/src/data/styles/terse.md +20 -0
  178. package/src/main.tsx +110 -0
  179. package/src/markdown.d.ts +13 -0
  180. package/src/opentui.d.ts +203 -0
  181. package/src/services/active-closure.ts +117 -0
  182. package/src/services/alias-settings.ts +60 -0
  183. package/src/services/alias-shell-writer.ts +1244 -0
  184. package/src/services/alias-store.ts +172 -0
  185. package/src/services/binary-signing.ts +78 -0
  186. package/src/services/catalog-cache-store.ts +316 -0
  187. package/src/services/claude-cli.ts +304 -0
  188. package/src/services/claude-runner.ts +31 -0
  189. package/src/services/claude-settings.ts +1564 -0
  190. package/src/services/cli-tool-commands.ts +133 -0
  191. package/src/services/community-fetcher.ts +90 -0
  192. package/src/services/community-styles.ts +1194 -0
  193. package/src/services/content-drift.ts +208 -0
  194. package/src/services/conventions-manager.ts +945 -0
  195. package/src/services/doctor-bins.ts +63 -0
  196. package/src/services/doctor.ts +570 -0
  197. package/src/services/dotenv.ts +181 -0
  198. package/src/services/git-worktree.ts +129 -0
  199. package/src/services/github-budget.ts +274 -0
  200. package/src/services/gitignore-check.ts +63 -0
  201. package/src/services/gitignore-detector.ts +167 -0
  202. package/src/services/gitignore-fixer.ts +280 -0
  203. package/src/services/gitignore-resolver.ts +208 -0
  204. package/src/services/gitignore-service.ts +167 -0
  205. package/src/services/hook-registration.ts +218 -0
  206. package/src/services/install-plan.ts +107 -0
  207. package/src/services/local-marketplace.ts +657 -0
  208. package/src/services/manifest.ts +250 -0
  209. package/src/services/marketplace-catalog-git.ts +173 -0
  210. package/src/services/marketplace-catalog.ts +129 -0
  211. package/src/services/marketplace-fetcher.ts +400 -0
  212. package/src/services/marketplace-refresh.ts +245 -0
  213. package/src/services/marketplace-sync.ts +145 -0
  214. package/src/services/mate-availability.ts +133 -0
  215. package/src/services/mate-catalog.ts +265 -0
  216. package/src/services/mcp-registry.ts +171 -0
  217. package/src/services/models-core.ts +969 -0
  218. package/src/services/models-manager.ts +606 -0
  219. package/src/services/plugin-checkout.ts +186 -0
  220. package/src/services/plugin-manager.ts +847 -0
  221. package/src/services/plugin-mcp-config.ts +242 -0
  222. package/src/services/plugin-releases.ts +47 -0
  223. package/src/services/plugin-requires.ts +227 -0
  224. package/src/services/plugin-setup.ts +965 -0
  225. package/src/services/profile-adopt.ts +206 -0
  226. package/src/services/profile-materializer.ts +133 -0
  227. package/src/services/profile-sync.ts +158 -0
  228. package/src/services/profiles.ts +225 -0
  229. package/src/services/resolver.ts +377 -0
  230. package/src/services/settings-file.ts +69 -0
  231. package/src/services/settings-manager.ts +307 -0
  232. package/src/services/skills-manager.ts +1111 -0
  233. package/src/services/skillsmp-client.ts +193 -0
  234. package/src/services/styles-manager.ts +1294 -0
  235. package/src/services/symlink-manager.ts +189 -0
  236. package/src/services/terminology-filler.ts +266 -0
  237. package/src/services/tool-probe.ts +1637 -0
  238. package/src/services/toolchain.ts +431 -0
  239. package/src/services/update-engine.ts +691 -0
  240. package/src/services/update-plan.ts +820 -0
  241. package/src/services/version-check.ts +127 -0
  242. package/src/services/version-snapshot.ts +189 -0
  243. package/src/tui.tsx +112 -0
  244. package/src/types/bun.d.ts +27 -0
  245. package/src/types/gitignore.ts +68 -0
  246. package/src/types/index.ts +447 -0
  247. package/src/ui/App.tsx +535 -0
  248. package/src/ui/adapters/catalogNotice.ts +122 -0
  249. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  250. package/src/ui/adapters/modelsAdapter.ts +213 -0
  251. package/src/ui/adapters/pluginsAdapter.ts +413 -0
  252. package/src/ui/adapters/settingsAdapter.ts +168 -0
  253. package/src/ui/adapters/skillsAdapter.ts +229 -0
  254. package/src/ui/adapters/stylesAdapter.ts +403 -0
  255. package/src/ui/components/CategoryHeader.tsx +64 -0
  256. package/src/ui/components/EmptyFilterState.tsx +32 -0
  257. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  258. package/src/ui/components/ScopeIndicator.tsx +63 -0
  259. package/src/ui/components/ScrollableList.tsx +95 -0
  260. package/src/ui/components/SearchInput.tsx +57 -0
  261. package/src/ui/components/StyledText.tsx +75 -0
  262. package/src/ui/components/TabBar.tsx +101 -0
  263. package/src/ui/components/layout/FooterHints.tsx +78 -0
  264. package/src/ui/components/layout/Panel.tsx +63 -0
  265. package/src/ui/components/layout/ProgressBar.tsx +44 -0
  266. package/src/ui/components/layout/ScopeTabs.tsx +54 -0
  267. package/src/ui/components/layout/ScreenLayout.tsx +321 -0
  268. package/src/ui/components/layout/index.ts +9 -0
  269. package/src/ui/components/modals/ConfirmModal.tsx +62 -0
  270. package/src/ui/components/modals/InputModal.tsx +75 -0
  271. package/src/ui/components/modals/LoadingModal.tsx +51 -0
  272. package/src/ui/components/modals/MessageModal.tsx +66 -0
  273. package/src/ui/components/modals/ModalContainer.tsx +159 -0
  274. package/src/ui/components/modals/SelectModal.tsx +90 -0
  275. package/src/ui/components/modals/index.ts +6 -0
  276. package/src/ui/components/primitives/ActionHints.tsx +44 -0
  277. package/src/ui/components/primitives/DetailSection.tsx +23 -0
  278. package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
  279. package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
  280. package/src/ui/components/primitives/MetaText.tsx +40 -0
  281. package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
  282. package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
  283. package/src/ui/components/primitives/SelectableRow.tsx +30 -0
  284. package/src/ui/components/primitives/index.ts +9 -0
  285. package/src/ui/hooks/index.ts +2 -0
  286. package/src/ui/hooks/useGitignoreModal.ts +95 -0
  287. package/src/ui/hooks/useKeyboard.ts +24 -0
  288. package/src/ui/hooks/useKeyboardHandler.ts +63 -0
  289. package/src/ui/registry.ts +33 -0
  290. package/src/ui/renderers/cliToolRenderers.tsx +326 -0
  291. package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
  292. package/src/ui/renderers/markdownRenderer.tsx +302 -0
  293. package/src/ui/renderers/mcpRenderers.tsx +149 -0
  294. package/src/ui/renderers/modelRenderers.tsx +1309 -0
  295. package/src/ui/renderers/modelVisuals.tsx +1402 -0
  296. package/src/ui/renderers/pluginRenderers.tsx +1089 -0
  297. package/src/ui/renderers/pluginRowText.ts +70 -0
  298. package/src/ui/renderers/pluginStatusText.ts +56 -0
  299. package/src/ui/renderers/profileRenderers.tsx +406 -0
  300. package/src/ui/renderers/settingsRenderers.tsx +218 -0
  301. package/src/ui/renderers/skillRenderers.tsx +479 -0
  302. package/src/ui/renderers/styleRenderers.tsx +813 -0
  303. package/src/ui/screens/AliasScreen.tsx +1558 -0
  304. package/src/ui/screens/CliToolsScreen.tsx +367 -0
  305. package/src/ui/screens/EnvVarsScreen.tsx +221 -0
  306. package/src/ui/screens/GitignoreScreen.tsx +562 -0
  307. package/src/ui/screens/McpRegistryScreen.tsx +366 -0
  308. package/src/ui/screens/McpScreen.tsx +256 -0
  309. package/src/ui/screens/ModelsScreen.tsx +542 -0
  310. package/src/ui/screens/PluginsScreen.tsx +1423 -0
  311. package/src/ui/screens/ProfilesScreen.tsx +438 -0
  312. package/src/ui/screens/SkillsScreen.tsx +853 -0
  313. package/src/ui/screens/StylesScreen.tsx +1064 -0
  314. package/src/ui/screens/index.ts +11 -0
  315. package/src/ui/state/AppContext.tsx +233 -0
  316. package/src/ui/state/DimensionsContext.tsx +154 -0
  317. package/src/ui/state/reducer.ts +810 -0
  318. package/src/ui/state/types.ts +503 -0
  319. package/src/ui/theme-mode.ts +440 -0
  320. package/src/ui/theme-resolve.ts +147 -0
  321. package/src/ui/theme.ts +175 -0
  322. package/src/utils/clipboard.ts +58 -0
  323. package/src/utils/command-utils.ts +149 -0
  324. package/src/utils/config-dir.ts +45 -0
  325. package/src/utils/file-locking.ts +146 -0
  326. package/src/utils/fuzzy-search.ts +138 -0
  327. package/src/utils/go-module.ts +36 -0
  328. package/src/utils/open-file.ts +84 -0
  329. package/src/utils/run.ts +67 -0
  330. package/src/utils/string-utils.ts +88 -0
@@ -0,0 +1,969 @@
1
+ /**
2
+ * Per-subagent model routing — the pure core.
3
+ *
4
+ * Claude Code has no per-agent model or effort knob that a project can set. Agent frontmatter
5
+ * is static, settings has one global `model`/`effortLevel`, and `CLAUDE_CODE_SUBAGENT_MODEL`
6
+ * is one model for every subagent. The one dynamic seam is a `PreToolUse` hook, which may
7
+ * rewrite the `Agent` tool's input.
8
+ *
9
+ * EVERY RULE BELOW IS MEASURED, not inferred. `benches/agent-model-routing/` (AMR-1) ran nine
10
+ * variants over one scenario on the interactive path, 2026-09-09, Claude Code 2.1.263,
11
+ * n=5 per run, 50 sessions, every run 5/5 or 0/5:
12
+ *
13
+ * 1. Injecting `updatedInput.model` DOES decide the spawned subagent's model.
14
+ * `hook-named` 5/5 (subagent on claude-haiku-4-5-20251001) vs `nohook` 0/5 (inherited
15
+ * claude-sonnet-5). One-sided Fisher p = 0.004.
16
+ * 2. `hookEventName` is REQUIRED. The payload without it ran on all five passes and changed
17
+ * nothing — so ALWAYS emit it.
18
+ * 3. `permissionDecision: "allow"` is neither required nor harmful, so it is NOT emitted:
19
+ * it would widen permission for the call in exchange for nothing.
20
+ * 4. The value is SCHEMA-CHECKED. A non-alias string refused the spawn outright on all five
21
+ * passes — the session reports a tool error and gets no subagent at all. Hence
22
+ * `isRoutableModel` below, and hence the hook re-checks the value even though the writer
23
+ * already validated it (see `evaluateAgentHook`).
24
+ * 5. Per-model effort DOES reach a subagent, and ALIAS keys work:
25
+ * `modelSettings: {"opus": {effortLevel: "low"}}` put the opus subagent at `low` while
26
+ * the sonnet main thread stayed `high` (5/5), against 0/5 with no modelSettings.
27
+ * 6. When a full model id and an alias key are BOTH present for one model and disagree, the
28
+ * FULL ID WINS (`effort-conflict` came back `xhigh`, the full-id value, 5/5). This is why
29
+ * `buildSettingsPatch` emits both forms with the same value rather than picking one:
30
+ * whichever the runtime prefers, they agree, and a user whose `/effort` already wrote a
31
+ * full-id entry cannot silently override the project's intent.
32
+ *
33
+ * This module is PURE: no fs, no process, no network. Everything that touches disk lives in
34
+ * `models-manager.ts`, and the hook entry point is `cli/hook.ts`. That split is what lets the
35
+ * hook — which Claude Code spawns on every single Agent call — stay small and fast, and lets
36
+ * every rule above be tested without a Claude session.
37
+ */
38
+
39
+ /** The model aliases Claude Code's Agent tool accepts. Measured: anything else is refused. */
40
+ export const MODEL_ALIASES = ["opus", "sonnet", "haiku", "fable"] as const;
41
+ export type ModelAlias = (typeof MODEL_ALIASES)[number];
42
+
43
+ /** Effort levels the settings vocabulary accepts. `max` is frontmatter-only; we never write it. */
44
+ export const EFFORTS = ["low", "medium", "high", "xhigh"] as const;
45
+ export type Effort = (typeof EFFORTS)[number];
46
+
47
+ /**
48
+ * Grades are named for the ROLE they serve, not for a rank.
49
+ *
50
+ * `critical` is the smartest model, and it is deliberately not the orchestrator's: the work
51
+ * that benefits from the strongest model is architecture, planning and review, while the
52
+ * thread that dispatches them mostly routes and summarises.
53
+ */
54
+ export const GRADES = ["smart", "normal", "cheap"] as const;
55
+ export type Grade = (typeof GRADES)[number];
56
+
57
+ /**
58
+ * Subagents whose model is a third-party external one, reached through claudish rather than
59
+ * through Claude Code's own aliases.
60
+ *
61
+ * Named for what they are to this tool: someone else's model, helping out. `kangaroo` is
62
+ * `mate3` — it is a joke, and it is load-bearing only in the sense that nobody mistakes it
63
+ * for a Claude alias.
64
+ *
65
+ * These are NOT routable. `MODEL_ALIASES` is the entire set the Agent tool accepts, and
66
+ * AMR-1 measured that anything else refuses the spawn (5/5). A mate therefore behaves like
67
+ * `inherit` at the hook: magus declines to rewrite the call, and whatever claudish or the
68
+ * agent's own frontmatter decides is what runs.
69
+ */
70
+ export const MATES = ["mate1", "mate2", "kangaroo"] as const;
71
+ export type Mate = (typeof MATES)[number];
72
+
73
+ /**
74
+ * An agent's model: an alias, `inherit` to leave the call alone, or a mate.
75
+ *
76
+ * A mate is the second opt-out, and it says something `inherit` does not: `inherit` means the
77
+ * author declined to decide, a mate means the author sent this agent somewhere magus has
78
+ * no say over. Both produce the same hook behaviour and different words on screen.
79
+ */
80
+ export type AgentModel = ModelAlias | "inherit" | Mate;
81
+
82
+ /**
83
+ * What a slot is BOUND to: a real external model, and optionally an effort for it.
84
+ *
85
+ * A slot is a ROLE — "the first external helper" — and a role is not a model. Until this
86
+ * existed the screen had nothing else to print in its Model column and printed the slot name
87
+ * there, so a row read `mate1 mate1 —`: the same word twice, neither of them naming a model.
88
+ *
89
+ * `model` is a BARE CATALOG ID (`grok-4.6`), never an `openrouterId` (`moonshotai/kimi-k3`)
90
+ * and never an Access route (`kc@kimi-k3`). Both of those forms PIN THE PROVIDER, which
91
+ * bypasses claudish's subscription-aware routing — the committed file should record the
92
+ * model's IDENTITY and leave the route to the thing that owns routing. The validator rejects
93
+ * `/` and `@` for exactly that reason, and says so.
94
+ */
95
+ export interface MateBinding {
96
+ /** A bare catalog id. No `@`, no `/` — see above. */
97
+ model: string;
98
+ /**
99
+ * DECLARED, not applied. magus writes it and nothing in magus reads it back:
100
+ * effort reaches a Claude subagent through `settings.modelSettings`, which is keyed by
101
+ * models Claude Code runs, and an external model has no key there. It is here for
102
+ * claudish to read. Every place it is drawn says so — see `effortWord`.
103
+ */
104
+ effort?: Effort;
105
+ }
106
+
107
+ /** The bindings, by slot. A slot may be absent: unbound is legal, and renders as unset. */
108
+ export type MatesConfig = Partial<Record<Mate, MateBinding>>;
109
+ /**
110
+ * The main thread may also ask for a long-context variant.
111
+ *
112
+ * Deliberately NOT widened to `Mate`. magus writes `main.model` straight into
113
+ * `settings.model`, which has to name a model Claude Code itself runs — a mate names one it
114
+ * has never heard of. The validator rejects it and says so; see `validateModelsConfig`.
115
+ */
116
+ export type MainModel = ModelAlias | `${ModelAlias}[1m]`;
117
+
118
+ export interface GradeSpec {
119
+ model: AgentModel;
120
+ /** Applied through `modelSettings`, keyed by this model. See rule 5 above. */
121
+ effort?: Effort;
122
+ }
123
+
124
+ export interface MainSpec {
125
+ model: MainModel;
126
+ effort?: Effort;
127
+ }
128
+
129
+ /** An agent is assigned a grade by name, or overridden with its own model. */
130
+ export type AgentAssignment = Grade | { model: AgentModel; effort?: Effort };
131
+
132
+ export interface ModelsConfig {
133
+ version: 1;
134
+ /** A built-in preset's name, or "custom" once hand-edited. Never load-bearing. */
135
+ preset: string;
136
+ main: MainSpec;
137
+ grades: Record<Grade, GradeSpec>;
138
+ agents: Record<string, AgentAssignment>;
139
+ /** Grade for an agent with no assignment. */
140
+ fallback: Grade;
141
+ /**
142
+ * Which external model each slot names. Optional in full: every config written before
143
+ * this existed stays valid, and an unbound slot renders as unset rather than erroring.
144
+ */
145
+ mates?: MatesConfig;
146
+ /**
147
+ * The effort every slot runs at unless it names its own.
148
+ *
149
+ * A PRESET DECISION, like the grades above it. A preset cannot bind a slot to a model —
150
+ * it ships in this repo and a catalogue id would go stale — but it can say how hard the
151
+ * external work should try, and that is the half of the answer that does not depend on
152
+ * which model serves it.
153
+ *
154
+ * A slot's own `mates[slot].effort` wins where it is set, so a project can raise or lower
155
+ * one slot without leaving the preset. `mateEffort()` is the single place that resolution
156
+ * happens.
157
+ */
158
+ mateEffort?: Effort;
159
+ }
160
+
161
+ export interface ValidationError {
162
+ path: string;
163
+ message: string;
164
+ }
165
+
166
+ const isRec = (v: unknown): v is Record<string, unknown> =>
167
+ typeof v === "object" && v !== null && !Array.isArray(v);
168
+
169
+ export function isModelAlias(v: unknown): v is ModelAlias {
170
+ return (
171
+ typeof v === "string" && (MODEL_ALIASES as readonly string[]).includes(v)
172
+ );
173
+ }
174
+
175
+ export function isEffort(v: unknown): v is Effort {
176
+ return typeof v === "string" && (EFFORTS as readonly string[]).includes(v);
177
+ }
178
+
179
+ export function isGrade(v: unknown): v is Grade {
180
+ return typeof v === "string" && (GRADES as readonly string[]).includes(v);
181
+ }
182
+
183
+ /**
184
+ * Is this one of the three external-model slots?
185
+ *
186
+ * Exact match only, and no prefix rule: `mate1` is a slot, `mate10` is a typo, and near-
187
+ * matching one into the other would route an agent somewhere its author never named.
188
+ */
189
+ export function isMate(v: unknown): v is Mate {
190
+ return typeof v === "string" && (MATES as readonly string[]).includes(v);
191
+ }
192
+
193
+ /**
194
+ * The values that own no `modelSettings` KEY: `inherit`, and every mate.
195
+ *
196
+ * Effort reaches a subagent through `modelSettings`, which is keyed BY MODEL (AMR-1 rule 5).
197
+ * `inherit` names none by construction; a mate names one Claude Code has never heard of, so
198
+ * a key under it could not select anything.
199
+ *
200
+ * This is now used by exactly ONE rule — the one-model-one-effort conflict pass — and it
201
+ * deliberately no longer gates VALIDATION. Those two questions looked identical and are not:
202
+ *
203
+ * - "may this carry an effort at all?" `inherit` no, a mate YES. A mate's effort is a
204
+ * per-call value for claudish, not a settings key, so it is a legitimate thing to write.
205
+ * - "can this collide with another effort over a shared key?" neither, because neither
206
+ * has a key to collide over. Two mates bound to the SAME external model may still carry
207
+ * different efforts, and that is not a conflict for the same reason.
208
+ *
209
+ * Collapsing them back into one predicate is what made an effort on a mate a validation
210
+ * error in the first place.
211
+ */
212
+ function namesNoKeyableModel(model: unknown): boolean {
213
+ return model === "inherit" || isMate(model);
214
+ }
215
+
216
+ /**
217
+ * Why a bare catalog id, and not the two forms that also identify a model.
218
+ *
219
+ * Both rejected forms name a ROUTE rather than an identity, and a committed file that pins a
220
+ * route makes every reader of it use that route — including a reader whose subscription
221
+ * would have served the same model directly. Routing is claudish's to decide; this file
222
+ * records WHICH MODEL, and nothing about how to reach it.
223
+ */
224
+ const ROUTE_PINNED =
225
+ "pins the provider and bypasses claudish's subscription-aware routing — store the model's identity, not a route to it";
226
+
227
+ /** Shape-check one `mates` entry. Returns the errors, which may be empty. */
228
+ function validateMateBinding(slot: string, value: unknown): ValidationError[] {
229
+ const out: ValidationError[] = [];
230
+ const at = `mates.${slot}`;
231
+ if (!isRec(value)) {
232
+ return [{ path: at, message: "must be an object with a `model`" }];
233
+ }
234
+ const model = value.model;
235
+ if (typeof model !== "string" || model.length === 0) {
236
+ out.push({
237
+ path: `${at}.model`,
238
+ message:
239
+ "must be a non-empty string naming a model in claudish's catalogue",
240
+ });
241
+ } else if (model.includes("/")) {
242
+ out.push({
243
+ path: `${at}.model`,
244
+ message: `${JSON.stringify(model)} looks like an openrouterId. A provider-prefixed id ${ROUTE_PINNED}. Use the bare catalogue id — \`${model.slice(model.lastIndexOf("/") + 1)}\``,
245
+ });
246
+ } else if (model.includes("@")) {
247
+ out.push({
248
+ path: `${at}.model`,
249
+ message: `${JSON.stringify(model)} looks like an Access route. A \`provider@model\` selector ${ROUTE_PINNED}. Use the bare catalogue id — \`${model.slice(model.indexOf("@") + 1)}\``,
250
+ });
251
+ }
252
+ if (value.effort !== undefined && !isEffort(value.effort)) {
253
+ out.push({
254
+ path: `${at}.effort`,
255
+ message: `must be one of ${EFFORTS.join(", ")}`,
256
+ });
257
+ }
258
+ return out;
259
+ }
260
+
261
+ /**
262
+ * Is this a value the Agent tool will accept as `model`?
263
+ *
264
+ * Measured (AMR-1 `bogus-model`, 5/5): a non-alias refuses the spawn, the session reports a
265
+ * tool error, and the agent cannot work around it. So this is checked in THREE places on
266
+ * purpose — at write time, at install time, and again in the hook — because the cost of a bad
267
+ * value is not a bad model, it is every subagent spawn failing for everyone who pulled the
268
+ * config.
269
+ *
270
+ * Mates are excluded DELIBERATELY, and widening this to admit them would be the one change
271
+ * that turns the whole feature into an outage: a mate is a name claudish understands and the
272
+ * Agent tool does not, so injecting one is exactly the `bogus-model` case measured above.
273
+ */
274
+ export function isRoutableModel(v: unknown): v is ModelAlias {
275
+ return isModelAlias(v);
276
+ }
277
+
278
+ /** Strip a `[1m]` (or any bracketed) suffix to get the underlying alias. */
279
+ export function baseAlias(model: string): string {
280
+ const i = model.indexOf("[");
281
+ return i === -1 ? model : model.slice(0, i);
282
+ }
283
+
284
+ /**
285
+ * Validate a parsed `models.json`. Returns [] when `raw` is a usable ModelsConfig.
286
+ *
287
+ * Hand-rolled rather than schema-library-driven, matching `manifest.ts validateManifest`:
288
+ * magus ships no validation dependency, and the errors here are meant to be read by a
289
+ * person fixing a committed file.
290
+ *
291
+ * A MATE IS VALID WHEREVER AN AGENT MODEL IS, whether or not the multimodel plugin is
292
+ * installed on this machine. Validity is a property of the file, not of the machine reading
293
+ * it: a `models.json` is committed and shared, so gating `mate1` on a local install would
294
+ * make the same file valid on one teammate's laptop and invalid on the next one's — and the
295
+ * failure would land on whoever had not installed the plugin yet, over a routing decision
296
+ * they did not make. Whether the slots are DRAWN is a UI question, answered in the renderers.
297
+ */
298
+ export function validateModelsConfig(raw: unknown): ValidationError[] {
299
+ const errors: ValidationError[] = [];
300
+ const err = (path: string, message: string) => errors.push({ path, message });
301
+
302
+ if (!isRec(raw)) return [{ path: "", message: "must be a JSON object" }];
303
+
304
+ if (raw.version !== 1) {
305
+ err("version", `must be 1, got ${JSON.stringify(raw.version)}`);
306
+ // Everything below assumes v1's shape; reading on would produce noise, not findings.
307
+ return errors;
308
+ }
309
+
310
+ if (typeof raw.preset !== "string" || raw.preset.length === 0) {
311
+ err("preset", 'must be a non-empty string (a built-in name, or "custom")');
312
+ }
313
+
314
+ // ── main ────────────────────────────────────────────────────────────────────────────
315
+ if (!isRec(raw.main)) {
316
+ err("main", "must be an object with a `model`");
317
+ } else {
318
+ const m = raw.main.model;
319
+ if (typeof m !== "string") {
320
+ err("main.model", "must be a string");
321
+ } else if (m === "inherit") {
322
+ err(
323
+ "main.model",
324
+ "`inherit` is for agents only — the main thread needs a real model",
325
+ );
326
+ } else if (isMate(m)) {
327
+ // The main thread is the one seat a mate can never take. Its model is written to
328
+ // `settings.model`, which Claude Code reads to start the session itself — a name
329
+ // only claudish understands would leave the session with no model at all, and
330
+ // unlike a subagent there is nothing left to fall back to.
331
+ err(
332
+ "main.model",
333
+ `\`${m}\` is for subagents only — magus writes main.model into settings.model, so it has to name a model Claude Code runs (${MODEL_ALIASES.join(", ")}). A mate names an external model reached through claudish, which the main thread cannot use`,
334
+ );
335
+ } else if (!isModelAlias(baseAlias(m))) {
336
+ err(
337
+ "main.model",
338
+ `must be one of ${MODEL_ALIASES.join(", ")} (optionally with a [1m] suffix), got ${JSON.stringify(m)}. Full model ids are rejected: they go stale, and an alias always names the current model`,
339
+ );
340
+ } else if (m !== baseAlias(m) && !/^[a-z]+\[1m\]$/.test(m)) {
341
+ err(
342
+ "main.model",
343
+ `only a [1m] suffix is understood, got ${JSON.stringify(m)}`,
344
+ );
345
+ }
346
+ if (raw.main.effort !== undefined && !isEffort(raw.main.effort)) {
347
+ err("main.effort", `must be one of ${EFFORTS.join(", ")}`);
348
+ }
349
+ }
350
+
351
+ // ── grades ──────────────────────────────────────────────────────────────────────────
352
+ if (!isRec(raw.grades)) {
353
+ err("grades", `must be an object with the keys ${GRADES.join(", ")}`);
354
+ } else {
355
+ for (const g of GRADES) {
356
+ const spec = raw.grades[g];
357
+ if (!isRec(spec)) {
358
+ err(`grades.${g}`, "must be an object with a `model`");
359
+ continue;
360
+ }
361
+ if (
362
+ spec.model !== "inherit" &&
363
+ !isMate(spec.model) &&
364
+ !isModelAlias(spec.model)
365
+ ) {
366
+ err(
367
+ `grades.${g}.model`,
368
+ `must be one of ${MODEL_ALIASES.join(", ")}, "inherit", or a mate (${MATES.join(", ")}), got ${JSON.stringify(spec.model)}`,
369
+ );
370
+ }
371
+ // `inherit` ONLY. An effort on a MATE is valid — declared for claudish rather
372
+ // than written to modelSettings — so `namesNoKeyableModel` is deliberately not
373
+ // used here; its comment explains why the two questions are not the same one.
374
+ if (spec.model === "inherit" && spec.effort !== undefined) {
375
+ err(
376
+ `grades.${g}.effort`,
377
+ "cannot set effort on `inherit` — effort is applied per model through modelSettings, and inherit names no model",
378
+ );
379
+ }
380
+ if (spec.effort !== undefined && !isEffort(spec.effort)) {
381
+ err(`grades.${g}.effort`, `must be one of ${EFFORTS.join(", ")}`);
382
+ }
383
+ }
384
+ for (const key of Object.keys(raw.grades)) {
385
+ if (!isGrade(key))
386
+ err(
387
+ `grades.${key}`,
388
+ `unknown grade — expected one of ${GRADES.join(", ")}`,
389
+ );
390
+ }
391
+ }
392
+
393
+ // ── fallback ────────────────────────────────────────────────────────────────────────
394
+ // Named `fallback`, not `default`: it answers a different question from the tiers —
395
+ // not "how much model", but "what an agent gets when nothing names it at all".
396
+ if (!isGrade(raw.fallback)) {
397
+ err("fallback", `must name a tier: ${GRADES.join(", ")}`);
398
+ }
399
+
400
+ // ── agents ──────────────────────────────────────────────────────────────────────────
401
+ if (raw.agents !== undefined && !isRec(raw.agents)) {
402
+ err(
403
+ "agents",
404
+ "must be an object mapping subagent_type to a grade name or {model}",
405
+ );
406
+ } else if (isRec(raw.agents)) {
407
+ for (const [name, value] of Object.entries(raw.agents)) {
408
+ if (typeof value === "string") {
409
+ if (!isGrade(value)) {
410
+ err(
411
+ `agents.${name}`,
412
+ `must name a grade (${GRADES.join(", ")}) or be an object with a model`,
413
+ );
414
+ }
415
+ continue;
416
+ }
417
+ if (!isRec(value)) {
418
+ err(
419
+ `agents.${name}`,
420
+ "must be a grade name or an object with a `model`",
421
+ );
422
+ continue;
423
+ }
424
+ if (
425
+ value.model !== "inherit" &&
426
+ !isMate(value.model) &&
427
+ !isModelAlias(value.model)
428
+ ) {
429
+ err(
430
+ `agents.${name}.model`,
431
+ `must be one of ${MODEL_ALIASES.join(", ")}, "inherit", or a mate (${MATES.join(", ")}), got ${JSON.stringify(value.model)}`,
432
+ );
433
+ }
434
+ if (value.effort !== undefined && !isEffort(value.effort)) {
435
+ err(`agents.${name}.effort`, `must be one of ${EFFORTS.join(", ")}`);
436
+ }
437
+ // `inherit` ONLY — see the identical rule on grades above.
438
+ if (value.model === "inherit" && value.effort !== undefined) {
439
+ err(
440
+ `agents.${name}.effort`,
441
+ "cannot set effort on `inherit` — effort is applied per model, and inherit names no model",
442
+ );
443
+ }
444
+ }
445
+ }
446
+
447
+ // ── mates ───────────────────────────────────────────────────────────────────────────
448
+ //
449
+ // VALIDATION IS OFFLINE AND CONSULTS NO CATALOGUE. Shape only — that `grok-4.6` is a
450
+ // plausible bare id, never that it exists.
451
+ //
452
+ // A `models.json` is committed and shared, so its validity has to be a property of the
453
+ // FILE. A validator that reached the network would make the same file valid on a
454
+ // connected laptop and invalid on a plane, and the failure would land on whoever happened
455
+ // to open it offline, over a routing decision they did not make. Whether a bound id is
456
+ // still in the live catalogue is a real question, and it is answered separately, as an
457
+ // ADVISORY note — `services/mate-catalog.ts`, which never blocks anything.
458
+ if (raw.mates !== undefined && !isRec(raw.mates)) {
459
+ err("mates", `must be an object keyed by ${MATES.join(", ")}`);
460
+ } else if (isRec(raw.mates)) {
461
+ for (const [slot, value] of Object.entries(raw.mates)) {
462
+ if (!isMate(slot)) {
463
+ // No near-matching: `mate10` is a typo, not `mate1`, and quietly binding one
464
+ // to the other would send an agent to a model its author never named.
465
+ err(
466
+ `mates.${slot}`,
467
+ `unknown slot — expected one of ${MATES.join(", ")}`,
468
+ );
469
+ continue;
470
+ }
471
+ for (const e of validateMateBinding(slot, value)) errors.push(e);
472
+ }
473
+ }
474
+
475
+ // ── one model, one effort ───────────────────────────────────────────────────────────
476
+ // `modelSettings` is keyed by MODEL, so two entries naming the same model cannot carry
477
+ // different efforts — one would silently lose. The main thread counts: `main.effort` IS
478
+ // the effort of `main.model`. Reported as a conflict rather than resolved, because there
479
+ // is no defensible way to pick a winner on the author's behalf.
480
+ if (errors.length === 0) {
481
+ const cfg = raw as unknown as ModelsConfig;
482
+ const claims = new Map<string, { effort: Effort; at: string }>();
483
+ const claim = (model: string, effort: Effort | undefined, at: string) => {
484
+ // Mates are skipped with `inherit`, and by the same predicate: neither owns a
485
+ // `modelSettings` key, so neither can collide with anything over one. Two seats
486
+ // on `mate1` are not a conflict — they are two agents sent to claudish, which is
487
+ // the normal way to use a slot.
488
+ //
489
+ // This exemption now carries real weight. Mates DO carry effort (the rule was
490
+ // reversed), so without it two agents on `mate1` at different efforts would be
491
+ // reported as a conflict — and it is not one. Effort on a mate is a per-call
492
+ // value handed to claudish, not a settings key, so two mates bound to the very
493
+ // same external model may still legitimately declare different efforts.
494
+ if (effort === undefined || namesNoKeyableModel(model)) return;
495
+ const key = baseAlias(model);
496
+ const prior = claims.get(key);
497
+ if (prior && prior.effort !== effort) {
498
+ err(
499
+ at,
500
+ `${key} is already set to effort "${prior.effort}" by ${prior.at}. Effort is applied per model, so one model cannot carry two efforts — give them different models, or use the same effort`,
501
+ );
502
+ return;
503
+ }
504
+ if (!prior) claims.set(key, { effort, at });
505
+ };
506
+ claim(cfg.main.model, cfg.main.effort, "main");
507
+ for (const g of GRADES)
508
+ claim(cfg.grades[g].model, cfg.grades[g].effort, `grades.${g}`);
509
+ for (const [name, value] of Object.entries(cfg.agents ?? {})) {
510
+ if (typeof value !== "string")
511
+ claim(value.model, value.effort, `agents.${name}`);
512
+ }
513
+ }
514
+
515
+ return errors;
516
+ }
517
+
518
+ // ─── Reading a binding ────────────────────────────────────────────────────────────────────
519
+
520
+ /**
521
+ * What the Model column shows for an unbound slot.
522
+ *
523
+ * NOT the slot name repeated, and not a blank. The slot name in a Model column is what this
524
+ * whole change exists to remove — it said `mate1 mate1 —` and neither word named a model. A
525
+ * blank would read as the `—` that means "nothing was decided", which is the opposite: the
526
+ * author decided to route here and has not yet said where.
527
+ */
528
+ export const UNBOUND_MATE = "unset";
529
+
530
+ /** The catalogue id a slot is bound to, or null when it is unbound. */
531
+ export function boundModel(
532
+ config: Pick<ModelsConfig, "mates">,
533
+ slot: Mate,
534
+ ): string | null {
535
+ return config.mates?.[slot]?.model ?? null;
536
+ }
537
+
538
+ /**
539
+ * The effort a slot runs at: its own if it names one, otherwise the preset's.
540
+ *
541
+ * Two rungs and no third. The slot is the specific answer and wins; the preset is the
542
+ * general one and covers every slot that did not bother. Resolved here rather than at each
543
+ * call site, so the tier table, the agent table and the CLI cannot disagree about what a
544
+ * slot with no effort of its own is actually running at.
545
+ */
546
+ export function mateEffort(
547
+ config: Pick<ModelsConfig, "mates" | "mateEffort">,
548
+ slot: Mate,
549
+ ): Effort | undefined {
550
+ return config.mates?.[slot]?.effort ?? config.mateEffort;
551
+ }
552
+
553
+ /**
554
+ * The text that names a routing value: a bound slot resolves to its model, everything else
555
+ * is already a model name.
556
+ *
557
+ * One function, because the Model column, its width maths and the CLI all have to print the
558
+ * SAME string. The renderers measure a column from this and then draw from it; the defect
559
+ * `modelWidth` exists to prevent — a column measured from a different string than the one
560
+ * rendered — is exactly what two copies of this rule would reintroduce.
561
+ */
562
+ export function modelLabel(
563
+ config: Pick<ModelsConfig, "mates">,
564
+ model: string,
565
+ ): string {
566
+ if (!isMate(model)) return model;
567
+ return boundModel(config, model) ?? UNBOUND_MATE;
568
+ }
569
+
570
+ /**
571
+ * A config as a canonical string, for comparing two of them.
572
+ *
573
+ * Key order is normalised because the two sides come from different places: a built-in preset
574
+ * is a TypeScript literal and a project's config is parsed from a file, so a plain
575
+ * `JSON.stringify` comparison would call two identical configs different whenever the file
576
+ * happened to list `fallback` before `agents`.
577
+ */
578
+ function canonical(value: unknown): string {
579
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
580
+ if (value && typeof value === "object") {
581
+ const entries = Object.entries(value as Record<string, unknown>)
582
+ // `undefined` members are ABSENT, not present-and-empty: `{effort: undefined}` and
583
+ // `{}` describe the same routing, and a JSON round-trip turns the first into the
584
+ // second — so a literal compared against a parsed file would differ over nothing.
585
+ .filter(([, v]) => v !== undefined)
586
+ .sort(([a], [b]) => a.localeCompare(b));
587
+ return `{${entries.map(([k, v]) => `${k}:${canonical(v)}`).join(",")}}`;
588
+ }
589
+ return JSON.stringify(value) ?? "null";
590
+ }
591
+
592
+ /**
593
+ * Do these two configs describe the same routing?
594
+ *
595
+ * Exists because "is the project running a built-in" cannot be answered by NAME. `magus
596
+ * models use opus-lead` writes `preset: "opus-lead"`, and every hand edit afterwards — a
597
+ * `mates` block, a rebound agent — leaves that name untouched. A name check therefore reports
598
+ * a diverged config as a built-in, which is how the one config on the machine that can name a
599
+ * mate became invisible to the TUI.
600
+ */
601
+ export function sameRouting(a: ModelsConfig, b: ModelsConfig): boolean {
602
+ return canonical(a) === canonical(b);
603
+ }
604
+
605
+ /** Every slot a config actually uses — assigned to a grade or to an agent. */
606
+ export function matesInUse(config: ModelsConfig): Mate[] {
607
+ const seen = new Set<string>();
608
+ for (const grade of GRADES) seen.add(config.grades[grade].model);
609
+ for (const assignment of Object.values(config.agents ?? {})) {
610
+ if (typeof assignment !== "string") seen.add(assignment.model);
611
+ }
612
+ return MATES.filter((mate) => seen.has(mate));
613
+ }
614
+
615
+ export interface Routing {
616
+ model: AgentModel;
617
+ effort?: Effort;
618
+ /** Where the answer came from — surfaced by `models status` and the hook's log. */
619
+ source: "agent-override" | "agent-grade" | "default-grade";
620
+ grade?: Grade;
621
+ }
622
+
623
+ /**
624
+ * Which model and effort should serve `subagentType`?
625
+ *
626
+ * An unknown agent falls to `fallback`, which is why a new plugin agent routes sensibly on the
627
+ * day it appears rather than silently inheriting the orchestrator's model.
628
+ */
629
+ export function resolveAgentRouting(
630
+ config: ModelsConfig,
631
+ subagentType: string | undefined,
632
+ ): Routing {
633
+ const assignment =
634
+ subagentType === undefined ? undefined : config.agents?.[subagentType];
635
+
636
+ if (assignment !== undefined && typeof assignment !== "string") {
637
+ const out: Routing = { model: assignment.model, source: "agent-override" };
638
+ if (assignment.effort !== undefined) out.effort = assignment.effort;
639
+ return out;
640
+ }
641
+ const grade: Grade =
642
+ typeof assignment === "string" ? assignment : config.fallback;
643
+ const spec = config.grades[grade];
644
+ const out: Routing = {
645
+ model: spec.model,
646
+ source: typeof assignment === "string" ? "agent-grade" : "default-grade",
647
+ grade,
648
+ };
649
+ if (spec.effort !== undefined) out.effort = spec.effort;
650
+ return out;
651
+ }
652
+
653
+ export interface AgentHookPayload {
654
+ hook_event_name?: string;
655
+ tool_name?: string;
656
+ tool_input?: Record<string, unknown>;
657
+ cwd?: string;
658
+ }
659
+
660
+ export type AgentHookDecision =
661
+ | {
662
+ kind: "passthrough";
663
+ reason:
664
+ | "not-agent"
665
+ | "no-config"
666
+ | "invalid-config"
667
+ | "explicit-model"
668
+ | "inherit"
669
+ | "mate"
670
+ | "unroutable-model";
671
+ }
672
+ | {
673
+ kind: "update";
674
+ model: ModelAlias;
675
+ output: {
676
+ hookSpecificOutput: {
677
+ hookEventName: "PreToolUse";
678
+ updatedInput: Record<string, unknown>;
679
+ };
680
+ };
681
+ };
682
+
683
+ /**
684
+ * Decide what the hook should print. Pure — `cli/hook.ts` supplies the payload and the config.
685
+ *
686
+ * FAIL OPEN, ALWAYS. Every branch that is not a confident rewrite is a passthrough, because
687
+ * this runs on every Agent call in every session on the machine. A hook that blocks or errors
688
+ * takes the user's subagents with it, and the worst outcome available is a config typo that
689
+ * makes delegation stop working everywhere with no clue why.
690
+ *
691
+ * `hookEventName` is emitted because AMR-1 measured it as required (the payload without it
692
+ * fired and was ignored, 5/5). `permissionDecision` is NOT emitted: it was measured as
693
+ * neither required nor harmful, so emitting it would widen permission for nothing.
694
+ */
695
+ export function evaluateAgentHook(
696
+ payload: AgentHookPayload,
697
+ config: ModelsConfig | null,
698
+ ): AgentHookDecision {
699
+ if (payload.tool_name !== "Agent")
700
+ return { kind: "passthrough", reason: "not-agent" };
701
+ if (config === null) return { kind: "passthrough", reason: "no-config" };
702
+
703
+ const toolInput = payload.tool_input ?? {};
704
+
705
+ // A model the caller set wins by Claude Code's own precedence (per-invocation > frontmatter
706
+ // > env > session). Overriding it would silently discard a deliberate choice.
707
+ if (typeof toolInput.model === "string" && toolInput.model.length > 0) {
708
+ return { kind: "passthrough", reason: "explicit-model" };
709
+ }
710
+
711
+ const subagentType =
712
+ typeof toolInput.subagent_type === "string"
713
+ ? toolInput.subagent_type
714
+ : undefined;
715
+ const routing = resolveAgentRouting(config, subagentType);
716
+
717
+ // `inherit` is the opt-out: leave the call exactly as it was, so the agent's own
718
+ // frontmatter (or the session model) decides.
719
+ if (routing.model === "inherit")
720
+ return { kind: "passthrough", reason: "inherit" };
721
+
722
+ // A mate's model is claudish's to choose, not ours. Same passthrough as `inherit`, kept as
723
+ // its own reason so the hook log distinguishes "the author opted out" from "the author sent
724
+ // this one outside".
725
+ //
726
+ // BEFORE the guard below, not after: a mate would fail `isRoutableModel` too, and reporting
727
+ // it as `unroutable-model` would read as a broken config — the one state the log exists to
728
+ // make obvious — when it is the config working exactly as written.
729
+ if (isMate(routing.model)) return { kind: "passthrough", reason: "mate" };
730
+
731
+ // Belt and braces over the writer's validation. A hand-edited profile or an older magus
732
+ // can put an unroutable value here, and MEASURED (AMR-1 `bogus-model`) that does not
733
+ // degrade — it refuses the spawn outright. Passing through costs the routing; injecting
734
+ // costs the user every subagent.
735
+ if (!isRoutableModel(routing.model))
736
+ return { kind: "passthrough", reason: "unroutable-model" };
737
+
738
+ return {
739
+ kind: "update",
740
+ model: routing.model,
741
+ output: {
742
+ hookSpecificOutput: {
743
+ hookEventName: "PreToolUse",
744
+ // updatedInput REPLACES the tool input wholesale, so it must carry everything
745
+ // the caller sent, not just the field being changed.
746
+ updatedInput: { ...toolInput, model: routing.model },
747
+ },
748
+ },
749
+ };
750
+ }
751
+
752
+ export interface SettingsPatch {
753
+ /** Keys to set on the settings object. */
754
+ set: Record<string, unknown>;
755
+ /** Top-level keys to delete (used by `models off`). */
756
+ unset: string[];
757
+ }
758
+
759
+ /**
760
+ * The settings a config implies: the main thread's model and effort, plus per-model effort for
761
+ * every distinct model any grade or agent names.
762
+ *
763
+ * `resolveFullId` maps an alias to the full model id this machine currently runs (resolved
764
+ * LIVE by the caller — never a table in this repo, which would go stale). BOTH key forms are
765
+ * written with the same value, which is the direct consequence of AMR-1's `effort-conflict`
766
+ * run: when both forms are present and disagree, the full id wins. Writing both means they
767
+ * cannot disagree, so it does not matter which the runtime prefers, and a full-id entry a user
768
+ * left behind with `/effort` cannot quietly override the project's intent.
769
+ */
770
+ export function buildSettingsPatch(
771
+ config: ModelsConfig,
772
+ resolveFullId: (alias: ModelAlias) => string | null,
773
+ ): SettingsPatch {
774
+ const set: Record<string, unknown> = {};
775
+ set.model = config.main.model;
776
+ if (config.main.effort !== undefined) set.effortLevel = config.main.effort;
777
+
778
+ const modelSettings: Record<string, { effortLevel: Effort }> = {};
779
+ const add = (model: string, effort: Effort | undefined) => {
780
+ if (effort === undefined || model === "inherit") return;
781
+ const alias = baseAlias(model);
782
+ // The `isModelAlias` gate is what keeps a MATE out of `modelSettings`, and it is the
783
+ // only thing that does. It is now LOAD-BEARING rather than belt-and-braces: an effort
784
+ // on a mate used to be a validation error, so nothing could reach here carrying one,
785
+ // and today it is valid and ordinary. `modelSettings` is keyed by models Claude Code
786
+ // RUNS — a `mate1` key selects nothing, and a `grok-4.6` key selects nothing either.
787
+ //
788
+ // A mate's effort is written to the `mates` block and read by claudish. It never
789
+ // reaches settings, from any path: `add()` is the only writer and this line is the
790
+ // only gate, so the test that pins it asserts on the whole map (no slot, no external
791
+ // id, no `undefined` key) rather than on this branch.
792
+ if (!isModelAlias(alias)) return;
793
+ modelSettings[alias] = { effortLevel: effort };
794
+ const full = resolveFullId(alias);
795
+ // A resolver that cannot answer leaves the alias key alone rather than inventing an id.
796
+ if (full) modelSettings[full] = { effortLevel: effort };
797
+ };
798
+ add(config.main.model, config.main.effort);
799
+ for (const g of GRADES) add(config.grades[g].model, config.grades[g].effort);
800
+ for (const value of Object.values(config.agents ?? {})) {
801
+ if (typeof value !== "string") add(value.model, value.effort);
802
+ }
803
+
804
+ if (Object.keys(modelSettings).length > 0) set.modelSettings = modelSettings;
805
+ return { set, unset: [] };
806
+ }
807
+
808
+ /**
809
+ * The four states routing can be in. Each is a fact the reader can act on.
810
+ *
811
+ * There is no state for "the settings differ from the config". Differing is what a CUSTOM
812
+ * config is, and the list says so with a `Custom` row rather than the header saying it with
813
+ * a warning.
814
+ */
815
+ export type ModelsState = "off" | "on" | "invalid" | "unhooked";
816
+
817
+ export interface ModelsStatus {
818
+ state: ModelsState;
819
+ preset: string | null;
820
+ /** Human-readable differences between what the config asks for and what settings hold. */
821
+ drift: string[];
822
+ warnings: string[];
823
+ }
824
+
825
+ /**
826
+ * What `models status` and `doctor` report.
827
+ *
828
+ * `unhooked` outranks `stale`, deliberately: a config with no hook registered routes NOTHING,
829
+ * so reporting a drifted effort value first would bury the reason nothing is happening.
830
+ */
831
+ export function computeModelsStatus(args: {
832
+ config: ModelsConfig | null;
833
+ errors: ValidationError[];
834
+ hookRegistered: boolean;
835
+ settings: Record<string, unknown>;
836
+ resolveFullId: (alias: ModelAlias) => string | null;
837
+ subagentModelEnv?: string | null;
838
+ }): ModelsStatus {
839
+ const {
840
+ config,
841
+ errors,
842
+ hookRegistered,
843
+ settings,
844
+ resolveFullId,
845
+ subagentModelEnv,
846
+ } = args;
847
+ const warnings: string[] = [];
848
+
849
+ if (subagentModelEnv) {
850
+ warnings.push(
851
+ `CLAUDE_CODE_SUBAGENT_MODEL is set to "${subagentModelEnv}". Per-call routing wins over it, so it now has no effect on routed agents — consider unsetting it.`,
852
+ );
853
+ }
854
+
855
+ if (errors.length > 0) {
856
+ return {
857
+ state: "invalid",
858
+ preset: null,
859
+ drift: errors.map((e) => `${e.path || "(root)"}: ${e.message}`),
860
+ warnings,
861
+ };
862
+ }
863
+ if (config === null)
864
+ return { state: "off", preset: null, drift: [], warnings };
865
+ if (!hookRegistered) {
866
+ return {
867
+ state: "unhooked",
868
+ preset: config.preset,
869
+ drift: ["the agent-model hook is not registered, so nothing is routed"],
870
+ warnings,
871
+ };
872
+ }
873
+
874
+ // A config whose settings differ from a built-in's is CUSTOM, and custom is not a fault.
875
+ //
876
+ // There is no comparison here any more, and no state for the result of one. A project may
877
+ // legitimately run models and efforts no shipped preset names; saying so as a state called
878
+ // `stale`, with a count of the differences, described a thing you have as a thing that is
879
+ // wrong with you — and the count was of MESSAGES rather than of differences, so `1 drift`
880
+ // could mean every setting differed.
881
+ //
882
+ // The list carries the fact instead: a config matching no built-in gets its own `Custom`
883
+ // row holding those settings, with its own tier table and chart. `drift` survives for the
884
+ // two states that ARE faults — a config that does not validate, and one with no hook to
885
+ // run it — where the lines name something the reader has to fix.
886
+ return {
887
+ state: "on",
888
+ preset: config.preset,
889
+ drift: [],
890
+ warnings,
891
+ };
892
+ }
893
+
894
+ /**
895
+ * One human-readable drift line.
896
+ *
897
+ * Three things this deliberately does NOT do, each a defect it replaced:
898
+ *
899
+ * - `JSON.stringify(undefined)` returns `undefined` — the string, not a value — so an
900
+ * absent key printed as `settings has undefined`, leaking a JavaScript value into text
901
+ * a person reads. Absent is now said in words.
902
+ * - `modelSettings` printed as raw JSON was a 200-character wall nobody parses.
903
+ * - Quoting every scalar (`config wants "opus"`) adds noise; a model name is a word.
904
+ */
905
+ function describeDrift(key: string, have: unknown, want: unknown): string {
906
+ // `modelSettings` is DIFFED rather than summarised on both sides.
907
+ //
908
+ // Summarising it was the wrong shape, and hiding the full-id keys to keep the summary
909
+ // short made it actively wrong: the patch writes every model twice (alias + live-resolved
910
+ // full id), and the side that differs is almost always the full id — which is also the
911
+ // side that WINS at runtime (AMR-1 rule 6 above). So the line could print
912
+ // `settings has opus xhigh, config wants opus xhigh` — X differs from X — while the key
913
+ // actually serving subagents at another effort was the one suppressed. That happens with
914
+ // no hand-editing at all: apply a preset, wait for the next model release, and the stored
915
+ // full id no longer matches the resolved one.
916
+ //
917
+ // Diffing cannot produce that line, because a key only appears when its two sides differ.
918
+ if (key === "modelSettings" && isRec(have) && isRec(want)) {
919
+ const changed = modelSettingsDiff(have, want);
920
+ if (changed.length > 0) return `modelSettings: ${changed.join(", ")}`;
921
+ }
922
+ return `${key}: settings ${have === undefined ? "has no value" : `has ${describeValue(have)}`}, config wants ${describeValue(want)}`;
923
+ }
924
+
925
+ /** A value as a person would say it, never as JSON. */
926
+ function describeValue(v: unknown): string {
927
+ if (v === undefined) return "nothing";
928
+ if (typeof v === "string") return v;
929
+ if (v && typeof v === "object") return describeModelSettings(v);
930
+ return JSON.stringify(v);
931
+ }
932
+
933
+ /** The `effortLevel` inside one `modelSettings` entry, or null when there is not one. */
934
+ function effortOf(spec: unknown): string | null {
935
+ if (!spec || typeof spec !== "object") return null;
936
+ const level = (spec as { effortLevel?: unknown }).effortLevel;
937
+ return typeof level === "string" ? level : null;
938
+ }
939
+
940
+ /**
941
+ * Every model whose effort differs between the two maps, as `opus xhigh → high`.
942
+ *
943
+ * Keys are taken from BOTH sides and sorted, so a model present in only one of them still
944
+ * appears — an entry `/effort` left behind in settings is exactly the case worth seeing, and
945
+ * a summary of one side alone can never show it.
946
+ */
947
+ function modelSettingsDiff(
948
+ have: Record<string, unknown>,
949
+ want: Record<string, unknown>,
950
+ ): string[] {
951
+ const models = new Set([...Object.keys(have), ...Object.keys(want)]);
952
+ const out: string[] = [];
953
+ for (const model of [...models].sort()) {
954
+ const h = effortOf(have[model]);
955
+ const w = effortOf(want[model]);
956
+ if (h === w) continue;
957
+ out.push(`${model} ${h ?? "unset"} → ${w ?? "unset"}`);
958
+ }
959
+ return out;
960
+ }
961
+
962
+ /** `modelSettings` as `opus xhigh, fable medium`, for the one-sided case. */
963
+ function describeModelSettings(v: object): string {
964
+ const parts: string[] = [];
965
+ for (const [model, spec] of Object.entries(v as Record<string, unknown>)) {
966
+ parts.push(`${model} ${effortOf(spec) ?? "unset"}`);
967
+ }
968
+ return parts.length ? parts.join(", ") : "nothing";
969
+ }