claudeup 6.7.1 → 6.8.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.
@@ -54,9 +54,65 @@ export type Effort = (typeof EFFORTS)[number];
54
54
  export const GRADES = ["smart", "normal", "cheap"] as const;
55
55
  export type Grade = (typeof GRADES)[number];
56
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. */
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: claudeup 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 claudeup 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. claudeup writes it and nothing in claudeup 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`. claudeup 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
+ */
60
116
  export type MainModel = ModelAlias | `${ModelAlias}[1m]`;
61
117
 
62
118
  export interface GradeSpec {
@@ -82,6 +138,24 @@ export interface ModelsConfig {
82
138
  agents: Record<string, AgentAssignment>;
83
139
  /** Grade for an agent with no assignment. */
84
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;
85
159
  }
86
160
 
87
161
  export interface ValidationError {
@@ -106,6 +180,84 @@ export function isGrade(v: unknown): v is Grade {
106
180
  return typeof v === "string" && (GRADES as readonly string[]).includes(v);
107
181
  }
108
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
+
109
261
  /**
110
262
  * Is this a value the Agent tool will accept as `model`?
111
263
  *
@@ -114,6 +266,10 @@ export function isGrade(v: unknown): v is Grade {
114
266
  * purpose — at write time, at install time, and again in the hook — because the cost of a bad
115
267
  * value is not a bad model, it is every subagent spawn failing for everyone who pulled the
116
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.
117
273
  */
118
274
  export function isRoutableModel(v: unknown): v is ModelAlias {
119
275
  return isModelAlias(v);
@@ -131,6 +287,13 @@ export function baseAlias(model: string): string {
131
287
  * Hand-rolled rather than schema-library-driven, matching `manifest.ts validateManifest`:
132
288
  * claudeup ships no validation dependency, and the errors here are meant to be read by a
133
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.
134
297
  */
135
298
  export function validateModelsConfig(raw: unknown): ValidationError[] {
136
299
  const errors: ValidationError[] = [];
@@ -160,6 +323,15 @@ export function validateModelsConfig(raw: unknown): ValidationError[] {
160
323
  "main.model",
161
324
  "`inherit` is for agents only — the main thread needs a real model",
162
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 — claudeup 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
+ );
163
335
  } else if (!isModelAlias(baseAlias(m))) {
164
336
  err(
165
337
  "main.model",
@@ -186,16 +358,23 @@ export function validateModelsConfig(raw: unknown): ValidationError[] {
186
358
  err(`grades.${g}`, "must be an object with a `model`");
187
359
  continue;
188
360
  }
189
- if (spec.model !== "inherit" && !isModelAlias(spec.model)) {
361
+ if (
362
+ spec.model !== "inherit" &&
363
+ !isMate(spec.model) &&
364
+ !isModelAlias(spec.model)
365
+ ) {
190
366
  err(
191
367
  `grades.${g}.model`,
192
- `must be one of ${MODEL_ALIASES.join(", ")} or "inherit", got ${JSON.stringify(spec.model)}`,
368
+ `must be one of ${MODEL_ALIASES.join(", ")}, "inherit", or a mate (${MATES.join(", ")}), got ${JSON.stringify(spec.model)}`,
193
369
  );
194
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.
195
374
  if (spec.model === "inherit" && spec.effort !== undefined) {
196
375
  err(
197
376
  `grades.${g}.effort`,
198
- "cannot set effort on `inherit` — effort is applied per model, and inherit names no model",
377
+ "cannot set effort on `inherit` — effort is applied per model through modelSettings, and inherit names no model",
199
378
  );
200
379
  }
201
380
  if (spec.effort !== undefined && !isEffort(spec.effort)) {
@@ -242,21 +421,54 @@ export function validateModelsConfig(raw: unknown): ValidationError[] {
242
421
  );
243
422
  continue;
244
423
  }
245
- if (value.model !== "inherit" && !isModelAlias(value.model)) {
424
+ if (
425
+ value.model !== "inherit" &&
426
+ !isMate(value.model) &&
427
+ !isModelAlias(value.model)
428
+ ) {
246
429
  err(
247
430
  `agents.${name}.model`,
248
- `must be one of ${MODEL_ALIASES.join(", ")} or "inherit", got ${JSON.stringify(value.model)}`,
431
+ `must be one of ${MODEL_ALIASES.join(", ")}, "inherit", or a mate (${MATES.join(", ")}), got ${JSON.stringify(value.model)}`,
249
432
  );
250
433
  }
251
434
  if (value.effort !== undefined && !isEffort(value.effort)) {
252
435
  err(`agents.${name}.effort`, `must be one of ${EFFORTS.join(", ")}`);
253
436
  }
437
+ // `inherit` ONLY — see the identical rule on grades above.
254
438
  if (value.model === "inherit" && value.effort !== undefined) {
255
439
  err(
256
440
  `agents.${name}.effort`,
257
- "cannot set effort on `inherit` — effort is applied per model",
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(", ")}`,
258
468
  );
