@crouton-kit/crouter 0.3.68 → 0.3.78

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 (176) hide show
  1. package/README.md +1 -1
  2. package/dist/builtin-memory/00-runtime-base.md +3 -10
  3. package/dist/builtin-memory/04-base-worker.md +18 -0
  4. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
  6. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +639 -741
  7. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +241 -58
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +365 -94
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
  11. package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
  12. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
  13. package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
  14. package/dist/builtin-views/canvas/tui.mjs +8 -9
  15. package/dist/builtin-views/chat/tui.mjs +12 -12
  16. package/dist/builtin-views/git-pr/tui.mjs +7 -8
  17. package/dist/builtin-views/inbox/tui.mjs +27 -41
  18. package/dist/builtin-views/linkedin/tui.mjs +23 -37
  19. package/dist/builtin-views/prompt-review/tui.mjs +11 -11
  20. package/dist/builtin-views/settings/tui.mjs +11 -11
  21. package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
  22. package/dist/cli.d.ts +1 -1
  23. package/dist/cli.js +1 -0
  24. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -7
  25. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  26. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/mermaid-render.test.js +20 -0
  28. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  29. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  30. package/dist/clients/attach/attach-cmd.d.ts +1 -1
  31. package/dist/clients/attach/attach-cmd.js +733 -730
  32. package/dist/clients/attach/auth-pickers.d.ts +12 -0
  33. package/dist/clients/attach/auth-pickers.js +11 -0
  34. package/dist/clients/attach/canvas-panels.js +2 -3
  35. package/dist/clients/attach/chat-view.d.ts +7 -8
  36. package/dist/clients/attach/chat-view.js +119 -79
  37. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  38. package/dist/clients/attach/edit-diff-render.js +263 -0
  39. package/dist/clients/attach/input-controller.d.ts +28 -2
  40. package/dist/clients/attach/input-controller.js +38 -5
  41. package/dist/clients/attach/mermaid-render.js +5 -2
  42. package/dist/clients/attach/pickers.d.ts +8 -7
  43. package/dist/clients/attach/pickers.js +13 -17
  44. package/dist/clients/attach/slash-commands.d.ts +9 -0
  45. package/dist/clients/attach/slash-commands.js +128 -7
  46. package/dist/clients/attach/titled-editor.d.ts +12 -1
  47. package/dist/clients/attach/titled-editor.js +103 -8
  48. package/dist/commands/canvas-browse.js +2 -2
  49. package/dist/commands/memory/lint.js +39 -5
  50. package/dist/commands/memory/write.js +1 -0
  51. package/dist/commands/node.js +12 -5
  52. package/dist/commands/profile/new.js +30 -5
  53. package/dist/commands/surface-tmux-spread.js +1 -3
  54. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  55. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  56. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  57. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  58. package/dist/commands/sys/config.js +18 -21
  59. package/dist/commands/sys/setup-core.d.ts +12 -1
  60. package/dist/commands/sys/setup-core.js +25 -3
  61. package/dist/commands/sys/setup.js +166 -20
  62. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  63. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  64. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  65. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  66. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  67. package/dist/core/__tests__/full/broker-pane-resolution.test.js +9 -6
  68. package/dist/core/__tests__/full/cascade-close.test.js +5 -2
  69. package/dist/core/__tests__/full/dead-pane-regression.test.js +6 -3
  70. package/dist/core/__tests__/full/detach-focus.test.js +10 -5
  71. package/dist/core/__tests__/full/human-new-window-regression.test.js +6 -3
  72. package/dist/core/__tests__/full/review-render-pane-regression.test.js +2 -2
  73. package/dist/core/__tests__/helpers/harness.d.ts +1 -0
  74. package/dist/core/__tests__/helpers/harness.js +28 -4
  75. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  76. package/dist/core/__tests__/live-mutation-verbs.test.js +5 -4
  77. package/dist/core/__tests__/review-model-floor.test.js +1 -1
  78. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  79. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  80. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  81. package/dist/core/canvas/__tests__/remote-event-stream.test.js +11 -9
  82. package/dist/core/canvas/__tests__/render-remote.test.js +7 -4
  83. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  84. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +30 -13
  85. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  86. package/dist/core/canvas/browse/app.js +164 -5
  87. package/dist/core/canvas/browse/model.d.ts +10 -6
  88. package/dist/core/canvas/browse/model.js +20 -12
  89. package/dist/core/canvas/browse/pins.d.ts +4 -0
  90. package/dist/core/canvas/browse/pins.js +29 -0
  91. package/dist/core/canvas/browse/render.d.ts +13 -1
  92. package/dist/core/canvas/browse/render.js +72 -15
  93. package/dist/core/canvas/nav-model.js +5 -11
  94. package/dist/core/canvas/paths.d.ts +5 -0
  95. package/dist/core/canvas/paths.js +7 -0
  96. package/dist/core/canvas/pid.d.ts +14 -10
  97. package/dist/core/canvas/pid.js +14 -10
  98. package/dist/core/config.d.ts +53 -2
  99. package/dist/core/config.js +228 -110
  100. package/dist/core/fault-classifier.js +1 -1
  101. package/dist/core/fs-utils.d.ts +1 -0
  102. package/dist/core/fs-utils.js +15 -1
  103. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  104. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  105. package/dist/core/keybindings/catalog.d.ts +14 -0
  106. package/dist/core/keybindings/catalog.js +256 -0
  107. package/dist/core/keybindings/index.d.ts +5 -0
  108. package/dist/core/keybindings/index.js +4 -0
  109. package/dist/core/keybindings/match.d.ts +29 -0
  110. package/dist/core/keybindings/match.js +99 -0
  111. package/dist/core/keybindings/persistence.d.ts +14 -0
  112. package/dist/core/keybindings/persistence.js +31 -0
  113. package/dist/core/keybindings/resolve.d.ts +14 -0
  114. package/dist/core/keybindings/resolve.js +305 -0
  115. package/dist/core/keybindings/types.d.ts +37 -0
  116. package/dist/core/keybindings/types.js +1 -0
  117. package/dist/core/memory-resolver.d.ts +1 -1
  118. package/dist/core/memory-resolver.js +10 -3
  119. package/dist/core/predicate.d.ts +5 -3
  120. package/dist/core/predicate.js +5 -3
  121. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  122. package/dist/core/runtime/broker.js +165 -14
  123. package/dist/core/runtime/launch.d.ts +39 -6
  124. package/dist/core/runtime/launch.js +79 -21
  125. package/dist/core/runtime/naming.js +3 -3
  126. package/dist/core/runtime/pi-cli.d.ts +6 -0
  127. package/dist/core/runtime/pi-cli.js +16 -2
  128. package/dist/core/runtime/placement.d.ts +2 -2
  129. package/dist/core/runtime/placement.js +4 -1
  130. package/dist/core/runtime/promote.js +4 -0
  131. package/dist/core/runtime/recap.d.ts +3 -3
  132. package/dist/core/runtime/recap.js +47 -44
  133. package/dist/core/runtime/recycle.js +6 -1
  134. package/dist/core/runtime/reset.js +5 -0
  135. package/dist/core/runtime/revive.js +28 -18
  136. package/dist/core/runtime/session-list-cache.d.ts +23 -0
  137. package/dist/core/runtime/session-list-cache.js +270 -0
  138. package/dist/core/runtime/spawn.js +51 -32
  139. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  140. package/dist/core/runtime/stream-watchdog.js +75 -0
  141. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  142. package/dist/core/runtime/tmux-chrome.js +2 -2
  143. package/dist/core/runtime/tmux.d.ts +18 -15
  144. package/dist/core/runtime/tmux.js +216 -110
  145. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  146. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  147. package/dist/core/tui/host.d.ts +30 -1
  148. package/dist/core/tui/host.js +148 -34
  149. package/dist/core/view/__tests__/transport-remote.test.js +9 -6
  150. package/dist/core/view/contract.d.ts +17 -3
  151. package/dist/daemon/crtrd-cli.d.ts +1 -1
  152. package/dist/daemon/crtrd-cli.js +1 -0
  153. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  154. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  155. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  156. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  157. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  158. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  159. package/dist/pi-extensions/canvas-recap.js +38 -37
  160. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  161. package/dist/pi-extensions/canvas-stophook.js +33 -4
  162. package/dist/suppress-experimental-warnings.d.ts +1 -0
  163. package/dist/suppress-experimental-warnings.js +15 -0
  164. package/dist/types.d.ts +3 -13
  165. package/dist/types.js +12 -36
  166. package/dist/web-client/assets/{index-CnF5r8ky.js → index--md2ylfi.js} +18 -17
  167. package/dist/web-client/assets/index-BUdm9s9s.css +2 -0
  168. package/dist/web-client/index.html +3 -3
  169. package/dist/web-client/sw.js +1 -1
  170. package/package.json +11 -8
  171. package/scripts/postinstall.mjs +1 -1
  172. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  173. package/dist/pi-extensions/canvas-nav.js +0 -640
  174. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  175. package/dist/pi-extensions/widget-order-bus.js +0 -34
  176. package/dist/web-client/assets/index-BnmSLNLa.css +0 -2
