claudeup 6.3.2 → 6.5.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 (72) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/catalog-notice.test.ts +3 -3
  3. package/src/__tests__/cli-live.test.ts +9 -2
  4. package/src/__tests__/cli-update-view.test.ts +2 -2
  5. package/src/__tests__/footer-hints.test.ts +40 -0
  6. package/src/__tests__/gap-fill-versions.test.ts +24 -24
  7. package/src/__tests__/gitignore-prerun.test.ts +6 -13
  8. package/src/__tests__/hook-import-policy.test.ts +90 -0
  9. package/src/__tests__/hook-process.test.ts +256 -0
  10. package/src/__tests__/hook-registration.test.ts +224 -0
  11. package/src/__tests__/manifest.test.ts +134 -0
  12. package/src/__tests__/marketplace-badge.test.ts +1 -1
  13. package/src/__tests__/marketplaces.test.ts +0 -1
  14. package/src/__tests__/model-visuals.test.tsx +793 -0
  15. package/src/__tests__/models-adapter.test.ts +317 -0
  16. package/src/__tests__/models-cli.test.ts +173 -0
  17. package/src/__tests__/models-core.test.ts +640 -0
  18. package/src/__tests__/models-manager.test.ts +497 -0
  19. package/src/__tests__/models-screen-state.test.ts +259 -0
  20. package/src/__tests__/moved-marketplace.test.ts +7 -8
  21. package/src/__tests__/plugin-contents.test.ts +1 -1
  22. package/src/__tests__/profile-adopt.test.ts +1 -1
  23. package/src/__tests__/profile-materializer.test.ts +48 -2
  24. package/src/__tests__/resolver.test.ts +43 -6
  25. package/src/__tests__/settings-file.test.ts +179 -0
  26. package/src/__tests__/symlink-manager.test.ts +65 -1
  27. package/src/__tests__/tabbar-layout.test.ts +40 -2
  28. package/src/__tests__/theme-adaptive-colors.test.ts +48 -1
  29. package/src/__tests__/version-snapshot.test.ts +4 -4
  30. package/src/cli/doctor.ts +90 -0
  31. package/src/cli/hook.ts +129 -0
  32. package/src/cli/models.ts +214 -0
  33. package/src/cli/router.ts +12 -0
  34. package/src/data/gitignore-defaults.ts +4 -1
  35. package/src/data/gitignore-reasons.ts +0 -4
  36. package/src/data/marketplaces.ts +1 -15
  37. package/src/data/models-presets.ts +270 -0
  38. package/src/data/predefined-profiles.ts +12 -21
  39. package/src/data/settings-catalog.ts +11 -4
  40. package/src/main.tsx +51 -82
  41. package/src/services/hook-registration.ts +218 -0
  42. package/src/services/manifest.ts +84 -0
  43. package/src/services/models-core.ts +628 -0
  44. package/src/services/models-manager.ts +606 -0
  45. package/src/services/plugin-manager.ts +2 -3
  46. package/src/services/profile-materializer.ts +17 -0
  47. package/src/services/resolver.ts +13 -2
  48. package/src/services/settings-file.ts +69 -0
  49. package/src/services/styles-manager.ts +23 -45
  50. package/src/services/symlink-manager.ts +57 -11
  51. package/src/tui.tsx +112 -0
  52. package/src/types/bun.d.ts +21 -0
  53. package/src/types/index.ts +14 -0
  54. package/src/ui/App.tsx +15 -3
  55. package/src/ui/adapters/modelsAdapter.ts +170 -0
  56. package/src/ui/adapters/pluginsAdapter.ts +1 -1
  57. package/src/ui/components/TabBar.tsx +9 -4
  58. package/src/ui/components/layout/FooterHints.tsx +20 -3
  59. package/src/ui/components/layout/ScreenLayout.tsx +87 -7
  60. package/src/ui/components/primitives/MetaText.tsx +27 -1
  61. package/src/ui/renderers/modelRenderers.tsx +1004 -0
  62. package/src/ui/renderers/modelVisuals.tsx +853 -0
  63. package/src/ui/renderers/skillRenderers.tsx +13 -3
  64. package/src/ui/renderers/styleRenderers.tsx +7 -3
  65. package/src/ui/screens/ModelsScreen.tsx +478 -0
  66. package/src/ui/screens/PluginsScreen.tsx +1 -1
  67. package/src/ui/screens/StylesScreen.tsx +8 -13
  68. package/src/ui/screens/index.ts +1 -0
  69. package/src/ui/state/reducer.ts +94 -0
  70. package/src/ui/state/types.ts +65 -2
  71. package/src/ui/theme-mode.ts +116 -0
  72. package/src/ui/theme.ts +26 -0