469
+ continue;
259
470
  }
471
+ for (const e of validateMateBinding(slot, value)) errors.push(e);
260
472
  }
261
473
  }
262
474
 
@@ -269,7 +481,17 @@ export function validateModelsConfig(raw: unknown): ValidationError[] {
269
481
  const cfg = raw as unknown as ModelsConfig;
270
482
  const claims = new Map<string, { effort: Effort; at: string }>();
271
483
  const claim = (model: string, effort: Effort | undefined, at: string) => {
272
- if (effort === undefined || model === "inherit") return;
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;
273
495
  const key = baseAlias(model);
274
496
  const prior = claims.get(key);
275
497
  if (prior && prior.effort !== effort) {
@@ -293,6 +515,103 @@ export function validateModelsConfig(raw: unknown): ValidationError[] {
293
515
  return errors;
294
516
  }
295
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. `claudeup
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
+
296
615
  export interface Routing {
297
616
  model: AgentModel;
298
617
  effort?: Effort;
@@ -347,6 +666,7 @@ export type AgentHookDecision =
347
666
  | "invalid-config"
348
667
  | "explicit-model"
349
668
  | "inherit"
669
+ | "mate"
350
670
  | "unroutable-model";
351
671
  }
352
672
  | {
@@ -399,6 +719,15 @@ export function evaluateAgentHook(
399
719
  if (routing.model === "inherit")
400
720
  return { kind: "passthrough", reason: "inherit" };
401
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
+
402
731
  // Belt and braces over the writer's validation. A hand-edited profile or an older claudeup
403
732
  // can put an unroutable value here, and MEASURED (AMR-1 `bogus-model`) that does not
404
733
  // degrade — it refuses the spawn outright. Passing through costs the routing; injecting
@@ -450,6 +779,16 @@ export function buildSettingsPatch(
450
779
  const add = (model: string, effort: Effort | undefined) => {
451
780
  if (effort === undefined || model === "inherit") return;
452
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.
453
792
  if (!isModelAlias(alias)) return;
454
793
  modelSettings[alias] = { effortLevel: effort };
455
794
  const full = resolveFullId(alias);
@@ -466,7 +805,14 @@ export function buildSettingsPatch(
466
805
  return { set, unset: [] };
467
806
  }
468
807
 
469
- export type ModelsState = "off" | "on" | "stale" | "invalid" | "unhooked";
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";
470
816
 
471
817
  export interface ModelsStatus {
472
818
  state: ModelsState;
@@ -525,27 +871,22 @@ export function computeModelsStatus(args: {
525
871
  };
526
872
  }
527
873
 
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
-
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.
545
886
  return {
546
- state: drift.length ? "stale" : "on",
887
+ state: "on",
547
888
  preset: config.preset,
548
- drift,
889
+ drift: [],
549
890
  warnings,
550
891
  };
551
892
  }
@@ -8,6 +8,7 @@ import {
8
8
  type ModelsConfig,
9
9
  type ModelsState,
10
10
  type ModelsStatus,
11
+ sameRouting,
11
12
  } from "../../services/models-core.js";
12
13
 
13
14
  // ─── Item types ───────────────────────────────────────────────────────────────
@@ -20,6 +21,14 @@ import {
20
21
  * warnings behind the counts live on the `ModelsStatus` the detail renderer is
21
22
  * handed, so they are not stored twice and cannot disagree.
22
23
  */
24
+ /**
25
+ * What the row for a diverged config is called.
26
+ *
27
+ * A fixed word, never the file's `preset` string: that string names whatever was last
28
+ * applied, and a config edited afterwards would wear a built-in's name while not being it.
29
+ */
30
+ export const CUSTOM_LABEL = "Custom";
31
+
23
32
  export interface ModelsStatusItem {
24
33
  id: string;
25
34
  kind: "status";
@@ -73,11 +82,16 @@ function matches(config: ModelsConfig, lowerQuery: string): boolean {
73
82
  // Both names are searchable: the row SHOWS "Opus with Fable help", and a user who knows
74
83
  // the command types "fable-advisor". Matching only one of them makes the other look like
75
84
  // a missing preset.
85
+ //
86
+ // Bound models are searchable too: a project that routes `smart` to `mate1` → `grok-4.6`
87
+ // is findable by typing `grok`, which is the name its author thinks in. The slot names
88
+ // come along with the grade models above, so both halves of the binding match.
76
89
  const haystack = [
77
90
  config.preset,
78
91
  presetLabel(config.preset),
79
92
  config.main.model,
80
93
  ...GRADES.map((grade) => config.grades[grade].model),
94
+ ...Object.values(config.mates ?? {}).map((binding) => binding.model),
81
95
  ]
82
96
  .join(" ")
83
97
  .toLowerCase();
@@ -105,7 +119,29 @@ export function buildModelsItems({
105
119
 
106
120
  const lowerQuery = query.trim().toLowerCase();
107
121
  const activeName = config?.preset ?? null;
108
- const builtInNames = new Set(presets.map((preset) => preset.preset));
122
+
123
+ // DOES THE PROJECT'S OWN CONFIG SAY SOMETHING NO BUILT-IN DOES?
124
+ //
125
+ // The test used to be "does it name a preset the built-ins do not", and that missed the
126
+ // ordinary case entirely. `claudeup models use opus-lead` writes `preset: "opus-lead"`,
127
+ // and hand-editing the file afterwards — adding a `mates` block, rebinding an agent —
128
+ // leaves that name untouched. So the config carrying the mate assignments matched a
129
+ // built-in by NAME, got no row of its own, and the list rendered the BUILT-IN's contents
130
+ // under the built-in's label.
131
+ //
132
+ // The consequence was not cosmetic: the only config on the machine that can name a mate
133
+ // was unreachable on screen, so neither the agent table nor the workflow bar could ever
134
+ // show one, no matter what the file said. `claudeup models list` printed it correctly the
135
+ // whole time, which is what made the gap look like a rendering bug rather than a missing
136
+ // row.
137
+ //
138
+ // Compared by CONTENT now, so a project that merely applied a preset still gets one row
139
+ // (its file and the built-in agree) and a project that changed anything gets two.
140
+ const builtInNamed = presets.find((preset) => preset.preset === activeName);
141
+ const projectOwn =
142
+ config && (!builtInNamed || !sameRouting(config, builtInNamed))
143
+ ? config
144
+ : null;
109
145
 
110
146
  const rows: ModelsPresetItem[] = presets
111
147
  .filter((preset) => matches(preset, lowerQuery))
@@ -114,26 +150,33 @@ export function buildModelsItems({
114
150
  kind: "preset" as const,
115
151
  label: presetLabel(preset.preset),
116
152
  config: preset,
117
- active: preset.preset === activeName,
153
+ // `●` marks WHAT IS RUNNING, and when the project has diverged the built-in of
154
+ // that name is not it. Leaving the mark on both rows would say two different
155
+ // routings are simultaneously in force.
156
+ active: preset.preset === activeName && projectOwn === null,
118
157
  isDefault: preset.preset === DEFAULT_PRESET,
119
158
  custom: false,
120
159
  }));
121
160
 
122
- // A project that hand-edited its models.json names a preset no built-in
123
- // carries. It is the routing actually in force, so it gets a row of its own —
124
- // otherwise the list shows four presets with none of them marked, which reads
125
- // as "routing is off" when it is emphatically on.
126
- if (
127
- config &&
128
- activeName &&
129
- !builtInNames.has(activeName) &&
130
- matches(config, lowerQuery)
131
- ) {
161
+ // A project whose settings differ from every built-in gets ONE row, called `Custom`.
162
+ //
163
+ // Divergence is a THING YOU HAVE, not a fault to warn about. A project can legitimately
164
+ // run models or efforts no preset ships, and the list's job is to show that as one more
165
+ // option carrying those settings — selectable, inspectable, with its own tier table and
166
+ // chart — rather than as a badge saying the config and the settings disagree.
167
+ //
168
+ // It is called `Custom` and never the string in the file's `preset` field. That string is
169
+ // whatever was last applied — `models use opus-lead` writes `opus-lead` and hand-editing
170
+ // afterwards leaves it — so showing it puts a built-in's name on a row that is not that
171
+ // built-in.
172
+ if (projectOwn && matches(projectOwn, lowerQuery)) {
132
173
  rows.push({
133
- id: `preset:${activeName}`,
174
+ // `project:` rather than `preset:`, because the name it carries may be a built-in's
175
+ // and two rows may not share an id.
176
+ id: `project:${projectOwn.preset}`,
134
177
  kind: "preset",
135
- label: presetLabel(activeName),
136
- config,
178
+ label: CUSTOM_LABEL,
179
+ config: projectOwn,
137
180
  active: true,
138
181
  isDefault: false,
139
182
  custom: true,
@@ -275,7 +275,12 @@ export function ScreenLayout({
275
275
  {/* Detail panel — scrollable. The thumb is themed so the reader can
276
276
  see there is more below the fold; the track keeps the library
277
277
  default so it stays chrome, not signal. */}
278
- <box width="50%" height={panelHeight} paddingLeft={1}>
278
+ {/* `overflow: hidden`, like the list panel opposite. Without it a line wider
279
+ than the panel is drawn OUTSIDE it, over the terminal's right edge, and the
280
+ reader loses characters off the end of a sentence with nothing to say they
281
+ were lost. Clipping at the panel is the guarantee; wrapping to
282
+ `detailPanelWidth` is what keeps there being nothing to clip. */}
283
+ <box width="50%" height={panelHeight} paddingLeft={1} overflow="hidden">
279
284
  <scrollbox
280
285
  ref={detailScrollRef}
281
286
  height={panelHeight}