@@ -1,4 +1,5 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { defaultModelLaddersConfig } from "../../../types.js";
2
3
  import { homedir } from "node:os";
3
4
  import { dirname, join } from "node:path";
4
5
  import type { ThinkingLevel } from "@earendil-works/pi-ai";
@@ -25,6 +26,11 @@ export type SubscriptionCredential = {
25
26
  rateLimitedUntil: number;
26
27
  lastAttemptAt: number;
27
28
  lastRateLimitedAt: number;
29
+ // Provider account identity, captured at login for Codex (the JWT `chatgpt_account_id`).
30
+ // Absent on Anthropic entries (their OAuth token carries no account claim) and on legacy
31
+ // entries added before identity capture -- a missing `accountId` means "unknown identity"
32
+ // and makes no dedupe claim.
33
+ accountId?: string;
28
34
  };
29
35
 
30
36
  export type ModelRef = {
@@ -90,10 +96,11 @@ function readJsonFile<T>(path: string): T | undefined {
90
96
  }
91
97
  }
92
98
 
93
- function writeJsonFile(path: string, value: unknown): void {
99
+ function writeJsonFile(path: string, value: unknown, mode?: number): void {
94
100
  mkdirSync(dirname(path), { recursive: true });
95
101
  const tmp = `${path}.${process.pid}.tmp`;
96
- writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
102
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, mode === undefined ? "utf8" : { encoding: "utf8", mode });
103
+ if (mode !== undefined) chmodSync(tmp, mode);
97
104
  renameSync(tmp, path);
98
105
  }
99
106
 
