toolroll 0.9.82 → 0.9.84
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.
- package/CHANGELOG.md +41 -0
- package/README.md +4 -3
- package/dist/assignment-ui.js +5 -1
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +22 -22
- package/dist/browser-crew.js +5 -1
- package/dist/browser-workspace.d.ts +7 -0
- package/dist/contracts/console-api.d.ts +2 -1
- package/dist/contracts/console-api.js +5 -1
- package/dist/control-setup.d.ts +9 -0
- package/dist/control-setup.js +31 -4
- package/dist/control-ui.d.ts +7 -1
- package/dist/control-ui.js +3 -2
- package/dist/dispatch.d.ts +15 -0
- package/dist/dispatch.js +26 -4
- package/dist/first-run.d.ts +5 -2
- package/dist/first-run.js +13 -2
- package/dist/first-tasks-child.d.ts +1 -0
- package/dist/first-tasks-child.js +11 -0
- package/dist/folder-look.d.ts +110 -0
- package/dist/folder-look.js +306 -0
- package/dist/guides.js +4 -3
- package/dist/learning-child.d.ts +1 -0
- package/dist/learning-child.js +34 -0
- package/dist/learning-look.d.ts +25 -0
- package/dist/learning-look.js +72 -0
- package/dist/model-catalog.d.ts +5 -0
- package/dist/model-catalog.js +11 -2
- package/dist/model-choices.d.ts +188 -0
- package/dist/model-choices.js +405 -0
- package/dist/model-names.d.ts +6 -0
- package/dist/model-names.js +34 -0
- package/dist/models-ui.d.ts +69 -5
- package/dist/models-ui.js +53 -10
- package/dist/needs-you.d.ts +8 -0
- package/dist/needs-you.js +8 -0
- package/dist/operate.d.ts +53 -0
- package/dist/operate.js +277 -49
- package/dist/project-admission.d.ts +4 -1
- package/dist/project-admission.js +23 -2
- package/dist/project-identity.d.ts +44 -0
- package/dist/project-identity.js +153 -0
- package/dist/project-knowledge.d.ts +6 -6
- package/dist/project-tools.d.ts +4 -1
- package/dist/project-tools.js +7 -2
- package/dist/project.d.ts +4 -1
- package/dist/project.js +11 -4
- package/dist/repos.d.ts +7 -0
- package/dist/repos.js +57 -0
- package/dist/serve.js +91 -17
- package/dist/server/chat.js +5 -4
- package/dist/server/chrome.d.ts +3 -0
- package/dist/server/chrome.js +5 -2
- package/dist/server/console.css +5 -2
- package/dist/server/flows.js +13 -3
- package/dist/server/guards.d.ts +4 -0
- package/dist/server/guards.js +12 -3
- package/dist/server/http.d.ts +12 -0
- package/dist/server/http.js +9 -0
- package/dist/server/pages.js +102 -92
- package/dist/server/people-tokens.js +40 -20
- package/dist/server/render-pages.d.ts +12 -16
- package/dist/server/render-pages.js +30 -69
- package/dist/server/render-people.d.ts +14 -2
- package/dist/server/render-people.js +35 -12
- package/dist/server/render-settings.js +1 -0
- package/dist/server/render-tasks.js +7 -14
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/route-table.js +8 -1
- package/dist/server/runtime.d.ts +4 -0
- package/dist/server/runtime.js +1 -0
- package/dist/server/settings.d.ts +58 -1
- package/dist/server/settings.js +211 -71
- package/dist/server/signup-gate.d.ts +9 -0
- package/dist/server/signup-gate.js +40 -0
- package/dist/server/tasks.js +19 -5
- package/dist/settings-kit-script.d.ts +21 -0
- package/dist/settings-kit-script.js +67 -0
- package/dist/settings-kit.d.ts +137 -0
- package/dist/settings-kit.js +186 -0
- package/dist/settings-proofs.d.ts +64 -0
- package/dist/settings-proofs.js +101 -0
- package/dist/setup-guide.js +4 -2
- package/dist/spend-report.d.ts +87 -0
- package/dist/spend-report.js +265 -0
- package/dist/spend-ui.d.ts +29 -8
- package/dist/spend-ui.js +74 -39
- package/dist/task-sizing.d.ts +5 -3
- package/dist/task-sizing.js +4 -4
- package/dist/up-processes.d.ts +13 -0
- package/dist/up-processes.js +6 -2
- package/package.json +1 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { type SeenModel } from "./model-catalog.js";
|
|
2
|
+
import { type PhaseRoute } from "./phase-routing.js";
|
|
3
|
+
import type { Store } from "./store.js";
|
|
4
|
+
export type SlotPhase = "plan" | "build" | "repair" | "review";
|
|
5
|
+
export type SlotTier = "routine" | "light" | "strong";
|
|
6
|
+
export type Slot = {
|
|
7
|
+
phase: SlotPhase;
|
|
8
|
+
tier: SlotTier;
|
|
9
|
+
};
|
|
10
|
+
export type Pair = {
|
|
11
|
+
provider: string;
|
|
12
|
+
model: string | null;
|
|
13
|
+
};
|
|
14
|
+
/** One row to set (an exact pair) or clear (null). */
|
|
15
|
+
/** A model is null only on the command line's `config set <phase> --provider p` (its harness default). */
|
|
16
|
+
export type ModelChange = {
|
|
17
|
+
slot: Slot;
|
|
18
|
+
to: {
|
|
19
|
+
provider: string;
|
|
20
|
+
model: string | null;
|
|
21
|
+
} | null;
|
|
22
|
+
};
|
|
23
|
+
/** Every row the editor owns, in the order the page and the diff show them. Alternates (`--also`) and the strong
|
|
24
|
+
* repair row stay CLI-only; presets clear the strong repair row so repairs follow the builder. */
|
|
25
|
+
export declare const SLOTS: readonly Slot[];
|
|
26
|
+
/** Every row the write path accepts: the editor's, plus the strong repair row the CLI also sets. */
|
|
27
|
+
export declare const ALL_SLOTS: readonly Slot[];
|
|
28
|
+
export declare const slotKey: (slot: Slot) => string;
|
|
29
|
+
export declare function slotOf(key: string): Slot | null;
|
|
30
|
+
/** The role in plain words. */
|
|
31
|
+
export declare function slotWords(slot: Slot): string;
|
|
32
|
+
export declare function rowAt(store: Store, scope: string, slot: Slot): Pair | null;
|
|
33
|
+
/** Every value the editor shows for a scope: its own rows and, for a project, the installation rows it inherits, and
|
|
34
|
+
* the alternates described in words. A change to any of them makes an open form stale. */
|
|
35
|
+
export declare function stateDigest(store: Store, scope: string): string;
|
|
36
|
+
/** The exact change a preview showed: its target and every set and clear, in slot order. */
|
|
37
|
+
export declare function changeDigest(scope: string, change: readonly ModelChange[]): string;
|
|
38
|
+
export type DiffRow = {
|
|
39
|
+
slot: Slot;
|
|
40
|
+
before: Pair | null;
|
|
41
|
+
after: Pair | null;
|
|
42
|
+
};
|
|
43
|
+
/** What a preview showed: the scope and every row's before → after. Bound into the apply, so a row that moved in
|
|
44
|
+
* between, or a change the preview never showed, is refused. */
|
|
45
|
+
export declare function diffDigest(scope: string, diff: readonly DiffRow[]): string;
|
|
46
|
+
/** A preview read from ONE snapshot: the rows it moves, what a cleared project row would inherit, and both digests
|
|
47
|
+
* the apply must match. */
|
|
48
|
+
export declare function previewModelChange(store: Store, scope: string, change: readonly ModelChange[]): {
|
|
49
|
+
rows: (DiffRow & {
|
|
50
|
+
inherited: Pair | null;
|
|
51
|
+
})[];
|
|
52
|
+
state: string;
|
|
53
|
+
change: string;
|
|
54
|
+
};
|
|
55
|
+
/** The rows this change really moves (a set to the value already there is no change). */
|
|
56
|
+
export declare function diffOf(store: Store, scope: string, change: readonly ModelChange[]): DiffRow[];
|
|
57
|
+
export declare const OPUS = "claude-opus-5-5";
|
|
58
|
+
export declare const SONNET = "claude-sonnet-5-5";
|
|
59
|
+
export declare const HAIKU = "claude-haiku-5-5";
|
|
60
|
+
export type PresetName = "sized" | "opus";
|
|
61
|
+
export declare const PRESETS: Record<PresetName, {
|
|
62
|
+
label: string;
|
|
63
|
+
words: string;
|
|
64
|
+
rows: Record<string, string | null>;
|
|
65
|
+
}>;
|
|
66
|
+
export declare const DEFAULT_PRESET: PresetName;
|
|
67
|
+
export declare const isPresetName: (value: string | null) => value is PresetName;
|
|
68
|
+
/** A preset as a change: every slot, as exact Claude ids, never an alias. Repair rows are cleared so repairs follow
|
|
69
|
+
* the builder of each size. */
|
|
70
|
+
export declare function presetChange(name: PresetName): ModelChange[];
|
|
71
|
+
/** The routes a task filed now would get at a scope, for every size and demand that picks a tier (inheritance,
|
|
72
|
+
* review following the planner, and repair following each size's builder included), or the words for why none can. */
|
|
73
|
+
export declare const ROUTE_CASES: readonly {
|
|
74
|
+
label: string;
|
|
75
|
+
size: "small" | "medium" | "large";
|
|
76
|
+
risky: boolean;
|
|
77
|
+
strict: boolean;
|
|
78
|
+
}[];
|
|
79
|
+
export declare function effectiveRoutes(store: Store, repo: string | null): {
|
|
80
|
+
ok: true;
|
|
81
|
+
routes: {
|
|
82
|
+
label: string;
|
|
83
|
+
route: PhaseRoute;
|
|
84
|
+
}[];
|
|
85
|
+
} | {
|
|
86
|
+
ok: false;
|
|
87
|
+
problem: string;
|
|
88
|
+
};
|
|
89
|
+
/** Which preset a scope's EFFECTIVE configuration matches (what each size would really run, inherited rows
|
|
90
|
+
* included), or null ("a custom set"). */
|
|
91
|
+
export declare function presetInUse(store: Store, scope: string): PresetName | null;
|
|
92
|
+
/** "Switch to it": every row at this scope that names `from` names `to` instead. */
|
|
93
|
+
export declare function switchChange(store: Store, scope: string, from: string, to: string): ModelChange[];
|
|
94
|
+
/** For each exact Claude id this scope uses, the newest model of its family the watch has seen, when that is newer. */
|
|
95
|
+
export declare function newerModels(store: Store, scope: string): {
|
|
96
|
+
from: string;
|
|
97
|
+
to: SeenModel;
|
|
98
|
+
slots: Slot[];
|
|
99
|
+
}[];
|
|
100
|
+
/** Who is acting, proved by the caller's own step-up, and re-proved here inside the transaction: the same account,
|
|
101
|
+
* still active, still an unrestricted instance operator, at the credential generation the proof was made under, on a
|
|
102
|
+
* session (or local CLI login) that is still live. */
|
|
103
|
+
export type ModelAuthority = {
|
|
104
|
+
actor: string;
|
|
105
|
+
/** The credential generation the proof was checked against (read before the proof, never after). */
|
|
106
|
+
generation: number;
|
|
107
|
+
/** How the person proved it (scope.ts AuthMethod): a Passwordless session or remembered CLI login stands only while
|
|
108
|
+
* Passwordless is still on, and is re-checked here. */
|
|
109
|
+
method: string;
|
|
110
|
+
live: () => boolean;
|
|
111
|
+
};
|
|
112
|
+
export type ApplyInput = {
|
|
113
|
+
scope: string;
|
|
114
|
+
change: readonly ModelChange[];
|
|
115
|
+
/** The state a page showed, and (for a preview) the exact change it showed; null on the command line, which shows
|
|
116
|
+
* nothing before it writes. */
|
|
117
|
+
expected: {
|
|
118
|
+
state: string;
|
|
119
|
+
change?: string;
|
|
120
|
+
} | null;
|
|
121
|
+
/** The console: exact ids only. The CLI may also name a floating name, or a routine row with no model. The
|
|
122
|
+
* resulting configuration is checked the same way for both. */
|
|
123
|
+
strict: boolean;
|
|
124
|
+
knownProject: (repo: string) => boolean;
|
|
125
|
+
/** The command line's other rows, under the same proof, target and result checks: another provider's candidate on
|
|
126
|
+
* a tier (`--also`), and removing a light planner left from before light became build-only. */
|
|
127
|
+
extra?: readonly ExtraWrite[];
|
|
128
|
+
};
|
|
129
|
+
export type ExtraWrite = {
|
|
130
|
+
kind: "also-set";
|
|
131
|
+
phase: "plan" | "build";
|
|
132
|
+
tier: SlotTier;
|
|
133
|
+
provider: string;
|
|
134
|
+
model: string;
|
|
135
|
+
} | {
|
|
136
|
+
kind: "also-clear";
|
|
137
|
+
phase: "plan" | "build";
|
|
138
|
+
tier: SlotTier;
|
|
139
|
+
provider: string | null;
|
|
140
|
+
} | {
|
|
141
|
+
kind: "legacy-light-clear";
|
|
142
|
+
phase: SlotPhase;
|
|
143
|
+
};
|
|
144
|
+
export type ApplyRefusal = {
|
|
145
|
+
ok: false;
|
|
146
|
+
reason: "authority" | "unknown-project" | "stale" | "invalid";
|
|
147
|
+
problem: string;
|
|
148
|
+
};
|
|
149
|
+
export type ApplyResult = {
|
|
150
|
+
ok: true;
|
|
151
|
+
diff: DiffRow[];
|
|
152
|
+
unresolved: string | null;
|
|
153
|
+
extra: number;
|
|
154
|
+
} | ApplyRefusal;
|
|
155
|
+
export type ScopeCheck = {
|
|
156
|
+
kind: "ok";
|
|
157
|
+
} | {
|
|
158
|
+
kind: "incomplete" | "invalid";
|
|
159
|
+
problem: string;
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* Whether tasks can be filed at a scope with its resulting configuration. INCOMPLETE: a role has no exact model yet
|
|
163
|
+
* (setup in progress; saved, and said). INVALID: something set can't run: an unknown provider, an ineligible reviewer,
|
|
164
|
+
* or a route of any size or demand (small, routine, large, risky, strict) whose repair can't resume its builder,
|
|
165
|
+
* inherited rows included. An invalid result is never saved.
|
|
166
|
+
*/
|
|
167
|
+
export declare function checkScope(store: Store, repo: string | null): ScopeCheck;
|
|
168
|
+
/** A resolver's words for a browser: what is wrong, never a terminal command to fix it. */
|
|
169
|
+
export declare function browserWords(problem: string): string;
|
|
170
|
+
/** The problem alone, or null when tasks can be filed. */
|
|
171
|
+
export declare function scopeProblem(store: Store, repo: string | null): string | null;
|
|
172
|
+
/**
|
|
173
|
+
* Apply one change in ONE transaction: re-prove the actor, check the target and that nothing moved since the person
|
|
174
|
+
* looked, check every row, write every set and clear, check the resulting configuration (no scope it reaches, the
|
|
175
|
+
* target or a project inheriting from it, may stop resolving), and ledger before → after for each row. Any refusal
|
|
176
|
+
* rolls everything back: a preset that fails on its last row writes nothing.
|
|
177
|
+
*/
|
|
178
|
+
export declare function applyModelChoices(store: Store, input: ApplyInput, authority: ModelAuthority, now: Date): ApplyResult;
|
|
179
|
+
export type RecentRun = {
|
|
180
|
+
runId: number;
|
|
181
|
+
taskId: string;
|
|
182
|
+
role: string;
|
|
183
|
+
chosen: string | null;
|
|
184
|
+
ranOn: string | null;
|
|
185
|
+
at: string;
|
|
186
|
+
};
|
|
187
|
+
/** The latest runs' floating or exact choice next to the model their provider reported: no verdict. */
|
|
188
|
+
export declare function recentRuns(store: Store, limit?: number): RecentRun[];
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings → Models, the one write path (docs/plans/spend-dashboard.md, slice 3): which model each role uses at the
|
|
3
|
+
* installation or one project, changed in one transaction, ledgered before → after per row (clears included), and
|
|
4
|
+
* never touching sealed work. Presets (D5: "Sized by job", "Opus for everything") and "Switch to it" are changes like
|
|
5
|
+
* any other: their exact diff is shown first and the apply carries its digest.
|
|
6
|
+
*
|
|
7
|
+
* Model choices apply to NEW filings only. Nothing here reads or writes a scope, its approved profile or digest, a run
|
|
8
|
+
* route, an approval, a mode's terms, or a routine's or standing order's pin: those were sealed at filing.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { isAcpProvider } from "./acp-agents.js";
|
|
12
|
+
import { INSTALLATION_SCOPE, resolvePhaseAgent, resolveRouteCandidates } from "./agentconfig.js";
|
|
13
|
+
import { seenModels } from "./model-catalog.js";
|
|
14
|
+
import { passwordlessOn } from "./passwordless.js";
|
|
15
|
+
import { exactModelId, recommendRoute, routeProblems } from "./phase-routing.js";
|
|
16
|
+
import { isProviderId, validateSpec } from "./provider.js";
|
|
17
|
+
/** Every row the editor owns, in the order the page and the diff show them. Alternates (`--also`) and the strong
|
|
18
|
+
* repair row stay CLI-only; presets clear the strong repair row so repairs follow the builder. */
|
|
19
|
+
export const SLOTS = Object.freeze([
|
|
20
|
+
{ phase: "plan", tier: "routine" }, { phase: "plan", tier: "strong" },
|
|
21
|
+
{ phase: "build", tier: "light" }, { phase: "build", tier: "routine" }, { phase: "build", tier: "strong" },
|
|
22
|
+
{ phase: "repair", tier: "routine" }, { phase: "review", tier: "routine" }, { phase: "review", tier: "strong" },
|
|
23
|
+
]);
|
|
24
|
+
/** Every row the write path accepts: the editor's, plus the strong repair row the CLI also sets. */
|
|
25
|
+
export const ALL_SLOTS = Object.freeze([
|
|
26
|
+
...SLOTS.slice(0, 6), { phase: "repair", tier: "strong" }, ...SLOTS.slice(6),
|
|
27
|
+
]);
|
|
28
|
+
const order = (slot) => ALL_SLOTS.findIndex(one => one.phase === slot.phase && one.tier === slot.tier);
|
|
29
|
+
export const slotKey = (slot) => slot.tier === "routine" ? slot.phase : `${slot.phase}-${slot.tier}`;
|
|
30
|
+
export function slotOf(key) {
|
|
31
|
+
return SLOTS.find(one => slotKey(one) === key) ?? null;
|
|
32
|
+
}
|
|
33
|
+
/** The role in plain words. */
|
|
34
|
+
export function slotWords(slot) {
|
|
35
|
+
if (slot.phase === "build")
|
|
36
|
+
return slot.tier === "light" ? "Builder for small tasks" : slot.tier === "strong" ? "Builder for large or risky tasks" : "Builder for routine tasks";
|
|
37
|
+
if (slot.phase === "plan")
|
|
38
|
+
return slot.tier === "strong" ? "Planner for large or risky tasks" : "Planner";
|
|
39
|
+
const role = slot.phase === "repair" ? "Repair" : "Reviewer";
|
|
40
|
+
return slot.tier === "strong" ? `${role} for large or risky tasks` : role;
|
|
41
|
+
}
|
|
42
|
+
export function rowAt(store, scope, slot) {
|
|
43
|
+
const row = slot.tier === "routine" ? store.phaseConfig(scope, slot.phase) : store.phaseTierConfig(scope, slot.phase, slot.tier);
|
|
44
|
+
return row === null ? null : { provider: row.provider, model: row.model };
|
|
45
|
+
}
|
|
46
|
+
const samePair = (a, b) => a === null || b === null ? a === b : a.provider === b.provider && a.model === b.model;
|
|
47
|
+
const sha = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
48
|
+
/** Every value the editor shows for a scope: its own rows and, for a project, the installation rows it inherits, and
|
|
49
|
+
* the alternates described in words. A change to any of them makes an open form stale. */
|
|
50
|
+
export function stateDigest(store, scope) {
|
|
51
|
+
const rows = (one) => ({
|
|
52
|
+
slots: ALL_SLOTS.map(slot => [slotKey(slot), rowAt(store, one, slot)]),
|
|
53
|
+
also: store.listPhaseTierAlternates(one).map(row => [row.phase, row.tier, row.provider, row.model]),
|
|
54
|
+
});
|
|
55
|
+
return sha({ scope, installation: rows(INSTALLATION_SCOPE), project: scope === INSTALLATION_SCOPE ? null : rows(scope) });
|
|
56
|
+
}
|
|
57
|
+
/** The exact change a preview showed: its target and every set and clear, in slot order. */
|
|
58
|
+
export function changeDigest(scope, change) {
|
|
59
|
+
const ordered = [...change].sort((a, b) => order(a.slot) - order(b.slot));
|
|
60
|
+
return sha({ scope, change: ordered.map(one => [slotKey(one.slot), one.to === null ? null : [one.to.provider, one.to.model]]) });
|
|
61
|
+
}
|
|
62
|
+
/** What a preview showed: the scope and every row's before → after. Bound into the apply, so a row that moved in
|
|
63
|
+
* between, or a change the preview never showed, is refused. */
|
|
64
|
+
export function diffDigest(scope, diff) {
|
|
65
|
+
return sha({ scope, diff: diff.map(row => [slotKey(row.slot), row.before, row.after]) });
|
|
66
|
+
}
|
|
67
|
+
/** A preview read from ONE snapshot: the rows it moves, what a cleared project row would inherit, and both digests
|
|
68
|
+
* the apply must match. */
|
|
69
|
+
export function previewModelChange(store, scope, change) {
|
|
70
|
+
return store.transact(() => {
|
|
71
|
+
const diff = diffOf(store, scope, change);
|
|
72
|
+
return {
|
|
73
|
+
rows: diff.map(row => ({ ...row, inherited: scope === INSTALLATION_SCOPE ? null : rowAt(store, INSTALLATION_SCOPE, row.slot) })),
|
|
74
|
+
state: stateDigest(store, scope), change: diffDigest(scope, diff),
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/** The rows this change really moves (a set to the value already there is no change). */
|
|
79
|
+
export function diffOf(store, scope, change) {
|
|
80
|
+
return ALL_SLOTS.flatMap(slot => {
|
|
81
|
+
const wanted = change.find(one => slotKey(one.slot) === slotKey(slot));
|
|
82
|
+
if (wanted === undefined)
|
|
83
|
+
return [];
|
|
84
|
+
const before = rowAt(store, scope, slot);
|
|
85
|
+
const after = wanted.to === null ? null : { provider: wanted.to.provider, model: wanted.to.model };
|
|
86
|
+
return samePair(before, after) ? [] : [{ slot, before, after }];
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
// ---- presets (owner decision D5, 2026-10-31) -------------------------------
|
|
90
|
+
export const OPUS = "claude-opus-5-5";
|
|
91
|
+
export const SONNET = "claude-sonnet-5-5";
|
|
92
|
+
export const HAIKU = "claude-haiku-5-5";
|
|
93
|
+
export const PRESETS = {
|
|
94
|
+
sized: {
|
|
95
|
+
label: "Sized by job",
|
|
96
|
+
words: "Small tasks build on Haiku 5.5, routine tasks on Sonnet 5.5, large or risky tasks on Opus 5.5. Planning and review always use Opus 5.5.",
|
|
97
|
+
rows: { plan: OPUS, "plan-strong": OPUS, "build-light": HAIKU, build: SONNET, "build-strong": OPUS, repair: null, "repair-strong": null, review: OPUS, "review-strong": OPUS },
|
|
98
|
+
},
|
|
99
|
+
opus: {
|
|
100
|
+
label: "Opus for everything",
|
|
101
|
+
words: "Every task plans, builds and is reviewed on Opus 5.5, whatever its size.",
|
|
102
|
+
rows: { plan: OPUS, "plan-strong": OPUS, "build-light": OPUS, build: OPUS, "build-strong": OPUS, repair: null, "repair-strong": null, review: OPUS, "review-strong": OPUS },
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
export const DEFAULT_PRESET = "sized";
|
|
106
|
+
export const isPresetName = (value) => value === "sized" || value === "opus";
|
|
107
|
+
/** A preset as a change: every slot, as exact Claude ids, never an alias. Repair rows are cleared so repairs follow
|
|
108
|
+
* the builder of each size. */
|
|
109
|
+
export function presetChange(name) {
|
|
110
|
+
return ALL_SLOTS.map(slot => {
|
|
111
|
+
const model = PRESETS[name].rows[slotKey(slot)];
|
|
112
|
+
return { slot, to: model === null || model === undefined ? null : { provider: "claude", model } };
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/** The routes a task filed now would get at a scope, for every size and demand that picks a tier (inheritance,
|
|
116
|
+
* review following the planner, and repair following each size's builder included), or the words for why none can. */
|
|
117
|
+
export const ROUTE_CASES = Object.freeze([
|
|
118
|
+
{ label: "small", size: "small", risky: false, strict: false }, { label: "routine", size: "medium", risky: false, strict: false },
|
|
119
|
+
{ label: "large", size: "large", risky: false, strict: false }, { label: "risky", size: "small", risky: true, strict: false },
|
|
120
|
+
{ label: "strict", size: "medium", risky: false, strict: true },
|
|
121
|
+
]);
|
|
122
|
+
export function effectiveRoutes(store, repo) {
|
|
123
|
+
const candidates = resolveRouteCandidates(store, repo);
|
|
124
|
+
if (!candidates.ok)
|
|
125
|
+
return { ok: false, problem: candidates.problem };
|
|
126
|
+
return { ok: true, routes: routesOf(candidates.candidates) };
|
|
127
|
+
}
|
|
128
|
+
/** Every size's and demand's route over these candidates. (A build moved to another provider for plan headroom takes
|
|
129
|
+
* its repairs with it, so that move can't break a repair; a tier whose only candidate is another provider's is its
|
|
130
|
+
* primary here, and checked.) */
|
|
131
|
+
function routesOf(candidates) {
|
|
132
|
+
const routes = ROUTE_CASES.map(one => ({ label: one.label, route: recommendRoute({ qualityMode: one.strict ? "strict" : "default", evidence: [], publication: "none",
|
|
133
|
+
candidates, overrides: [], size: { size: one.size, risky: one.risky, source: "heuristic", reason: "" } }) }));
|
|
134
|
+
return routes;
|
|
135
|
+
}
|
|
136
|
+
/** Which preset a scope's EFFECTIVE configuration matches (what each size would really run, inherited rows
|
|
137
|
+
* included), or null ("a custom set"). */
|
|
138
|
+
export function presetInUse(store, scope) {
|
|
139
|
+
const effective = effectiveRoutes(store, scope === INSTALLATION_SCOPE ? null : scope);
|
|
140
|
+
if (!effective.ok)
|
|
141
|
+
return null;
|
|
142
|
+
const legs = (label) => effective.routes.find(one => one.label === label).route.legs;
|
|
143
|
+
const model = (label, phase) => legs(label).find(leg => leg.phase === phase);
|
|
144
|
+
const want = { sized: { small: HAIKU, routine: SONNET, large: OPUS, risky: OPUS, strict: OPUS }, opus: { small: OPUS, routine: OPUS, large: OPUS, risky: OPUS, strict: OPUS } };
|
|
145
|
+
for (const name of ["sized", "opus"]) {
|
|
146
|
+
const fits = ROUTE_CASES.every(one => {
|
|
147
|
+
const build = model(one.label, "build"), repair = model(one.label, "repair"), plan = model(one.label, "plan"), review = model(one.label, "review");
|
|
148
|
+
return build?.provider === "claude" && build.model === want[name][one.label] && repair?.model === build.model
|
|
149
|
+
&& plan?.provider === "claude" && plan.model === OPUS && review?.provider === "claude" && review.model === OPUS;
|
|
150
|
+
});
|
|
151
|
+
if (fits)
|
|
152
|
+
return name;
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
/** "Switch to it": every row at this scope that names `from` names `to` instead. */
|
|
157
|
+
export function switchChange(store, scope, from, to) {
|
|
158
|
+
return SLOTS.flatMap(slot => {
|
|
159
|
+
const row = rowAt(store, scope, slot);
|
|
160
|
+
return row !== null && row.provider === "claude" && row.model === from ? [{ slot, to: { provider: "claude", model: to } }] : [];
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
const FAMILY = /^claude-(opus|sonnet|haiku|fable)-/;
|
|
164
|
+
/** For each exact Claude id this scope uses, the newest model of its family the watch has seen, when that is newer. */
|
|
165
|
+
export function newerModels(store, scope) {
|
|
166
|
+
const seen = seenModels(store, "claude");
|
|
167
|
+
const used = new Map();
|
|
168
|
+
for (const slot of SLOTS) {
|
|
169
|
+
const row = rowAt(store, scope, slot);
|
|
170
|
+
if (row?.provider === "claude" && row.model !== null && FAMILY.test(row.model))
|
|
171
|
+
used.set(row.model, [...used.get(row.model) ?? [], slot]);
|
|
172
|
+
}
|
|
173
|
+
return [...used].flatMap(([from, slots]) => {
|
|
174
|
+
const family = FAMILY.exec(from)[1];
|
|
175
|
+
const current = seen.find(one => one.id === from) ?? null;
|
|
176
|
+
const newest = seen.filter(one => one.id.startsWith(`claude-${family}-`) && one.releasedAt !== null)
|
|
177
|
+
.sort((a, b) => (b.releasedAt ?? "").localeCompare(a.releasedAt ?? ""))[0];
|
|
178
|
+
if (newest === undefined || newest.id === from || !exactModelId(newest.id))
|
|
179
|
+
return [];
|
|
180
|
+
if (current !== null && current.releasedAt !== null && newest.releasedAt <= current.releasedAt)
|
|
181
|
+
return [];
|
|
182
|
+
return [{ from, to: newest, slots }];
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
const PERSON_PROOFS = new Set(["password-now", "sso-now", "session", "cli-login"]);
|
|
186
|
+
class Refused extends Error {
|
|
187
|
+
refusal;
|
|
188
|
+
constructor(refusal) {
|
|
189
|
+
super(refusal.problem);
|
|
190
|
+
this.refusal = refusal;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Why one row can't be written as asked, or null. */
|
|
194
|
+
function rowProblem(change, strict) {
|
|
195
|
+
const { slot, to } = change;
|
|
196
|
+
if (order(slot) < 0)
|
|
197
|
+
return "Only the builder has a small-task model.";
|
|
198
|
+
if (to === null)
|
|
199
|
+
return null;
|
|
200
|
+
if (!isProviderId(to.provider))
|
|
201
|
+
return `${slotWords(slot)}: choose a known provider.`;
|
|
202
|
+
const valid = validateSpec({ provider: to.provider, model: to.model });
|
|
203
|
+
if (!valid.ok)
|
|
204
|
+
return `${slotWords(slot)}: ${valid.problem}`;
|
|
205
|
+
if (to.model === null && (strict || slot.tier !== "routine"))
|
|
206
|
+
return `${slotWords(slot)}: choose an exact model.`;
|
|
207
|
+
if (to.model !== null && !exactModelId(to.model))
|
|
208
|
+
return `${slotWords(slot)}: choose an exact model.`;
|
|
209
|
+
if ((slot.phase === "plan" || slot.phase === "review") && isAcpProvider(to.provider))
|
|
210
|
+
return `${slotWords(slot)}: this agent builds and repairs only. Choose Claude or Codex.`;
|
|
211
|
+
if (strict && slot.phase === "review" && to.provider === "gemini")
|
|
212
|
+
return "Gemini cannot review yet. Choose Claude or Codex.";
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
/** Projects with rows of their own: an installation change reaches them through inheritance. */
|
|
216
|
+
function projectsWithRows(store) {
|
|
217
|
+
return store.handle.prepare(`SELECT scope FROM phase_config WHERE scope != ? UNION SELECT scope FROM phase_tier_config WHERE scope != ?
|
|
218
|
+
UNION SELECT scope FROM phase_tier_alternate WHERE scope != ? ORDER BY scope`).all(INSTALLATION_SCOPE, INSTALLATION_SCOPE, INSTALLATION_SCOPE).map(one => String(one.scope));
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Whether tasks can be filed at a scope with its resulting configuration. INCOMPLETE: a role has no exact model yet
|
|
222
|
+
* (setup in progress; saved, and said). INVALID: something set can't run: an unknown provider, an ineligible reviewer,
|
|
223
|
+
* or a route of any size or demand (small, routine, large, risky, strict) whose repair can't resume its builder,
|
|
224
|
+
* inherited rows included. An invalid result is never saved.
|
|
225
|
+
*/
|
|
226
|
+
export function checkScope(store, repo) {
|
|
227
|
+
const build = resolvePhaseAgent(store, "build", repo, {});
|
|
228
|
+
if (!build.ok)
|
|
229
|
+
return { kind: "invalid", problem: build.problem };
|
|
230
|
+
const plan = resolvePhaseAgent(store, "plan", repo, {});
|
|
231
|
+
if (!plan.ok)
|
|
232
|
+
return { kind: "invalid", problem: plan.problem };
|
|
233
|
+
// The reviewer is not checked here: an unset reviewer follows the planner, and routing deliberately never lets a
|
|
234
|
+
// reviewer setting stop a task from planning or building (agentconfig.ts resolveRouteCandidates; that leg never
|
|
235
|
+
// runs). So a Gemini planner with no reviewer of its own stays a valid configuration. A reviewer row written here is
|
|
236
|
+
// still refused when an ACP agent, and in the editor when Gemini (rowProblem).
|
|
237
|
+
const repair = (repo === null ? null : store.phaseConfig(repo, "repair")) ?? store.phaseConfig(INSTALLATION_SCOPE, "repair");
|
|
238
|
+
if (repair !== null && repair.provider !== build.spec.provider)
|
|
239
|
+
return { kind: "invalid", problem: `Repairs must use the builder's provider (${build.spec.provider}).` };
|
|
240
|
+
// What is configured is checked first, with a stand-in for any role that has no model yet, so a missing planner
|
|
241
|
+
// never hides a repair that can't resume its builder: every size's and demand's route, inherited rows included.
|
|
242
|
+
const standIn = (spec) => isProviderId(spec.provider) ? { provider: spec.provider, model: spec.model ?? "not-chosen-yet" } : null;
|
|
243
|
+
const configured = resolveRouteCandidates(store, repo, { plan: standIn(plan.spec), build: standIn(build.spec) });
|
|
244
|
+
if (!configured.ok)
|
|
245
|
+
return { kind: /has no exact model/.test(configured.problem) ? "incomplete" : "invalid", problem: configured.problem };
|
|
246
|
+
for (const route of routesOf(configured.candidates)) {
|
|
247
|
+
const problems = routeProblems(route.route);
|
|
248
|
+
if (problems.length > 0)
|
|
249
|
+
return { kind: "invalid", problem: `${route.label} tasks: ${problems[0]}` };
|
|
250
|
+
}
|
|
251
|
+
const effective = effectiveRoutes(store, repo);
|
|
252
|
+
if (!effective.ok)
|
|
253
|
+
return { kind: /has no exact model/.test(effective.problem) ? "incomplete" : "invalid", problem: effective.problem };
|
|
254
|
+
return { kind: "ok" };
|
|
255
|
+
}
|
|
256
|
+
/** A resolver's words for a browser: what is wrong, never a terminal command to fix it. */
|
|
257
|
+
export function browserWords(problem) {
|
|
258
|
+
const said = problem.split(" — ")[0].replace(/`[^`]*`/g, "").replace(/\s+([:;,.])/g, "$1").replace(/\s{2,}/g, " ").trim();
|
|
259
|
+
return said.length === 0 ? "This choice can't run." : `${said.charAt(0).toUpperCase()}${said.slice(1)}${/[.!?]$/.test(said) ? "" : "."}`;
|
|
260
|
+
}
|
|
261
|
+
/** The problem alone, or null when tasks can be filed. */
|
|
262
|
+
export function scopeProblem(store, repo) {
|
|
263
|
+
const checked = checkScope(store, repo);
|
|
264
|
+
return checked.kind === "ok" ? null : checked.problem;
|
|
265
|
+
}
|
|
266
|
+
const pairWords = (pair) => pair === null ? "not set" : `${pair.provider} · ${pair.model ?? "its default model"}`;
|
|
267
|
+
const ACTION_OF = (slot) => slot.tier === "routine" ? `agent for ${slot.phase} changed` : `${slot.tier} agent for ${slot.phase} changed`;
|
|
268
|
+
/**
|
|
269
|
+
* Apply one change in ONE transaction: re-prove the actor, check the target and that nothing moved since the person
|
|
270
|
+
* looked, check every row, write every set and clear, check the resulting configuration (no scope it reaches, the
|
|
271
|
+
* target or a project inheriting from it, may stop resolving), and ledger before → after for each row. Any refusal
|
|
272
|
+
* rolls everything back: a preset that fails on its last row writes nothing.
|
|
273
|
+
*/
|
|
274
|
+
export function applyModelChoices(store, input, authority, now) {
|
|
275
|
+
try {
|
|
276
|
+
return store.transact(() => {
|
|
277
|
+
// A savepoint, so a refusal rolls back these writes even inside a caller's transaction (project setup).
|
|
278
|
+
store.handle.exec("SAVEPOINT model_choices");
|
|
279
|
+
try {
|
|
280
|
+
const result = applyLocked(store, input, authority, now);
|
|
281
|
+
store.handle.exec("RELEASE model_choices");
|
|
282
|
+
return result;
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
store.handle.exec("ROLLBACK TO model_choices");
|
|
286
|
+
store.handle.exec("RELEASE model_choices");
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
if (error instanceof Refused)
|
|
293
|
+
return error.refusal;
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function applyLocked(store, input, authority, now) {
|
|
298
|
+
{
|
|
299
|
+
{
|
|
300
|
+
const account = store.accountOf(authority.actor);
|
|
301
|
+
const standsNow = PERSON_PROOFS.has(authority.method) && ((authority.method !== "session" && authority.method !== "cli-login") || passwordlessOn(store));
|
|
302
|
+
if (account === null || account.revokedAt !== null || account.generation !== authority.generation || !store.isInstanceOperator(authority.actor) || !authority.live() || !standsNow) {
|
|
303
|
+
throw new Refused({ ok: false, reason: "authority", problem: "Only an instance operator, signed in now, can change models." });
|
|
304
|
+
}
|
|
305
|
+
const scope = input.scope;
|
|
306
|
+
if (scope !== INSTALLATION_SCOPE && !input.knownProject(scope))
|
|
307
|
+
throw new Refused({ ok: false, reason: "unknown-project", problem: "That isn't a project here." });
|
|
308
|
+
// A page's form or preview: refused when what it showed moved since. The command line shows nothing first
|
|
309
|
+
// (`expected: null`): its change applies to the rows as they stand inside this transaction, and the result is
|
|
310
|
+
// checked below against exactly those rows, so concurrent command-line writers serialize instead of refusing.
|
|
311
|
+
if (input.expected !== null) {
|
|
312
|
+
if (stateDigest(store, scope) !== input.expected.state)
|
|
313
|
+
throw new Refused({ ok: false, reason: "stale", problem: "Models changed since this page loaded. Nothing was saved. Review the current choices and try again." });
|
|
314
|
+
if (input.expected.change !== undefined && diffDigest(scope, diffOf(store, scope, input.change)) !== input.expected.change)
|
|
315
|
+
throw new Refused({ ok: false, reason: "stale", problem: "This change is not the one you reviewed. Nothing was saved." });
|
|
316
|
+
}
|
|
317
|
+
const keys = input.change.map(one => slotKey(one.slot));
|
|
318
|
+
if (new Set(keys).size !== keys.length)
|
|
319
|
+
throw new Refused({ ok: false, reason: "invalid", problem: "Each role can change once per save." });
|
|
320
|
+
for (const one of input.change) {
|
|
321
|
+
const problem = rowProblem(one, input.strict);
|
|
322
|
+
if (problem !== null)
|
|
323
|
+
throw new Refused({ ok: false, reason: "invalid", problem });
|
|
324
|
+
}
|
|
325
|
+
const affected = scope === INSTALLATION_SCOPE ? [null, ...projectsWithRows(store)] : [scope];
|
|
326
|
+
const before = new Map(affected.map(repo => [repo, checkScope(store, repo).kind]));
|
|
327
|
+
const diff = diffOf(store, scope, input.change);
|
|
328
|
+
const repo = scope === INSTALLATION_SCOPE ? null : scope;
|
|
329
|
+
for (const row of diff) {
|
|
330
|
+
const { slot, after } = row;
|
|
331
|
+
if (slot.tier === "routine") {
|
|
332
|
+
// setPhaseConfig keeps its own before → after entry.
|
|
333
|
+
if (after !== null) {
|
|
334
|
+
store.setPhaseConfig(scope, slot.phase, after.provider, after.model, authority.actor, now);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
store.clearPhaseConfig(scope, slot.phase);
|
|
338
|
+
}
|
|
339
|
+
else if (after !== null) {
|
|
340
|
+
store.setPhaseTierConfig(scope, slot.phase, slot.tier, after.provider, after.model, authority.actor, now);
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
store.clearPhaseTierConfig(scope, slot.phase, slot.tier);
|
|
344
|
+
}
|
|
345
|
+
store.recordAction({ at: now.toISOString(), actor: authority.actor, repo, taskId: null, runId: null, action: ACTION_OF(slot), outcome: "changed", source: "policy", detail: `${pairWords(row.before)} → ${pairWords(after)}` });
|
|
346
|
+
}
|
|
347
|
+
let extra = 0;
|
|
348
|
+
for (const op of input.extra ?? [])
|
|
349
|
+
extra += extraWrite(store, scope, repo, op, authority.actor, now);
|
|
350
|
+
// INVALID is never saved, for the target or any project inheriting from it. INCOMPLETE (a role with no exact
|
|
351
|
+
// model yet) is saved and said for the target; another project that could file tasks before must still.
|
|
352
|
+
let unresolved = null;
|
|
353
|
+
for (const target of affected) {
|
|
354
|
+
const checked = checkScope(store, target);
|
|
355
|
+
if (checked.kind === "ok")
|
|
356
|
+
continue;
|
|
357
|
+
const where = target === repo ? "" : `${target} would stop working: `;
|
|
358
|
+
if (checked.kind === "invalid" || (target !== repo && before.get(target) === "ok")) {
|
|
359
|
+
throw new Refused({ ok: false, reason: "invalid", problem: `Nothing was saved: ${where}${checked.problem}` });
|
|
360
|
+
}
|
|
361
|
+
if (target === repo)
|
|
362
|
+
unresolved = checked.problem;
|
|
363
|
+
}
|
|
364
|
+
return { ok: true, diff, unresolved, extra };
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/** The latest runs' floating or exact choice next to the model their provider reported: no verdict. */
|
|
369
|
+
export function recentRuns(store, limit = 8) {
|
|
370
|
+
return store.handle.prepare(`SELECT run.id, task_ref.external_id, run.role, run.model, run.model_reported, run.started_at FROM run
|
|
371
|
+
JOIN task_ref ON task_ref.id = run.task_ref ORDER BY run.started_at DESC, run.id DESC LIMIT ?`).all(Math.max(1, Math.min(limit, 50)))
|
|
372
|
+
.map(row => ({ runId: Number(row["id"]), taskId: String(row["external_id"]), role: String(row["role"]), chosen: row["model"] == null ? null : String(row["model"]),
|
|
373
|
+
ranOn: row["model_reported"] == null ? null : String(row["model_reported"]), at: String(row["started_at"]) }));
|
|
374
|
+
}
|
|
375
|
+
/** One of the command line's other rows, ledgered; how many rows it changed. */
|
|
376
|
+
function extraWrite(store, scope, repo, op, actor, now) {
|
|
377
|
+
const ledger = (action, detail) => store.recordAction({ at: now.toISOString(), actor, repo, taskId: null, runId: null, action, outcome: "changed", source: "policy", detail });
|
|
378
|
+
if (op.kind === "legacy-light-clear") {
|
|
379
|
+
const before = store.phaseTierConfig(scope, op.phase, "light");
|
|
380
|
+
if (before === null || !store.clearPhaseTierConfig(scope, op.phase, "light"))
|
|
381
|
+
return 0;
|
|
382
|
+
ledger(`light agent for ${op.phase} changed`, `${pairWords(before)} → not set`);
|
|
383
|
+
return 1;
|
|
384
|
+
}
|
|
385
|
+
if (op.tier === "light" && op.phase !== "build")
|
|
386
|
+
throw new Refused({ ok: false, reason: "invalid", problem: "Only the builder has a small-task model." });
|
|
387
|
+
if (op.kind === "also-clear") {
|
|
388
|
+
const before = store.phaseTierAlternates(scope, op.phase, op.tier).filter(row => op.provider === null || row.provider === op.provider);
|
|
389
|
+
const removed = store.clearPhaseTierAlternates(scope, op.phase, op.tier, op.provider);
|
|
390
|
+
for (const row of before)
|
|
391
|
+
ledger(`other-provider ${op.tier} agent for ${op.phase} changed`, `${pairWords(row)} → not set`);
|
|
392
|
+
return removed;
|
|
393
|
+
}
|
|
394
|
+
if (!isProviderId(op.provider))
|
|
395
|
+
throw new Refused({ ok: false, reason: "invalid", problem: "Choose a known provider." });
|
|
396
|
+
const valid = validateSpec({ provider: op.provider, model: op.model });
|
|
397
|
+
if (!valid.ok || !exactModelId(op.model))
|
|
398
|
+
throw new Refused({ ok: false, reason: "invalid", problem: valid.ok ? "Choose an exact model." : valid.problem });
|
|
399
|
+
const before = store.phaseTierAlternates(scope, op.phase, op.tier).find(row => row.provider === op.provider) ?? null;
|
|
400
|
+
if (before !== null && before.model === op.model)
|
|
401
|
+
return 0;
|
|
402
|
+
store.setPhaseTierAlternate(scope, op.phase, op.tier, op.provider, op.model, actor, now);
|
|
403
|
+
ledger(`other-provider ${op.tier} agent for ${op.phase} changed`, `${pairWords(before)} → ${pairWords({ provider: op.provider, model: op.model })}`);
|
|
404
|
+
return 1;
|
|
405
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** The display name for a model id: "claude-opus-5-5" → "Claude Opus 5.5", "opus" → "Claude Opus"; an id it doesn't
|
|
2
|
+
* know stays the id. `null`, empty or "default": the provider's own default. */
|
|
3
|
+
export declare function modelName(provider: string | null, id: string | null): string;
|
|
4
|
+
/** The agent and its model in one phrase: "Claude Opus 5.5", "Codex · gpt-5.5", or the agent alone when no model is
|
|
5
|
+
* pinned. A model name that already says its agent isn't said twice. */
|
|
6
|
+
export declare function agentModelName(provider: string, id: string | null): string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One name per model, on every screen that names one (Settings revamp S4): the label the Models editor offers for a
|
|
3
|
+
* Claude id (`CLAUDE_EXACT`), the same words for a Claude id that list doesn't carry yet, and the id itself for
|
|
4
|
+
* anything else. Display only: saved rows, approvals, JSON and the CLI keep the exact id.
|
|
5
|
+
*/
|
|
6
|
+
import { CLAUDE_EXACT } from "./model-catalog.js";
|
|
7
|
+
import { providerName } from "./provider-auth.js";
|
|
8
|
+
const CLAUDE_PROVIDERS = new Set(["claude", "claude-subscription", "anthropic-api"]);
|
|
9
|
+
const LABELS = new Map(CLAUDE_EXACT.map(one => [one.value, one.label]));
|
|
10
|
+
const FAMILY = /^(?:claude-)?(opus|sonnet|haiku|fable)(?:-(\d+(?:-\d+)*))?$/;
|
|
11
|
+
/** The display name for a model id: "claude-opus-5-5" → "Claude Opus 5.5", "opus" → "Claude Opus"; an id it doesn't
|
|
12
|
+
* know stays the id. `null`, empty or "default": the provider's own default. */
|
|
13
|
+
export function modelName(provider, id) {
|
|
14
|
+
if (id === null || id.trim() === "" || id === "default")
|
|
15
|
+
return "the default model";
|
|
16
|
+
const known = LABELS.get(id);
|
|
17
|
+
if (known !== undefined)
|
|
18
|
+
return known;
|
|
19
|
+
if (provider === null || CLAUDE_PROVIDERS.has(provider)) {
|
|
20
|
+
const family = FAMILY.exec(id);
|
|
21
|
+
if (family !== null)
|
|
22
|
+
return `Claude ${family[1].charAt(0).toUpperCase()}${family[1].slice(1)}${family[2] === undefined ? "" : ` ${family[2].replaceAll("-", ".")}`}`;
|
|
23
|
+
}
|
|
24
|
+
return id;
|
|
25
|
+
}
|
|
26
|
+
/** The agent and its model in one phrase: "Claude Opus 5.5", "Codex · gpt-5.5", or the agent alone when no model is
|
|
27
|
+
* pinned. A model name that already says its agent isn't said twice. */
|
|
28
|
+
export function agentModelName(provider, id) {
|
|
29
|
+
const agent = providerName(provider);
|
|
30
|
+
if (id === null || id.trim() === "" || id === "default")
|
|
31
|
+
return agent;
|
|
32
|
+
const name = modelName(provider, id);
|
|
33
|
+
return name.startsWith(`${agent} `) ? name : `${agent} · ${name}`;
|
|
34
|
+
}
|