@@ -0,0 +1,628 @@
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
+ /** An agent's model: an alias, or `inherit` to leave the call alone. */
58
+ export type AgentModel = ModelAlias | "inherit";
59
+ /** The main thread may also ask for a long-context variant. */
60
+ export type MainModel = ModelAlias | `${ModelAlias}[1m]`;
61
+
62
+ export interface GradeSpec {
63
+ model: AgentModel;
64
+ /** Applied through `modelSettings`, keyed by this model. See rule 5 above. */
65
+ effort?: Effort;
66
+ }
67
+
68
+ export interface MainSpec {
69
+ model: MainModel;
70
+ effort?: Effort;
71
+ }
72
+
73
+ /** An agent is assigned a grade by name, or overridden with its own model. */
74
+ export type AgentAssignment = Grade | { model: AgentModel; effort?: Effort };
75
+
76
+ export interface ModelsConfig {
77
+ version: 1;
78
+ /** A built-in preset's name, or "custom" once hand-edited. Never load-bearing. */
79
+ preset: string;
80
+ main: MainSpec;
81
+ grades: Record<Grade, GradeSpec>;
82
+ agents: Record<string, AgentAssignment>;
83
+ /** Grade for an agent with no assignment. */
84
+ fallback: Grade;
85
+ }
86
+
87
+ export interface ValidationError {
88
+ path: string;
89
+ message: string;
90
+ }
91
+
92
+ const isRec = (v: unknown): v is Record<string, unknown> =>
93
+ typeof v === "object" && v !== null && !Array.isArray(v);
94
+
95
+ export function isModelAlias(v: unknown): v is ModelAlias {
96
+ return (
97
+ typeof v === "string" && (MODEL_ALIASES as readonly string[]).includes(v)
98
+ );
99
+ }
100
+
101
+ export function isEffort(v: unknown): v is Effort {
102
+ return typeof v === "string" && (EFFORTS as readonly string[]).includes(v);
103
+ }
104
+
105
+ export function isGrade(v: unknown): v is Grade {
106
+ return typeof v === "string" && (GRADES as readonly string[]).includes(v);
107
+ }
108
+
109
+ /**
110
+ * Is this a value the Agent tool will accept as `model`?
111
+ *
112
+ * Measured (AMR-1 `bogus-model`, 5/5): a non-alias refuses the spawn, the session reports a
113
+ * tool error, and the agent cannot work around it. So this is checked in THREE places on
114
+ * purpose — at write time, at install time, and again in the hook — because the cost of a bad
115
+ * value is not a bad model, it is every subagent spawn failing for everyone who pulled the
116
+ * config.
117
+ */
118
+ export function isRoutableModel(v: unknown): v is ModelAlias {
119
+ return isModelAlias(v);
120
+ }
121
+
122
+ /** Strip a `[1m]` (or any bracketed) suffix to get the underlying alias. */
123
+ export function baseAlias(model: string): string {
124
+ const i = model.indexOf("[");
125
+ return i === -1 ? model : model.slice(0, i);
126
+ }
127
+
128
+ /**
129
+ * Validate a parsed `models.json`. Returns [] when `raw` is a usable ModelsConfig.
130
+ *
131
+ * Hand-rolled rather than schema-library-driven, matching `manifest.ts validateManifest`:
132
+ * claudeup ships no validation dependency, and the errors here are meant to be read by a
133
+ * person fixing a committed file.
134
+ */
135
+ export function validateModelsConfig(raw: unknown): ValidationError[] {
136
+ const errors: ValidationError[] = [];
137
+ const err = (path: string, message: string) => errors.push({ path, message });
138
+
139
+ if (!isRec(raw)) return [{ path: "", message: "must be a JSON object" }];
140
+
141
+ if (raw.version !== 1) {
142
+ err("version", `must be 1, got ${JSON.stringify(raw.version)}`);
143
+ // Everything below assumes v1's shape; reading on would produce noise, not findings.
144
+ return errors;
145
+ }
146
+
147
+ if (typeof raw.preset !== "string" || raw.preset.length === 0) {
148
+ err("preset", 'must be a non-empty string (a built-in name, or "custom")');
149
+ }
150
+
151
+ // ── main ────────────────────────────────────────────────────────────────────────────
152
+ if (!isRec(raw.main)) {
153
+ err("main", "must be an object with a `model`");
154
+ } else {
155
+ const m = raw.main.model;
156
+ if (typeof m !== "string") {
157
+ err("main.model", "must be a string");
158
+ } else if (m === "inherit") {
159
+ err(
160
+ "main.model",
161
+ "`inherit` is for agents only — the main thread needs a real model",
162
+ );
163
+ } else if (!isModelAlias(baseAlias(m))) {
164
+ err(
165
+ "main.model",
166
+ `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`,
167
+ );
168
+ } else if (m !== baseAlias(m) && !/^[a-z]+\[1m\]$/.test(m)) {
169
+ err(
170
+ "main.model",
171
+ `only a [1m] suffix is understood, got ${JSON.stringify(m)}`,
172
+ );
173
+ }
174
+ if (raw.main.effort !== undefined && !isEffort(raw.main.effort)) {
175
+ err("main.effort", `must be one of ${EFFORTS.join(", ")}`);
176
+ }
177
+ }
178
+
179
+ // ── grades ──────────────────────────────────────────────────────────────────────────
180
+ if (!isRec(raw.grades)) {
181
+ err("grades", `must be an object with the keys ${GRADES.join(", ")}`);
182
+ } else {
183
+ for (const g of GRADES) {
184
+ const spec = raw.grades[g];
185
+ if (!isRec(spec)) {
186
+ err(`grades.${g}`, "must be an object with a `model`");
187
+ continue;
188
+ }
189
+ if (spec.model !== "inherit" && !isModelAlias(spec.model)) {
190
+ err(
191
+ `grades.${g}.model`,
192
+ `must be one of ${MODEL_ALIASES.join(", ")} or "inherit", got ${JSON.stringify(spec.model)}`,
193
+ );
194
+ }
195
+ if (spec.model === "inherit" && spec.effort !== undefined) {
196
+ err(
197
+ `grades.${g}.effort`,
198
+ "cannot set effort on `inherit` — effort is applied per model, and inherit names no model",
199
+ );
200
+ }
201
+ if (spec.effort !== undefined && !isEffort(spec.effort)) {
202
+ err(`grades.${g}.effort`, `must be one of ${EFFORTS.join(", ")}`);
203
+ }
204
+ }
205
+ for (const key of Object.keys(raw.grades)) {
206
+ if (!isGrade(key))
207
+ err(
208
+ `grades.${key}`,
209
+ `unknown grade — expected one of ${GRADES.join(", ")}`,
210
+ );
211
+ }
212
+ }
213
+
214
+ // ── fallback ────────────────────────────────────────────────────────────────────────
215
+ // Named `fallback`, not `default`: it answers a different question from the tiers —
216
+ // not "how much model", but "what an agent gets when nothing names it at all".
217
+ if (!isGrade(raw.fallback)) {
218
+ err("fallback", `must name a tier: ${GRADES.join(", ")}`);
219
+ }
220
+
221
+ // ── agents ──────────────────────────────────────────────────────────────────────────
222
+ if (raw.agents !== undefined && !isRec(raw.agents)) {
223
+ err(
224
+ "agents",
225
+ "must be an object mapping subagent_type to a grade name or {model}",
226
+ );
227
+ } else if (isRec(raw.agents)) {
228
+ for (const [name, value] of Object.entries(raw.agents)) {
229
+ if (typeof value === "string") {
230
+ if (!isGrade(value)) {
231
+ err(
232
+ `agents.${name}`,
233
+ `must name a grade (${GRADES.join(", ")}) or be an object with a model`,
234
+ );
235
+ }
236
+ continue;
237
+ }
238
+ if (!isRec(value)) {
239
+ err(
240
+ `agents.${name}`,
241
+ "must be a grade name or an object with a `model`",
242
+ );
243
+ continue;
244
+ }
245
+ if (value.model !== "inherit" && !isModelAlias(value.model)) {
246
+ err(
247
+ `agents.${name}.model`,
248
+ `must be one of ${MODEL_ALIASES.join(", ")} or "inherit", got ${JSON.stringify(value.model)}`,
249
+ );
250
+ }
251
+ if (value.effort !== undefined && !isEffort(value.effort)) {
252
+ err(`agents.${name}.effort`, `must be one of ${EFFORTS.join(", ")}`);
253
+ }
254
+ if (value.model === "inherit" && value.effort !== undefined) {
255
+ err(
256
+ `agents.${name}.effort`,
257
+ "cannot set effort on `inherit` — effort is applied per model",
258
+ );
259
+ }
260
+ }
261
+ }
262
+
263
+ // ── one model, one effort ───────────────────────────────────────────────────────────
264
+ // `modelSettings` is keyed by MODEL, so two entries naming the same model cannot carry
265
+ // different efforts — one would silently lose. The main thread counts: `main.effort` IS
266
+ // the effort of `main.model`. Reported as a conflict rather than resolved, because there
267
+ // is no defensible way to pick a winner on the author's behalf.
268
+ if (errors.length === 0) {
269
+ const cfg = raw as unknown as ModelsConfig;
270
+ const claims = new Map<string, { effort: Effort; at: string }>();
271
+ const claim = (model: string, effort: Effort | undefined, at: string) => {
272
+ if (effort === undefined || model === "inherit") return;
273
+ const key = baseAlias(model);
274
+ const prior = claims.get(key);
275
+ if (prior && prior.effort !== effort) {
276
+ err(
277
+ at,
278
+ `${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`,
279
+ );
280
+ return;
281
+ }
282
+ if (!prior) claims.set(key, { effort, at });
283
+ };
284
+ claim(cfg.main.model, cfg.main.effort, "main");
285
+ for (const g of GRADES)
286
+ claim(cfg.grades[g].model, cfg.grades[g].effort, `grades.${g}`);
287
+ for (const [name, value] of Object.entries(cfg.agents ?? {})) {
288
+ if (typeof value !== "string")
289
+ claim(value.model, value.effort, `agents.${name}`);
290
+ }
291
+ }
292
+
293
+ return errors;
294
+ }
295
+
296
+ export interface Routing {
297
+ model: AgentModel;
298
+ effort?: Effort;
299
+ /** Where the answer came from — surfaced by `models status` and the hook's log. */
300
+ source: "agent-override" | "agent-grade" | "default-grade";
301
+ grade?: Grade;
302
+ }
303
+
304
+ /**
305
+ * Which model and effort should serve `subagentType`?
306
+ *
307
+ * An unknown agent falls to `fallback`, which is why a new plugin agent routes sensibly on the
308
+ * day it appears rather than silently inheriting the orchestrator's model.
309
+ */
310
+ export function resolveAgentRouting(
311
+ config: ModelsConfig,
312
+ subagentType: string | undefined,
313
+ ): Routing {
314
+ const assignment =
315
+ subagentType === undefined ? undefined : config.agents?.[subagentType];
316
+
317
+ if (assignment !== undefined && typeof assignment !== "string") {
318
+ const out: Routing = { model: assignment.model, source: "agent-override" };
319
+ if (assignment.effort !== undefined) out.effort = assignment.effort;
320
+ return out;
321
+ }
322
+ const grade: Grade =
323
+ typeof assignment === "string" ? assignment : config.fallback;
324
+ const spec = config.grades[grade];
325
+ const out: Routing = {
326
+ model: spec.model,
327
+ source: typeof assignment === "string" ? "agent-grade" : "default-grade",
328
+ grade,
329
+ };
330
+ if (spec.effort !== undefined) out.effort = spec.effort;
331
+ return out;
332
+ }
333
+
334
+ export interface AgentHookPayload {
335
+ hook_event_name?: string;
336
+ tool_name?: string;
337
+ tool_input?: Record<string, unknown>;
338
+ cwd?: string;
339
+ }
340
+
341
+ export type AgentHookDecision =
342
+ | {
343
+ kind: "passthrough";
344
+ reason:
345
+ | "not-agent"
346
+ | "no-config"
347
+ | "invalid-config"
348
+ | "explicit-model"
349
+ | "inherit"
350
+ | "unroutable-model";
351
+ }
352
+ | {
353
+ kind: "update";
354
+ model: ModelAlias;
355
+ output: {
356
+ hookSpecificOutput: {
357
+ hookEventName: "PreToolUse";
358
+ updatedInput: Record<string, unknown>;
359
+ };
360
+ };
361
+ };
362
+
363
+ /**
364
+ * Decide what the hook should print. Pure — `cli/hook.ts` supplies the payload and the config.
365
+ *
366
+ * FAIL OPEN, ALWAYS. Every branch that is not a confident rewrite is a passthrough, because
367
+ * this runs on every Agent call in every session on the machine. A hook that blocks or errors
368
+ * takes the user's subagents with it, and the worst outcome available is a config typo that
369
+ * makes delegation stop working everywhere with no clue why.
370
+ *
371
+ * `hookEventName` is emitted because AMR-1 measured it as required (the payload without it
372
+ * fired and was ignored, 5/5). `permissionDecision` is NOT emitted: it was measured as
373
+ * neither required nor harmful, so emitting it would widen permission for nothing.
374
+ */
375
+ export function evaluateAgentHook(
376
+ payload: AgentHookPayload,
377
+ config: ModelsConfig | null,
378
+ ): AgentHookDecision {
379
+ if (payload.tool_name !== "Agent")
380
+ return { kind: "passthrough", reason: "not-agent" };
381
+ if (config === null) return { kind: "passthrough", reason: "no-config" };
382
+
383
+ const toolInput = payload.tool_input ?? {};
384
+
385
+ // A model the caller set wins by Claude Code's own precedence (per-invocation > frontmatter
386
+ // > env > session). Overriding it would silently discard a deliberate choice.
387
+ if (typeof toolInput.model === "string" && toolInput.model.length > 0) {
388
+ return { kind: "passthrough", reason: "explicit-model" };
389
+ }
390
+
391
+ const subagentType =
392
+ typeof toolInput.subagent_type === "string"
393
+ ? toolInput.subagent_type
394
+ : undefined;
395
+ const routing = resolveAgentRouting(config, subagentType);
396
+
397
+ // `inherit` is the opt-out: leave the call exactly as it was, so the agent's own
398
+ // frontmatter (or the session model) decides.
399
+ if (routing.model === "inherit")
400
+ return { kind: "passthrough", reason: "inherit" };
401
+
402
+ // Belt and braces over the writer's validation. A hand-edited profile or an older claudeup
403
+ // can put an unroutable value here, and MEASURED (AMR-1 `bogus-model`) that does not
404
+ // degrade — it refuses the spawn outright. Passing through costs the routing; injecting
405
+ // costs the user every subagent.
406
+ if (!isRoutableModel(routing.model))
407
+ return { kind: "passthrough", reason: "unroutable-model" };
408
+
409
+ return {
410
+ kind: "update",
411
+ model: routing.model,
412
+ output: {
413
+ hookSpecificOutput: {
414
+ hookEventName: "PreToolUse",
415
+ // updatedInput REPLACES the tool input wholesale, so it must carry everything
416
+ // the caller sent, not just the field being changed.
417
+ updatedInput: { ...toolInput, model: routing.model },
418
+ },
419
+ },
420
+ };
421
+ }
422
+
423
+ export interface SettingsPatch {
424
+ /** Keys to set on the settings object. */
425
+ set: Record<string, unknown>;
426
+ /** Top-level keys to delete (used by `models off`). */
427
+ unset: string[];
428
+ }
429
+
430
+ /**
431
+ * The settings a config implies: the main thread's model and effort, plus per-model effort for
432
+ * every distinct model any grade or agent names.
433
+ *
434
+ * `resolveFullId` maps an alias to the full model id this machine currently runs (resolved
435
+ * LIVE by the caller — never a table in this repo, which would go stale). BOTH key forms are
436
+ * written with the same value, which is the direct consequence of AMR-1's `effort-conflict`
437
+ * run: when both forms are present and disagree, the full id wins. Writing both means they
438
+ * cannot disagree, so it does not matter which the runtime prefers, and a full-id entry a user
439
+ * left behind with `/effort` cannot quietly override the project's intent.
440
+ */
441
+ export function buildSettingsPatch(
442
+ config: ModelsConfig,
443
+ resolveFullId: (alias: ModelAlias) => string | null,
444
+ ): SettingsPatch {
445
+ const set: Record<string, unknown> = {};
446
+ set.model = config.main.model;
447
+ if (config.main.effort !== undefined) set.effortLevel = config.main.effort;
448
+
449
+ const modelSettings: Record<string, { effortLevel: Effort }> = {};
450
+ const add = (model: string, effort: Effort | undefined) => {
451
+ if (effort === undefined || model === "inherit") return;
452
+ const alias = baseAlias(model);
453
+ if (!isModelAlias(alias)) return;
454
+ modelSettings[alias] = { effortLevel: effort };
455
+ const full = resolveFullId(alias);
456
+ // A resolver that cannot answer leaves the alias key alone rather than inventing an id.
457
+ if (full) modelSettings[full] = { effortLevel: effort };
458
+ };
459
+ add(config.main.model, config.main.effort);
460
+ for (const g of GRADES) add(config.grades[g].model, config.grades[g].effort);
461
+ for (const value of Object.values(config.agents ?? {})) {
462
+ if (typeof value !== "string") add(value.model, value.effort);
463
+ }
464
+
465
+ if (Object.keys(modelSettings).length > 0) set.modelSettings = modelSettings;
466
+ return { set, unset: [] };
467
+ }
468
+
469
+ export type ModelsState = "off" | "on" | "stale" | "invalid" | "unhooked";
470
+
471
+ export interface ModelsStatus {
472
+ state: ModelsState;
473
+ preset: string | null;
474
+ /** Human-readable differences between what the config asks for and what settings hold. */
475
+ drift: string[];
476
+ warnings: string[];
477
+ }
478
+
479
+ /**
480
+ * What `models status` and `doctor` report.
481
+ *
482
+ * `unhooked` outranks `stale`, deliberately: a config with no hook registered routes NOTHING,
483
+ * so reporting a drifted effort value first would bury the reason nothing is happening.
484
+ */
485
+ export function computeModelsStatus(args: {
486
+ config: ModelsConfig | null;
487
+ errors: ValidationError[];
488
+ hookRegistered: boolean;
489
+ settings: Record<string, unknown>;
490
+ resolveFullId: (alias: ModelAlias) => string | null;
491
+ subagentModelEnv?: string | null;
492
+ }): ModelsStatus {
493
+ const {
494
+ config,
495
+ errors,
496
+ hookRegistered,
497
+ settings,
498
+ resolveFullId,
499
+ subagentModelEnv,
500
+ } = args;
501
+ const warnings: string[] = [];
502
+
503
+ if (subagentModelEnv) {
504
+ warnings.push(
505
+ `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.`,
506
+ );
507
+ }
508
+
509
+ if (errors.length > 0) {
510
+ return {
511
+ state: "invalid",
512
+ preset: null,
513
+ drift: errors.map((e) => `${e.path || "(root)"}: ${e.message}`),
514
+ warnings,
515
+ };
516
+ }
517
+ if (config === null)
518
+ return { state: "off", preset: null, drift: [], warnings };
519
+ if (!hookRegistered) {
520
+ return {
521
+ state: "unhooked",
522
+ preset: config.preset,
523
+ drift: ["the agent-model hook is not registered, so nothing is routed"],
524
+ warnings,
525
+ };
526
+ }
527
+
528
+ const patch = buildSettingsPatch(config, resolveFullId);
529
+ const entries = Object.entries(patch.set);
530
+ const differing = entries.filter(
531
+ ([key, want]) => JSON.stringify(settings[key]) !== JSON.stringify(want),
532
+ );
533
+
534
+ // Nothing written at all is ONE fact, not three. Listing `model`, `effortLevel` and
535
+ // `modelSettings` separately when every one of them is absent reads as three distinct
536
+ // problems and buries the only thing that matters: no settings were ever written here.
537
+ const allMissing =
538
+ differing.length === entries.length &&
539
+ differing.every(([key]) => settings[key] === undefined);
540
+
541
+ const drift = allMissing
542
+ ? ["settings carry none of the config — none of it is in force"]
543
+ : differing.map(([key, want]) => describeDrift(key, settings[key], want));
544
+
545
+ return {
546
+ state: drift.length ? "stale" : "on",
547
+ preset: config.preset,
548
+ drift,
549
+ warnings,
550
+ };
551
+ }
552
+
553
+ /**
554
+ * One human-readable drift line.
555
+ *
556
+ * Three things this deliberately does NOT do, each a defect it replaced:
557
+ *
558
+ * - `JSON.stringify(undefined)` returns `undefined` — the string, not a value — so an
559
+ * absent key printed as `settings has undefined`, leaking a JavaScript value into text
560
+ * a person reads. Absent is now said in words.
561
+ * - `modelSettings` printed as raw JSON was a 200-character wall nobody parses.
562
+ * - Quoting every scalar (`config wants "opus"`) adds noise; a model name is a word.
563
+ */
564
+ function describeDrift(key: string, have: unknown, want: unknown): string {
565
+ // `modelSettings` is DIFFED rather than summarised on both sides.
566
+ //
567
+ // Summarising it was the wrong shape, and hiding the full-id keys to keep the summary
568
+ // short made it actively wrong: the patch writes every model twice (alias + live-resolved
569
+ // full id), and the side that differs is almost always the full id — which is also the
570
+ // side that WINS at runtime (AMR-1 rule 6 above). So the line could print
571
+ // `settings has opus xhigh, config wants opus xhigh` — X differs from X — while the key
572
+ // actually serving subagents at another effort was the one suppressed. That happens with
573
+ // no hand-editing at all: apply a preset, wait for the next model release, and the stored
574
+ // full id no longer matches the resolved one.
575
+ //
576
+ // Diffing cannot produce that line, because a key only appears when its two sides differ.
577
+ if (key === "modelSettings" && isRec(have) && isRec(want)) {
578
+ const changed = modelSettingsDiff(have, want);
579
+ if (changed.length > 0) return `modelSettings: ${changed.join(", ")}`;
580
+ }
581
+ return `${key}: settings ${have === undefined ? "has no value" : `has ${describeValue(have)}`}, config wants ${describeValue(want)}`;
582
+ }
583
+
584
+ /** A value as a person would say it, never as JSON. */
585
+ function describeValue(v: unknown): string {
586
+ if (v === undefined) return "nothing";
587
+ if (typeof v === "string") return v;
588
+ if (v && typeof v === "object") return describeModelSettings(v);
589
+ return JSON.stringify(v);
590
+ }
591
+
592
+ /** The `effortLevel` inside one `modelSettings` entry, or null when there is not one. */
593
+ function effortOf(spec: unknown): string | null {
594
+ if (!spec || typeof spec !== "object") return null;
595
+ const level = (spec as { effortLevel?: unknown }).effortLevel;
596
+ return typeof level === "string" ? level : null;
597
+ }
598
+
599
+ /**
600
+ * Every model whose effort differs between the two maps, as `opus xhigh → high`.
601
+ *
602
+ * Keys are taken from BOTH sides and sorted, so a model present in only one of them still
603
+ * appears — an entry `/effort` left behind in settings is exactly the case worth seeing, and
604
+ * a summary of one side alone can never show it.
605
+ */
606
+ function modelSettingsDiff(
607
+ have: Record<string, unknown>,
608
+ want: Record<string, unknown>,
609
+ ): string[] {
610
+ const models = new Set([...Object.keys(have), ...Object.keys(want)]);
611
+ const out: string[] = [];
612
+ for (const model of [...models].sort()) {
613
+ const h = effortOf(have[model]);
614
+ const w = effortOf(want[model]);
615
+ if (h === w) continue;
616
+ out.push(`${model} ${h ?? "unset"} → ${w ?? "unset"}`);
617
+ }
618
+ return out;
619
+ }
620
+
621
+ /** `modelSettings` as `opus xhigh, fable medium`, for the one-sided case. */
622
+ function describeModelSettings(v: object): string {
623
+ const parts: string[] = [];
624
+ for (const [model, spec] of Object.entries(v as Record<string, unknown>)) {
625
+ parts.push(`${model} ${effortOf(spec) ?? "unset"}`);
626
+ }
627
+ return parts.length ? parts.join(", ") : "nothing";
628
+ }