@@ -111,11 +118,13 @@ function normalizeCredential(value: Partial<SubscriptionCredential> & { label?:
111
118
  if (!label) return undefined;
112
119
  const refresh = typeof value.refresh === "string" ? value.refresh.trim() : "";
113
120
  const access = typeof value.access === "string" ? value.access.trim() : "";
121
+ const accountId = typeof value.accountId === "string" ? value.accountId.trim() : "";
114
122
  const expires = Number(value.expires);
115
123
  const rateLimitedUntil = Number(value.rateLimitedUntil);
116
124
  return {
117
125
  label,
118
126
  ...(refresh && access ? { refresh, access, expires: Number.isFinite(expires) ? expires : 0 } : {}),
127
+ ...(accountId ? { accountId } : {}),
119
128
  rateLimitedUntil: Number.isFinite(rateLimitedUntil) ? rateLimitedUntil : 0,
120
129
  lastAttemptAt: normalizeTiming(value.lastAttemptAt),
121
130
  lastRateLimitedAt: normalizeTiming(value.lastRateLimitedAt),
@@ -131,32 +140,60 @@ function getCurrentModelRef(model?: { provider: string; id: string }): ModelRef
131
140
  return isManagedProvider(model?.provider) ? { providerId: model.provider, modelId: model.id } : undefined;
132
141
  }
133
142
 
134
- type AuthFileData = Record<string, { type?: string }>;
143
+ type AuthFileData = Record<string, { type?: string; accountId?: unknown }>;
135
144
 
136
- function readAuthCredential(providerId: ManagedProviderId): { type?: string } | undefined {
145
+ function readAuthCredential(providerId: ManagedProviderId): { type?: string; accountId?: string } | undefined {
137
146
  return new FileAuthStorageBackend().withLock((current) => {
138
147
  if (!current) return { result: undefined };
139
148
  const data = JSON.parse(current) as AuthFileData;
140
- return { result: data[providerId] };
149
+ const record = data[providerId];
150
+ if (!record) return { result: undefined };
151
+ const accountId = typeof record.accountId === "string" && record.accountId.trim() ? record.accountId.trim() : undefined;
152
+ return { result: { type: record.type, accountId } };
141
153
  });
142
154
  }
143
155
 
144
- // Seeds a METADATA-ONLY pool entry for the default label (`label === providerId`)
145
- // whenever an OAuth-login-linked credential exists for it in auth.json, regardless of
146
- // whether the pool already holds OTHER explicit accounts. The auth.json seed read goes
147
- // through FileAuthStorageBackend's real lock so a held auth.json lock or parse failure is
148
- // surfaced instead of silently pretending the default credential is absent. Gated
149
- // specifically on an OAUTH auth.json record (`type === "oauth"`) rather than the presence
150
- // of any credential, because an api_key credential has no refresh token -- a
151
- // metadata-only "default" slot seeded for one would be a slot `refreshDefaultSlotCredential`
152
- // can never actually refresh. No token is ever copied into the pool: the value lives
153
- // exclusively in auth.json.
156
+ // Reconciles the METADATA-ONLY pool entry for the default label (`label === providerId`)
157
+ // against auth.json, which is the source of truth for the default slot's identity. auth.json
158
+ // is read through FileAuthStorageBackend's real lock so a held auth.json lock or parse failure
159
+ // is surfaced instead of silently pretending the default credential is absent. Gated
160
+ // specifically on an OAUTH auth.json record (`type === "oauth"`) rather than the presence of
161
+ // any credential, because an api_key credential has no refresh token -- a metadata-only
162
+ // "default" slot seeded for one would be a slot `refreshDefaultSlotCredential` can never
163
+ // actually refresh; with no oauth record the pool is left exactly as-is (never seeded, never
164
+ // stripped). When an oauth record exists this ALWAYS re-derives the default entry's identity:
165
+ // it seeds a missing entry and rewrites an existing entry's `accountId` to match auth.json's
166
+ // (dropping it when auth.json carries none), preserving that entry's cooldown/timing fields.
167
+ // A NEW array reference is returned only when something actually changed -- an in-sync pool
168
+ // returns the SAME reference, which is what lets `readSubscriptionPool`'s heal-trigger and the
169
+ // mutate primitive's change detection stay sound. No token is ever copied into the pool: the
170
+ // value lives exclusively in auth.json. `accountId` IS identity metadata, so the default slot
171
+ // participates in duplicate detection and `/provider-sub list` rendering like an explicit account.
154
172
  function ensureDefaultSubscriptionMetadata(providerId: ManagedProviderId, pool: SubscriptionCredential[]): SubscriptionCredential[] {
155
- if (pool.some((entry) => entry.label === providerId)) return pool;
156
173
  const authCred = readAuthCredential(providerId);
157
174
  if (authCred?.type !== "oauth") return pool;
158
- const seeded = normalizeCredential({ label: providerId, rateLimitedUntil: 0, lastAttemptAt: 0, lastRateLimitedAt: 0 });
159
- return seeded ? [...pool, seeded] : pool;
175
+ const desired = authCred.accountId;
176
+ const idx = pool.findIndex((entry) => entry.label === providerId);
177
+ if (idx === -1) {
178
+ const seeded = normalizeCredential({
179
+ label: providerId,
180
+ ...(desired ? { accountId: desired } : {}),
181
+ rateLimitedUntil: 0,
182
+ lastAttemptAt: 0,
183
+ lastRateLimitedAt: 0,
184
+ });
185
+ return seeded ? [...pool, seeded] : pool;
186
+ }
187
+ const existing = pool[idx];
188
+ if ((existing.accountId ?? undefined) === (desired ?? undefined)) return pool;
189
+ const next = [...pool];
190
+ if (desired) {
191
+ next[idx] = { ...existing, accountId: desired };
192
+ } else {
193
+ const { accountId: _drop, ...rest } = existing;
194
+ next[idx] = rest;
195
+ }
196
+ return next;
160
197
  }
161
198
 
162
199
  // The default label's value lives exclusively in auth.json, so its pool entry must never
@@ -180,23 +217,98 @@ export function isManagedProvider(providerId: string | undefined): providerId is
180
217
  return providerId === ANTHROPIC_PROVIDER_ID || providerId === OPENAI_CODEX_PROVIDER_ID;
181
218
  }
182
219
 
220
+ /** The `modelLadders` CONFIG key for a runtime provider id -- `openai-codex`
221
+ * (the runtime/auth id) maps to the `openai` ladder key used in
222
+ * ~/.crouter/config.json. Callers building a repair command that a user can
223
+ * paste into `crtr sys config set modelLadders.<key>...` must use THIS key,
224
+ * not the runtime provider id. */
225
+ export function getLadderProviderId(providerId: ManagedProviderId): LadderProviderId {
226
+ return PROVIDERS[providerId].ladderProviderId;
227
+ }
228
+
183
229
  export function getProviderLabel(providerId: ManagedProviderId): string {
184
230
  return PROVIDERS[providerId].label;
185
231
  }
186
232
 
233
+ // The ONE locked read/modify/write primitive for a provider pool file (design §1). Acquires
234
+ // a cross-process lock ON THE POOL FILE via the same proper-lockfile-backed backend pi uses
235
+ // for auth.json, re-reads + normalizes the pool UNDER the lock, applies the caller's pure
236
+ // transform, and persists atomically (tmp+rename, mode 0600) before releasing. Every write to
237
+ // a pool file goes through here -- that is what finally makes the causal cooldown guards sound
238
+ // (they compare against fresh-under-lock state, not a stale snapshot).
239
+ //
240
+ // `fn` returning `undefined` means "no change, skip the write" (used by the timestamp guards).
241
+ // `withLock` seeds a missing file with `{}` (an OBJECT, not an array) -- normalizeCredentials
242
+ // coerces any non-array to [] so that first-lock seed is harmless; do not "fix" it into a parse
243
+ // error. We do our OWN atomic write inside the callback and return `next: undefined` so the
244
+ // backend's non-atomic writeFileSync path never runs and lock-free readers always observe a
245
+ // whole snapshot.
246
+ //
247
+ // Lock ordering (contract): pool lock -> auth lock is legal (seeding reads auth.json under its
248
+ // own FileAuthStorageBackend lock while holding the pool lock, via ensureDefaultSubscriptionMetadata
249
+ // -> readAuthCredential). NEVER acquire a pool lock inside an auth-lock callback. Throws (rather
250
+ // than proceeding unlocked) if lock acquisition is exhausted -- callers surface that as a visible
251
+ // error, never a silent lost write.
252
+ export function mutateSubscriptionPool(
253
+ providerId: ManagedProviderId,
254
+ fn: (pool: SubscriptionCredential[]) => SubscriptionCredential[] | undefined,
255
+ ): SubscriptionCredential[] {
256
+ const poolFile = PROVIDERS[providerId].poolFile;
257
+ const backend = new FileAuthStorageBackend(poolFile);
258
+ return backend.withLock((current) => {
259
+ let parsed: unknown;
260
+ try {
261
+ parsed = current ? JSON.parse(current) : undefined;
262
+ } catch {
263
+ parsed = undefined;
264
+ }
265
+ const { pool: scrubbed, changed: wasScrubbed } = scrubDefaultValue(providerId, normalizeCredentials(parsed));
266
+ const base = ensureDefaultSubscriptionMetadata(providerId, scrubbed);
267
+ const transformed = fn(base);
268
+ if (transformed === undefined) {
269
+ assertDefaultAccountAvailable(base, providerId);
270
+ // A causal timestamp guard may reject the caller's update while auth-driven identity
271
+ // reconciliation or token scrubbing still needs to heal the stored metadata.
272
+ if (wasScrubbed || base !== scrubbed) {
273
+ const healed = scrubDefaultValue(providerId, normalizeCredentials(base)).pool;
274
+ writeJsonFile(poolFile, healed, 0o600);
275
+ return { result: healed };
276
+ }
277
+ return { result: base };
278
+ }
279
+ const normalized = scrubDefaultValue(providerId, normalizeCredentials(transformed)).pool;
280
+ // Reconciliation may have projected a new default identity from auth.json. Validate the
281
+ // final candidate while the pool lock is still held, after the transform so a removal or
282
+ // default-account switch can repair an externally-created collision instead of bricking it.
283
+ assertDefaultAccountAvailable(normalized, providerId);
284
+ writeJsonFile(poolFile, normalized, 0o600);
285
+ return { result: normalized };
286
+ });
287
+ }
288
+
187
289
  export function readSubscriptionPool(providerId: ManagedProviderId): SubscriptionCredential[] {
188
290
  const poolFile = PROVIDERS[providerId].poolFile;
189
291
  const raw = normalizeCredentials(readJsonFile<unknown>(poolFile));
190
292
  const { pool: scrubbed, changed: wasScrubbed } = scrubDefaultValue(providerId, raw);
191
293
  const withDefaultSlot = ensureDefaultSubscriptionMetadata(providerId, scrubbed);
192
- if (wasScrubbed || withDefaultSlot !== scrubbed) return writeSubscriptionPool(providerId, withDefaultSlot);
294
+ // Reads are lock-free (a whole-file tmp+rename write means a reader always sees a complete
295
+ // snapshot). Only when the on-disk pool actually needs self-healing -- a stray default-slot
296
+ // value to scrub, or default metadata to reconcile with auth.json -- do we take the lock and
297
+ // re-derive the healed pool from fresh state through the write primitive.
298
+ // A source-of-truth auth change can reconcile the default slot into an explicit account.
299
+ // Force that case through the locked mutation path too: it either validates a fresh snapshot
300
+ // and persists a unique identity, or throws without returning/writing a duplicate pool.
301
+ if (wasScrubbed || withDefaultSlot !== scrubbed || findDefaultAccountCollision(withDefaultSlot, providerId)) {
302
+ return mutateSubscriptionPool(providerId, (pool) => pool);
303
+ }
193
304
  return withDefaultSlot;
194
305
  }
195
306
 
307
+ // Seed/replace the entire pool under the lock. Production mutation goes through the specific
308
+ // mutators below (each a thin transform over `mutateSubscriptionPool`); this whole-array replace
309
+ // exists for test seeding and is still locked, never an unlocked write surface.
196
310
  export function writeSubscriptionPool(providerId: ManagedProviderId, next: SubscriptionCredential[]): SubscriptionCredential[] {
197
- const normalized = scrubDefaultValue(providerId, normalizeCredentials(next)).pool;
198
- writeJsonFile(PROVIDERS[providerId].poolFile, normalized);
199
- return normalized;
311
+ return mutateSubscriptionPool(providerId, () => next);
200
312
  }
201
313
 
202
314
  function resolveCredentialIndex(pool: SubscriptionCredential[], ref: string): number {
@@ -208,39 +320,210 @@ function resolveCredentialIndex(pool: SubscriptionCredential[], ref: string): nu
208
320
  return pool.findIndex((entry) => entry.label.toLowerCase() === needle);
209
321
  }
210
322
 
323
+ // Merge an incoming upsert onto an existing same-label entry (design §1). Credential VALUE
324
+ // fields always come from the incoming entry (that is the point of the upsert); timing/cooldown
325
+ // state is reconciled causally so a refresh/reauth upsert built from a snapshot taken before a
326
+ // concurrent 429 can never erase that newer cooldown. The `>=` on `lastRateLimitedAt` is
327
+ // deliberate: reauth clears a cooldown after a successful re-login while preserving
328
+ // `lastRateLimitedAt`, so its equal-timestamp write wins and clears -- but a 429 that landed
329
+ // AFTER the reauth snapshot has a strictly greater `lastRateLimitedAt` and survives.
330
+ function mergeUpsert(existing: SubscriptionCredential, incoming: SubscriptionCredential): SubscriptionCredential {
331
+ const cooldownFromIncoming = incoming.lastRateLimitedAt >= existing.lastRateLimitedAt;
332
+ const accountId = incoming.accountId ?? existing.accountId;
333
+ return {
334
+ ...incoming,
335
+ ...(accountId ? { accountId } : {}),
336
+ lastAttemptAt: Math.max(existing.lastAttemptAt, incoming.lastAttemptAt),
337
+ lastRateLimitedAt: cooldownFromIncoming ? incoming.lastRateLimitedAt : existing.lastRateLimitedAt,
338
+ rateLimitedUntil: cooldownFromIncoming ? incoming.rateLimitedUntil : existing.rateLimitedUntil,
339
+ };
340
+ }
341
+
211
342
  export function upsertSubscription(providerId: ManagedProviderId, next: SubscriptionCredential): SubscriptionCredential[] {
212
- const pool = readSubscriptionPool(providerId);
213
- const index = pool.findIndex((entry) => entry.label.toLowerCase() === next.label.toLowerCase());
214
- if (index === -1) pool.push(next);
215
- else pool[index] = next;
216
- return writeSubscriptionPool(providerId, pool);
343
+ return mutateSubscriptionPool(providerId, (pool) => {
344
+ const index = pool.findIndex((entry) => entry.label.toLowerCase() === next.label.toLowerCase());
345
+ if (index === -1) return [...pool, next];
346
+ const merged = [...pool];
347
+ merged[index] = mergeUpsert(pool[index], next);
348
+ return merged;
349
+ });
350
+ }
351
+
352
+ // Thrown from inside the locked add transform when a label or Codex account already exists. A
353
+ // typed error so the /provider-sub handler surfaces a precise, user-facing message while the
354
+ // uniqueness decision itself stays atomic under the pool lock.
355
+ export class DuplicateSubscriptionError extends Error {
356
+ constructor(message: string) {
357
+ super(message);
358
+ this.name = "DuplicateSubscriptionError";
359
+ }
360
+ }
361
+
362
+ // The ONE account-uniqueness gate, shared by every identity write (explicit add, explicit
363
+ // reauth, default (re)login). Called INSIDE a `mutateSubscriptionPool` transform so it sees
364
+ // fresh-under-lock state. `exceptLabel` is the slot being updated -- excluded from the scan so
365
+ // a reauth/relogin of a slot into the SAME account it already holds is not a self-collision.
366
+ // A missing `accountId` makes no dedupe claim (Anthropic has no account claim, and legacy
367
+ // entries predate identity capture), so it is a no-op. Throws the typed error the handler
368
+ // surfaces to the user.
369
+ function findAccountCollision(pool: SubscriptionCredential[], accountId: string | undefined, exceptLabel: string): SubscriptionCredential | undefined {
370
+ if (!accountId) return undefined;
371
+ return pool.find((entry) => entry.label.toLowerCase() !== exceptLabel.toLowerCase() && entry.accountId === accountId);
372
+ }
373
+
374
+ function assertAccountAvailable(pool: SubscriptionCredential[], providerId: ManagedProviderId, accountId: string | undefined, exceptLabel: string): void {
375
+ const collision = findAccountCollision(pool, accountId, exceptLabel);
376
+ if (collision) {
377
+ throw new DuplicateSubscriptionError(
378
+ `This ${getProviderLabel(providerId)} account is already added as “${collision.label}” — it shares the same quota, so a second slot adds no redundancy.`,
379
+ );
380
+ }
381
+ }
382
+
383
+ function findDefaultAccountCollision(pool: SubscriptionCredential[], providerId: ManagedProviderId): SubscriptionCredential | undefined {
384
+ const defaultSlot = pool.find((entry) => entry.label === providerId);
385
+ return findAccountCollision(pool, defaultSlot?.accountId, providerId);
386
+ }
387
+
388
+ function assertDefaultAccountAvailable(pool: SubscriptionCredential[], providerId: ManagedProviderId): void {
389
+ const defaultSlot = pool.find((entry) => entry.label === providerId);
390
+ assertAccountAvailable(pool, providerId, defaultSlot?.accountId, providerId);
391
+ }
392
+
393
+ // Atomically append a NEW explicit subscription (non-default label), enforcing label AND Codex
394
+ // account uniqueness INSIDE the pool lock (design finding 2). The long OAuth flow stays outside
395
+ // the lock; only this final validate-then-append is serialized, so two brokers that both finish
396
+ // OAuth for the same account cannot each slip a second label onto one quota. Account uniqueness
397
+ // also catches a collision with the default slot, whose identity is seeded from auth.json.
398
+ export function addSubscription(providerId: ManagedProviderId, entry: SubscriptionCredential): SubscriptionCredential[] {
399
+ return mutateSubscriptionPool(providerId, (pool) => {
400
+ // Normalize the candidate label BEFORE the locked uniqueness check: `normalizeCredentials`
401
+ // trims on persist, so a raw " work " that passed an untrimmed check would land as a
402
+ // duplicate "work". Reject an empty/whitespace label here too -- the storage primitive
403
+ // stays defensive regardless of what the handler validated.
404
+ const label = entry.label.trim();
405
+ if (!label) throw new Error(`${getProviderLabel(providerId)} subscription label cannot be empty`);
406
+ if (pool.some((existing) => existing.label.toLowerCase() === label.toLowerCase())) {
407
+ throw new DuplicateSubscriptionError(`${getProviderLabel(providerId)} subscription already exists: ${label}`);
408
+ }
409
+ assertAccountAvailable(pool, providerId, entry.accountId, label);
410
+ return [...pool, { ...entry, label }];
411
+ });
412
+ }
413
+
414
+ // Identity-checked upsert for an EXPLICIT slot's reauth (label !== providerId). Same merge
415
+ // semantics as `upsertSubscription` (append-or-`mergeUpsert`, preserving cooldown causality),
416
+ // but gated by `assertAccountAvailable` first: reauthenticating a slot into an account already
417
+ // held by ANOTHER slot (default or explicit) throws instead of creating a duplicate quota entry.
418
+ // `next.label` is excluded from the uniqueness scan so a reauth into the slot's OWN account passes.
419
+ export function upsertSubscriptionWithUniqueAccount(providerId: ManagedProviderId, next: SubscriptionCredential): SubscriptionCredential[] {
420
+ return mutateSubscriptionPool(providerId, (pool) => {
421
+ assertAccountAvailable(pool, providerId, next.accountId, next.label);
422
+ const index = pool.findIndex((entry) => entry.label.toLowerCase() === next.label.toLowerCase());
423
+ if (index === -1) return [...pool, next];
424
+ const merged = [...pool];
425
+ merged[index] = mergeUpsert(pool[index], next);
426
+ return merged;
427
+ });
428
+ }
429
+
430
+ // The token-bearing half of a default-slot (re)login, destined for auth.json ONLY -- never the
431
+ // pool (the default pool entry is metadata-only). `accountId` is optional (Anthropic carries none).
432
+ export type DefaultSlotLogin = {
433
+ refresh: string;
434
+ access: string;
435
+ expires: number;
436
+ accountId?: string;
437
+ // The default entry's rate-limit timestamp observed before OAuth began. A newer timestamp
438
+ // found under the commit lock belongs to a concurrent 429 and must survive a same-account login.
439
+ lastRateLimitedAt: number;
440
+ };
441
+
442
+ // Persist the default slot's freshly-logged-in credential into auth.json under the auth lock.
443
+ // Called ONLY from `commitDefaultIdentity`, while the pool lock is already held, so the effective
444
+ // order is pool-lock -> auth-lock (the legal direction). Writes the full oauth record (tokens +
445
+ // optional accountId); a blank/whitespace accountId is dropped.
446
+ function persistDefaultAuthRecordSync(providerId: ManagedProviderId, login: DefaultSlotLogin): void {
447
+ const accountId = login.accountId?.trim() || undefined;
448
+ new FileAuthStorageBackend().withLock((current) => {
449
+ // Corrupt credential storage is never equivalent to an empty file: parsing must fail before
450
+ // either store changes rather than silently deleting every other provider's credential.
451
+ const data = current ? (JSON.parse(current) as Record<string, unknown>) : {};
452
+ data[providerId] = { type: "oauth", refresh: login.refresh, access: login.access, expires: login.expires, ...(accountId ? { accountId } : {}) };
453
+ return { result: undefined, next: `${JSON.stringify(data, null, 2)}\n` };
454
+ });
455
+ }
456
+
457
+ // Atomically commit a (re)login of the DEFAULT slot (design finding 2). Under the pool lock it:
458
+ // (1) validates account-uniqueness EXCLUDING the default slot -- throwing BEFORE any auth write,
459
+ // so a rejection never partially switches auth.json; (2) persists the credential to auth.json
460
+ // under the nested auth lock (pool -> auth order); (3) reflects the new identity and causally
461
+ // reconciles the cooldown on the metadata-only default pool entry (NO tokens copied -- the entry
462
+ // stays metadata-only; scrubDefaultValue enforces this on write regardless). Seeds the default entry
463
+ // if it is absent. The account uniqueness catches a collision with any explicit slot.
464
+ export function commitDefaultIdentity(providerId: ManagedProviderId, login: DefaultSlotLogin): SubscriptionCredential[] {
465
+ const accountId = login.accountId?.trim() || undefined;
466
+ return mutateSubscriptionPool(providerId, (pool) => {
467
+ assertAccountAvailable(pool, providerId, accountId, providerId);
468
+ persistDefaultAuthRecordSync(providerId, login);
469
+ const index = pool.findIndex((entry) => entry.label === providerId);
470
+ const now = Date.now();
471
+ const existing = index === -1 ? undefined : pool[index];
472
+ // Cooldowns belong to an account identity. A login that actually switches accounts starts
473
+ // the new quota clean. For the same account, mirror mergeUpsert's causal rule: only a login
474
+ // whose snapshot includes the latest 429 may clear it; a strictly newer concurrent 429 wins.
475
+ const sameAccount = (existing?.accountId ?? undefined) === (accountId ?? undefined);
476
+ const loginCanClearCooldown = !existing || !sameAccount || login.lastRateLimitedAt >= existing.lastRateLimitedAt;
477
+ const base =
478
+ existing === undefined
479
+ ? { label: providerId, rateLimitedUntil: 0, lastAttemptAt: now, lastRateLimitedAt: 0 }
480
+ : {
481
+ ...existing,
482
+ rateLimitedUntil: loginCanClearCooldown ? 0 : existing.rateLimitedUntil,
483
+ lastAttemptAt: Math.max(existing.lastAttemptAt, now),
484
+ };
485
+ const entry: SubscriptionCredential = accountId
486
+ ? { ...base, accountId }
487
+ : (() => {
488
+ const { accountId: _drop, ...rest } = base;
489
+ return rest;
490
+ })();
491
+ const next = [...pool];
492
+ if (index === -1) next.push(entry);
493
+ else next[index] = entry;
494
+ return next;
495
+ });
217
496
  }
218
497
 
219
498
  export function removeSubscription(providerId: ManagedProviderId, ref: string): SubscriptionCredential[] {
220
- const pool = readSubscriptionPool(providerId);
221
- const index = resolveCredentialIndex(pool, ref);
222
- if (index === -1) throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
223
- pool.splice(index, 1);
224
- return writeSubscriptionPool(providerId, pool);
499
+ return mutateSubscriptionPool(providerId, (pool) => {
500
+ const index = resolveCredentialIndex(pool, ref);
501
+ if (index === -1) throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
502
+ const next = [...pool];
503
+ next.splice(index, 1);
504
+ return next;
505
+ });
225
506
  }
226
507
 
227
508
  export function promoteSubscription(providerId: ManagedProviderId, ref: string): SubscriptionCredential[] {
228
- const pool = readSubscriptionPool(providerId);
229
- const index = resolveCredentialIndex(pool, ref);
230
- if (index === -1) throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
231
- if (index > 0) pool.unshift(pool.splice(index, 1)[0]);
232
- return writeSubscriptionPool(providerId, pool);
509
+ return mutateSubscriptionPool(providerId, (pool) => {
510
+ const index = resolveCredentialIndex(pool, ref);
511
+ if (index === -1) throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
512
+ if (index === 0) return pool;
513
+ const next = [...pool];
514
+ next.unshift(next.splice(index, 1)[0]);
515
+ return next;
516
+ });
233
517
  }
234
518
 
235
519
  export function markSubscriptionAttempt(providerId: ManagedProviderId, ref: string, attemptAt = Date.now()): SubscriptionCredential[] {
236
- const pool = readSubscriptionPool(providerId);
237
- const index = resolveCredentialIndex(pool, ref);
238
- if (index === -1) throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
239
- pool[index] = {
240
- ...pool[index],
241
- lastAttemptAt: Math.max(pool[index].lastAttemptAt, attemptAt),
242
- };
243
- return writeSubscriptionPool(providerId, pool);
520
+ return mutateSubscriptionPool(providerId, (pool) => {
521
+ const index = resolveCredentialIndex(pool, ref);
522
+ if (index === -1) throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
523
+ const next = [...pool];
524
+ next[index] = { ...pool[index], lastAttemptAt: Math.max(pool[index].lastAttemptAt, attemptAt) };
525
+ return next;
526
+ });
244
527
  }
245
528
 
246
529
  export function markSubscriptionRateLimited(
@@ -250,30 +533,34 @@ export function markSubscriptionRateLimited(
250
533
  attemptAt = Date.now(),
251
534
  rateLimitedAt = Date.now(),
252
535
  ): SubscriptionCredential[] {
253
- const pool = readSubscriptionPool(providerId);
254
- const index = resolveCredentialIndex(pool, ref);
255
- if (index === -1) throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
256
- if (rateLimitedAt < pool[index].lastRateLimitedAt) return pool;
257
- pool[index] = {
258
- ...pool[index],
259
- lastAttemptAt: Math.max(pool[index].lastAttemptAt, attemptAt),
260
- lastRateLimitedAt: Math.max(pool[index].lastRateLimitedAt, rateLimitedAt),
261
- rateLimitedUntil: rateLimitedAt + Math.max(0, retryAfterMs),
262
- };
263
- return writeSubscriptionPool(providerId, pool);
536
+ return mutateSubscriptionPool(providerId, (pool) => {
537
+ const index = resolveCredentialIndex(pool, ref);
538
+ if (index === -1) throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
539
+ // A later rate-limit is authoritative until it expires: an out-of-order (stale) 429 whose
540
+ // basis predates the slot's newest cooldown must not reopen or shorten it.
541
+ if (rateLimitedAt < pool[index].lastRateLimitedAt) return undefined;
542
+ const next = [...pool];
543
+ next[index] = {
544
+ ...pool[index],
545
+ lastAttemptAt: Math.max(pool[index].lastAttemptAt, attemptAt),
546
+ lastRateLimitedAt: Math.max(pool[index].lastRateLimitedAt, rateLimitedAt),
547
+ rateLimitedUntil: rateLimitedAt + Math.max(0, retryAfterMs),
548
+ };
549
+ return next;
550
+ });
264
551
  }
265
552
 
266
553
  export function markSubscriptionSuccess(providerId: ManagedProviderId, ref: string, attemptAt = Date.now()): SubscriptionCredential[] {
267
- const pool = readSubscriptionPool(providerId);
268
- const index = resolveCredentialIndex(pool, ref);
269
- if (index === -1) throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
270
- if (attemptAt < pool[index].lastRateLimitedAt) return pool;
271
- pool[index] = {
272
- ...pool[index],
273
- lastAttemptAt: Math.max(pool[index].lastAttemptAt, attemptAt),
274
- rateLimitedUntil: 0,
275
- };
276
- return writeSubscriptionPool(providerId, pool);
554
+ return mutateSubscriptionPool(providerId, (pool) => {
555
+ const index = resolveCredentialIndex(pool, ref);
556
+ if (index === -1) throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
557
+ // A success whose attempt started before the slot's newest 429 must not clear that cooldown
558
+ // (the memory rule): treat the later rate-limit as authoritative until it expires.
559
+ if (attemptAt < pool[index].lastRateLimitedAt) return undefined;
560
+ const next = [...pool];
561
+ next[index] = { ...pool[index], lastAttemptAt: Math.max(pool[index].lastAttemptAt, attemptAt), rateLimitedUntil: 0 };
562
+ return next;
563
+ });
277
564
  }
278
565
 
279
566
  export function findAvailableSubscription(providerId: ManagedProviderId, now = Date.now()): SubscriptionCredential | undefined {
@@ -291,7 +578,7 @@ export function parseRetryAfterHeader(value: string | undefined, now = Date.now(
291
578
  }
292
579
 
293
580
  function thinkingLevelOfRef(ref: string): ThinkingLevel | undefined {
294
- const match = ref.trim().match(/:(thinking|off|minimal|low|medium|high|xhigh)$/i);
581
+ const match = ref.trim().match(/:(thinking|off|minimal|low|medium|high|xhigh|max)$/i);
295
582
  if (!match) return undefined;
296
583
  const level = match[1].toLowerCase();
297
584
  return level === "off" || level === "thinking" ? undefined : (level as ThinkingLevel);
@@ -301,7 +588,7 @@ function normalizeModelRef(ref: string): string {
301
588
  return ref
302
589
  .trim()
303
590
  .replace(/^[^/]+\//, "")
304
- .replace(/:(?:thinking|off|minimal|low|medium|high|xhigh)$/i, "")
591
+ .replace(/:(?:thinking|off|minimal|low|medium|high|xhigh|max)$/i, "")
305
592
  .toLowerCase();
306
593
  }
307
594
 
@@ -313,7 +600,7 @@ function splitModelRef(ref: string): FallbackTarget {
313
600
  throw new Error(`Unsupported fallback provider: ${providerId}`);
314
601
  }
315
602
  const rawModelId = slash === -1 ? trimmed : trimmed.slice(slash + 1).trim();
316
- const thinkingMatch = rawModelId.match(/^(.*?)(?::(thinking|off|minimal|low|medium|high|xhigh))$/i);
603
+ const thinkingMatch = rawModelId.match(/^(.*?)(?::(thinking|off|minimal|low|medium|high|xhigh|max))$/i);
317
604
  return {
318
605
  providerId,
319
606
  modelId: thinkingMatch ? thinkingMatch[1] : rawModelId,
@@ -325,28 +612,11 @@ function splitModelRef(ref: string): FallbackTarget {
325
612
  };
326
613
  }
327
614
 
328
- // Vendored copy of crouter's `defaultModelLaddersConfig()` (src/types.ts).
329
- // This package is synced from a standalone repo (pi-personal-extensions) and
330
- // has no runtime import path into crouter's compiled internals, so the
331
- // builtin rungs are duplicated here rather than imported; keep the two in
332
- // sync by hand if either changes.
333
- const BUILTIN_MODEL_LADDERS: Record<LadderProviderId, ModelLadderConfig> = {
334
- anthropic: {
335
- ultra: "anthropic/claude-fable-5:high",
336
- strong: "anthropic/claude-opus-4-8:high",
337
- medium: "anthropic/claude-sonnet-5:high",
338
- light: "anthropic/claude-haiku-4-5:high",
339
- },
340
- openai: {
341
- ultra: "openai-codex/gpt-5.5:xhigh",
342
- strong: "openai-codex/gpt-5.5:high",
343
- medium: "openai-codex/gpt-5.5:medium",
344
- light: "openai-codex/gpt-5.3-codex-spark",
345
- },
346
- };
347
-
348
- // Layers ~/.crouter/config.json's `modelLadders` (if any) OVER the builtin
349
- // defaults above, per provider/strength — mirroring crouter's own
615
+ // Layers ~/.crouter/config.json's `modelLadders` (if any) OVER crouter's
616
+ // builtin ladder (`defaultModelLaddersConfig()`, src/types.ts imported
617
+ // directly: this package's canonical home is the crouter tree, so the
618
+ // relative import always resolves, to dist/types.js at runtime), per
619
+ // provider/strength mirroring crouter's own
350
620
  // `mergeModelLadders` (src/core/config.ts), which is what a node's launcher
351
621
  // actually resolves models from. Without this fallback, a config-less user
352
622
  // (the common case: fresh install, no ~/.crouter/config.json) saw an EMPTY
@@ -358,9 +628,10 @@ const BUILTIN_MODEL_LADDERS: Record<LadderProviderId, ModelLadderConfig> = {
358
628
  function readModelLadders(): ModelLaddersConfig {
359
629
  const config = readJsonFile<{ modelLadders?: Partial<Record<LadderProviderId, Partial<ModelLadderConfig>>> }>(CROUTER_CONFIG_FILE);
360
630
  const overrides = config?.modelLadders;
631
+ const builtin = defaultModelLaddersConfig();
361
632
  const result: ModelLaddersConfig = {};
362
633
  for (const provider of ["anthropic", "openai"] as const) {
363
- const normalized: ModelLadderConfig = { ...BUILTIN_MODEL_LADDERS[provider] };
634
+ const normalized: ModelLadderConfig = { ...builtin[provider] };
364
635
  const override = overrides?.[provider];
365
636
  if (override) {
366
637
  for (const strength of ["ultra", "strong", "medium", "light"] as const) {
@@ -389,7 +660,7 @@ function findStrengthForModelId(
389
660
  if (ref && normalizeModelRef(ref) === bareId) matches.push(strength);
390
661
  }
391
662
  if (matches.length === 0) return undefined;
392
- // Rungs that share a bare model id (e.g. gpt-5.5:xhigh vs gpt-5.5:high) are
663
+ // Rungs that share a bare model id (e.g. gpt-5.6-sol:max vs gpt-5.6-sol:high) are
393
664
  // disambiguated by the live reasoning level so an ultra run doesn't collapse to strong.
394
665
  if (matches.length > 1 && currentThinkingLevel) {
395
666
  const exact = matches.find((strength) => thinkingLevelOfRef(source[strength]!) === currentThinkingLevel);
@@ -1,11 +1,11 @@
1
1
  {
2
- "name": "pi-personal-extensions",
2
+ "name": "pi-crtr-extensions",
3
3
  "version": "0.1.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
- "name": "pi-personal-extensions",
8
+ "name": "pi-crtr-extensions",
9
9
  "version": "0.1.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {