@crouton-kit/crouter 0.3.78 → 0.3.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/build-root.d.ts +12 -4
  2. package/dist/build-root.js +25 -6
  3. package/dist/builtin-memory/crouter-development/plugins.md +82 -5
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1228 -12
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +3 -3
  6. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -787
  7. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +8 -3
  8. package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
  9. package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
  10. package/dist/clients/attach/__tests__/mermaid-render.test.js +9 -1
  11. package/dist/clients/attach/attach-cmd.d.ts +9 -1
  12. package/dist/clients/attach/attach-cmd.js +849 -803
  13. package/dist/clients/attach/auth-pickers.d.ts +0 -12
  14. package/dist/clients/attach/auth-pickers.js +64 -15
  15. package/dist/clients/attach/graph-overlay.d.ts +12 -2
  16. package/dist/clients/attach/graph-overlay.js +83 -33
  17. package/dist/commands/human/queue.js +3 -4
  18. package/dist/commands/pkg/plugin-inspect.js +22 -1
  19. package/dist/commands/pkg/plugin-manage.js +31 -9
  20. package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
  21. package/dist/commands/sys/doctor.js +42 -4
  22. package/dist/commands/sys/setup-core.d.ts +37 -0
  23. package/dist/commands/sys/setup-core.js +138 -1
  24. package/dist/commands/sys/setup.d.ts +88 -0
  25. package/dist/commands/sys/setup.js +915 -171
  26. package/dist/commands/view-pick.d.ts +4 -0
  27. package/dist/commands/view-pick.js +17 -7
  28. package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
  29. package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
  30. package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
  31. package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
  32. package/dist/core/__tests__/command-plugins.test.js +444 -0
  33. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  34. package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
  35. package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
  36. package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
  37. package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
  38. package/dist/core/canvas/browse/app.d.ts +6 -0
  39. package/dist/core/canvas/browse/app.js +71 -41
  40. package/dist/core/command-plugins/adapter.d.ts +15 -0
  41. package/dist/core/command-plugins/adapter.js +145 -0
  42. package/dist/core/command-plugins/compose.d.ts +5 -0
  43. package/dist/core/command-plugins/compose.js +56 -0
  44. package/dist/core/command-plugins/discovery.d.ts +104 -0
  45. package/dist/core/command-plugins/discovery.js +565 -0
  46. package/dist/core/config.d.ts +2 -5
  47. package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
  48. package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
  49. package/dist/core/keybindings/__tests__/resolve.test.js +2 -2
  50. package/dist/core/keybindings/catalog.d.ts +3 -3
  51. package/dist/core/keybindings/catalog.js +2 -1
  52. package/dist/core/profiles/select.d.ts +6 -0
  53. package/dist/core/profiles/select.js +86 -52
  54. package/dist/core/provider-management.d.ts +12 -0
  55. package/dist/core/provider-management.js +24 -0
  56. package/dist/core/runtime/broker.js +7 -6
  57. package/dist/core/runtime/pi-vendored.d.ts +8 -0
  58. package/dist/core/runtime/pi-vendored.js +14 -0
  59. package/dist/core/runtime/recap.d.ts +1 -1
  60. package/dist/core/runtime/recap.js +50 -25
  61. package/dist/core/runtime/session-list-cache.d.ts +10 -0
  62. package/dist/core/runtime/session-list-cache.js +94 -26
  63. package/dist/core/runtime/spawn.js +1 -13
  64. package/dist/core/runtime/tmux.js +2 -1
  65. package/dist/core/scope.js +27 -4
  66. package/dist/core/subscription-state.d.ts +90 -0
  67. package/dist/core/subscription-state.js +762 -0
  68. package/dist/daemon/crtrd.js +253 -12
  69. package/dist/pi-extensions/canvas-recap.js +43 -17
  70. package/dist/types.d.ts +6 -13
  71. package/dist/types.js +0 -3
  72. package/package.json +7 -3
@@ -1,787 +1,2 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
- import { defaultModelLaddersConfig } from "../../../types.js";
3
- import { homedir } from "node:os";
4
- import { dirname, join } from "node:path";
5
- import type { ThinkingLevel } from "@earendil-works/pi-ai";
6
- import { FileAuthStorageBackend } from "@earendil-works/pi-coding-agent";
7
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
-
9
- export const ANTHROPIC_PROVIDER_ID = "anthropic";
10
- export const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
11
-
12
- export type ManagedProviderId = typeof ANTHROPIC_PROVIDER_ID | typeof OPENAI_CODEX_PROVIDER_ID;
13
- export type LadderProviderId = "anthropic" | "openai";
14
- export type ModelStrength = "ultra" | "strong" | "medium" | "light";
15
-
16
- // The default label's slot (`label === providerId`) is the OAuth-login-linked credential --
17
- // its live value (`refresh`/`access`/`expires`) is owned exclusively by pi's auth.json (see
18
- // provider-rotation.ts's `refreshDefaultSlotCredential`/`reauthenticateCredential`), so those
19
- // fields are absent on that entry. Any OTHER explicitly-added account (`/provider-sub add`,
20
- // label !== providerId) keeps the full value here, unchanged.
21
- export type SubscriptionCredential = {
22
- label: string;
23
- refresh?: string;
24
- access?: string;
25
- expires?: number;
26
- rateLimitedUntil: number;
27
- lastAttemptAt: number;
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;
34
- };
35
-
36
- export type ModelRef = {
37
- providerId: ManagedProviderId;
38
- modelId: string;
39
- };
40
-
41
- export type RotationConfig = {
42
- defaultFallbackStrength?: ModelStrength;
43
- revertWhenAvailable?: boolean;
44
- preferredModel?: ModelRef;
45
- };
46
-
47
- type RotationConfigUpdate = {
48
- defaultFallbackStrength?: ModelStrength;
49
- revertWhenAvailable?: boolean;
50
- preferredModel?: ModelRef | null;
51
- };
52
-
53
- export type FallbackTarget = {
54
- providerId: ManagedProviderId;
55
- modelId: string;
56
- label: string;
57
- strength: ModelStrength;
58
- thinkingLevel?: ThinkingLevel;
59
- };
60
-
61
- type ModelLadderConfig = Partial<Record<ModelStrength, string>>;
62
- type ModelLaddersConfig = Partial<Record<LadderProviderId, ModelLadderConfig>>;
63
- const AGENT_DIR = join(homedir(), ".pi", "agent");
64
- const ROTATION_CONFIG_FILE = join(AGENT_DIR, "provider-rotation.json");
65
- const CROUTER_CONFIG_FILE = join(homedir(), ".crouter", "config.json");
66
- const DEFAULT_FALLBACK_STRENGTH: ModelStrength = "strong";
67
-
68
- const PROVIDERS: Record<
69
- ManagedProviderId,
70
- {
71
- label: string;
72
- ladderProviderId: LadderProviderId;
73
- poolFile: string;
74
- }
75
- > = {
76
- [ANTHROPIC_PROVIDER_ID]: {
77
- label: "Claude",
78
- ladderProviderId: "anthropic",
79
- poolFile: join(AGENT_DIR, "anthropic-subscriptions.json"),
80
- },
81
- [OPENAI_CODEX_PROVIDER_ID]: {
82
- label: "OpenAI Codex",
83
- ladderProviderId: "openai",
84
- poolFile: join(AGENT_DIR, "openai-codex-subscriptions.json"),
85
- },
86
- };
87
-
88
- function readJsonFile<T>(path: string): T | undefined {
89
- if (!existsSync(path)) return undefined;
90
- try {
91
- const raw = readFileSync(path, "utf8").trim();
92
- if (!raw) return undefined;
93
- return JSON.parse(raw) as T;
94
- } catch {
95
- return undefined;
96
- }
97
- }
98
-
99
- function writeJsonFile(path: string, value: unknown, mode?: number): void {
100
- mkdirSync(dirname(path), { recursive: true });
101
- const tmp = `${path}.${process.pid}.tmp`;
102
- writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, mode === undefined ? "utf8" : { encoding: "utf8", mode });
103
- if (mode !== undefined) chmodSync(tmp, mode);
104
- renameSync(tmp, path);
105
- }
106
-
107
- function normalizeStrength(value: unknown): ModelStrength | undefined {
108
- return value === "ultra" || value === "strong" || value === "medium" || value === "light" ? value : undefined;
109
- }
110
-
111
- function normalizeTiming(value: unknown): number {
112
- const numeric = Number(value);
113
- return Number.isFinite(numeric) ? numeric : 0;
114
- }
115
-
116
- function normalizeCredential(value: Partial<SubscriptionCredential> & { label?: string }): SubscriptionCredential | undefined {
117
- const label = typeof value.label === "string" ? value.label.trim() : "";
118
- if (!label) return undefined;
119
- const refresh = typeof value.refresh === "string" ? value.refresh.trim() : "";
120
- const access = typeof value.access === "string" ? value.access.trim() : "";
121
- const accountId = typeof value.accountId === "string" ? value.accountId.trim() : "";
122
- const expires = Number(value.expires);
123
- const rateLimitedUntil = Number(value.rateLimitedUntil);
124
- return {
125
- label,
126
- ...(refresh && access ? { refresh, access, expires: Number.isFinite(expires) ? expires : 0 } : {}),
127
- ...(accountId ? { accountId } : {}),
128
- rateLimitedUntil: Number.isFinite(rateLimitedUntil) ? rateLimitedUntil : 0,
129
- lastAttemptAt: normalizeTiming(value.lastAttemptAt),
130
- lastRateLimitedAt: normalizeTiming(value.lastRateLimitedAt),
131
- };
132
- }
133
-
134
- function normalizeCredentials(values: unknown): SubscriptionCredential[] {
135
- if (!Array.isArray(values)) return [];
136
- return values.map((entry) => normalizeCredential(entry as Partial<SubscriptionCredential> & { label?: string })).filter((entry): entry is SubscriptionCredential => Boolean(entry));
137
- }
138
-
139
- function getCurrentModelRef(model?: { provider: string; id: string }): ModelRef | undefined {
140
- return isManagedProvider(model?.provider) ? { providerId: model.provider, modelId: model.id } : undefined;
141
- }
142
-
143
- type AuthFileData = Record<string, { type?: string; accountId?: unknown }>;
144
-
145
- function readAuthCredential(providerId: ManagedProviderId): { type?: string; accountId?: string } | undefined {
146
- return new FileAuthStorageBackend().withLock((current) => {
147
- if (!current) return { result: undefined };
148
- const data = JSON.parse(current) as AuthFileData;
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 } };
153
- });
154
- }
155
-
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.
172
- function ensureDefaultSubscriptionMetadata(providerId: ManagedProviderId, pool: SubscriptionCredential[]): SubscriptionCredential[] {
173
- const authCred = readAuthCredential(providerId);
174
- if (authCred?.type !== "oauth") return 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;
197
- }
198
-
199
- // The default label's value lives exclusively in auth.json, so its pool entry must never
200
- // carry `refresh`/`access`/`expires`; this strips those fields on every read and write,
201
- // self-healing any pool file that has them physically present on disk. `changed` tells
202
- // the caller whether anything was actually stripped, so `readSubscriptionPool` can
203
- // persist the metadata-only result immediately rather than leaving a stale copy on disk
204
- // until some unrelated write.
205
- function scrubDefaultValue(providerId: ManagedProviderId, pool: SubscriptionCredential[]): { pool: SubscriptionCredential[]; changed: boolean } {
206
- let changed = false;
207
- const next = pool.map((entry) => {
208
- if (entry.label !== providerId || (entry.refresh === undefined && entry.access === undefined && entry.expires === undefined)) return entry;
209
- changed = true;
210
- const { refresh: _refresh, access: _access, expires: _expires, ...meta } = entry;
211
- return meta;
212
- });
213
- return { pool: next, changed };
214
- }
215
-
216
- export function isManagedProvider(providerId: string | undefined): providerId is ManagedProviderId {
217
- return providerId === ANTHROPIC_PROVIDER_ID || providerId === OPENAI_CODEX_PROVIDER_ID;
218
- }
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
-
229
- export function getProviderLabel(providerId: ManagedProviderId): string {
230
- return PROVIDERS[providerId].label;
231
- }
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
-
289
- export function readSubscriptionPool(providerId: ManagedProviderId): SubscriptionCredential[] {
290
- const poolFile = PROVIDERS[providerId].poolFile;
291
- const raw = normalizeCredentials(readJsonFile<unknown>(poolFile));
292
- const { pool: scrubbed, changed: wasScrubbed } = scrubDefaultValue(providerId, raw);
293
- const withDefaultSlot = ensureDefaultSubscriptionMetadata(providerId, scrubbed);
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
- }
304
- return withDefaultSlot;
305
- }
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.
310
- export function writeSubscriptionPool(providerId: ManagedProviderId, next: SubscriptionCredential[]): SubscriptionCredential[] {
311
- return mutateSubscriptionPool(providerId, () => next);
312
- }
313
-
314
- function resolveCredentialIndex(pool: SubscriptionCredential[], ref: string): number {
315
- const needle = ref.trim().toLowerCase();
316
- const numeric = Number.parseInt(needle, 10);
317
- if (Number.isFinite(numeric) && String(numeric) === needle && numeric >= 1 && numeric <= pool.length) {
318
- return numeric - 1;
319
- }
320
- return pool.findIndex((entry) => entry.label.toLowerCase() === needle);
321
- }
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
-
342
- export function upsertSubscription(providerId: ManagedProviderId, next: SubscriptionCredential): SubscriptionCredential[] {
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
- });
496
- }
497
-
498
- export function removeSubscription(providerId: ManagedProviderId, ref: string): SubscriptionCredential[] {
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
- });
506
- }
507
-
508
- export function promoteSubscription(providerId: ManagedProviderId, ref: string): SubscriptionCredential[] {
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
- });
517
- }
518
-
519
- export function markSubscriptionAttempt(providerId: ManagedProviderId, ref: string, attemptAt = Date.now()): SubscriptionCredential[] {
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
- });
527
- }
528
-
529
- export function markSubscriptionRateLimited(
530
- providerId: ManagedProviderId,
531
- ref: string,
532
- retryAfterMs: number,
533
- attemptAt = Date.now(),
534
- rateLimitedAt = Date.now(),
535
- ): SubscriptionCredential[] {
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
- });
551
- }
552
-
553
- export function markSubscriptionSuccess(providerId: ManagedProviderId, ref: string, attemptAt = Date.now()): SubscriptionCredential[] {
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
- });
564
- }
565
-
566
- export function findAvailableSubscription(providerId: ManagedProviderId, now = Date.now()): SubscriptionCredential | undefined {
567
- return readSubscriptionPool(providerId).find((entry) => !entry.rateLimitedUntil || entry.rateLimitedUntil <= now);
568
- }
569
-
570
- export function parseRetryAfterHeader(value: string | undefined, now = Date.now()): number | undefined {
571
- if (!value) return undefined;
572
- const trimmed = value.trim();
573
- if (!trimmed) return undefined;
574
- const seconds = Number(trimmed);
575
- if (Number.isFinite(seconds)) return Math.max(0, Math.round(seconds * 1000));
576
- const parsed = Date.parse(trimmed);
577
- return Number.isFinite(parsed) ? Math.max(0, parsed - now) : undefined;
578
- }
579
-
580
- function thinkingLevelOfRef(ref: string): ThinkingLevel | undefined {
581
- const match = ref.trim().match(/:(thinking|off|minimal|low|medium|high|xhigh|max)$/i);
582
- if (!match) return undefined;
583
- const level = match[1].toLowerCase();
584
- return level === "off" || level === "thinking" ? undefined : (level as ThinkingLevel);
585
- }
586
-
587
- function normalizeModelRef(ref: string): string {
588
- return ref
589
- .trim()
590
- .replace(/^[^/]+\//, "")
591
- .replace(/:(?:thinking|off|minimal|low|medium|high|xhigh|max)$/i, "")
592
- .toLowerCase();
593
- }
594
-
595
- function splitModelRef(ref: string): FallbackTarget {
596
- const trimmed = ref.trim();
597
- const slash = trimmed.indexOf("/");
598
- const providerId = slash === -1 ? OPENAI_CODEX_PROVIDER_ID : trimmed.slice(0, slash).trim();
599
- if (!isManagedProvider(providerId)) {
600
- throw new Error(`Unsupported fallback provider: ${providerId}`);
601
- }
602
- const rawModelId = slash === -1 ? trimmed : trimmed.slice(slash + 1).trim();
603
- const thinkingMatch = rawModelId.match(/^(.*?)(?::(thinking|off|minimal|low|medium|high|xhigh|max))$/i);
604
- return {
605
- providerId,
606
- modelId: thinkingMatch ? thinkingMatch[1] : rawModelId,
607
- label: `${providerId}/${thinkingMatch ? thinkingMatch[1] : rawModelId}${thinkingMatch?.[2] ? `:${thinkingMatch[2].toLowerCase()}` : ""}`,
608
- strength: DEFAULT_FALLBACK_STRENGTH,
609
- thinkingLevel: thinkingMatch?.[2] && thinkingMatch[2].toLowerCase() !== "off" && thinkingMatch[2].toLowerCase() !== "thinking"
610
- ? (thinkingMatch[2].toLowerCase() as ThinkingLevel)
611
- : undefined,
612
- };
613
- }
614
-
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
620
- // `mergeModelLadders` (src/core/config.ts), which is what a node's launcher
621
- // actually resolves models from. Without this fallback, a config-less user
622
- // (the common case: fresh install, no ~/.crouter/config.json) saw an EMPTY
623
- // ladder here while crtr itself launched them on the builtin defaults —
624
- // rotation could then never resolve a strength tier or a cross-provider
625
- // fallback for a model it was already running. Always returns a fully
626
- // populated ladder for both providers; a config.json override only replaces
627
- // the specific strengths it sets.
628
- function readModelLadders(): ModelLaddersConfig {
629
- const config = readJsonFile<{ modelLadders?: Partial<Record<LadderProviderId, Partial<ModelLadderConfig>>> }>(CROUTER_CONFIG_FILE);
630
- const overrides = config?.modelLadders;
631
- const builtin = defaultModelLaddersConfig();
632
- const result: ModelLaddersConfig = {};
633
- for (const provider of ["anthropic", "openai"] as const) {
634
- const normalized: ModelLadderConfig = { ...builtin[provider] };
635
- const override = overrides?.[provider];
636
- if (override) {
637
- for (const strength of ["ultra", "strong", "medium", "light"] as const) {
638
- const value = typeof override[strength] === "string" ? override[strength]?.trim() : "";
639
- if (value) normalized[strength] = value;
640
- }
641
- }
642
- result[provider] = normalized;
643
- }
644
- return result;
645
- }
646
-
647
- function findStrengthForModelId(
648
- providerId: ManagedProviderId,
649
- modelId: string | undefined,
650
- defaultStrength: ModelStrength,
651
- ladders = readModelLadders(),
652
- currentThinkingLevel?: string,
653
- ): ModelStrength | undefined {
654
- const source = ladders?.[PROVIDERS[providerId].ladderProviderId];
655
- const bareId = modelId ? normalizeModelRef(modelId) : undefined;
656
- if (!source || !bareId) return undefined;
657
- const matches: ModelStrength[] = [];
658
- for (const strength of ["ultra", "strong", "medium", "light"] as const) {
659
- const ref = source[strength];
660
- if (ref && normalizeModelRef(ref) === bareId) matches.push(strength);
661
- }
662
- if (matches.length === 0) return undefined;
663
- // Rungs that share a bare model id (e.g. gpt-5.6-sol:max vs gpt-5.6-sol:high) are
664
- // disambiguated by the live reasoning level so an ultra run doesn't collapse to strong.
665
- if (matches.length > 1 && currentThinkingLevel) {
666
- const exact = matches.find((strength) => thinkingLevelOfRef(source[strength]!) === currentThinkingLevel);
667
- if (exact) return exact;
668
- }
669
- return matches.includes(defaultStrength) ? defaultStrength : matches[0];
670
- }
671
-
672
- export function readRotationConfig(): RotationConfig {
673
- const raw = readJsonFile<RotationConfig>(ROTATION_CONFIG_FILE) ?? {};
674
- const preferredModel =
675
- raw.preferredModel && isManagedProvider(raw.preferredModel.providerId) && typeof raw.preferredModel.modelId === "string"
676
- ? { providerId: raw.preferredModel.providerId, modelId: raw.preferredModel.modelId.trim() }
677
- : undefined;
678
- return {
679
- defaultFallbackStrength: normalizeStrength(raw.defaultFallbackStrength) ?? DEFAULT_FALLBACK_STRENGTH,
680
- revertWhenAvailable: raw.revertWhenAvailable ?? true,
681
- preferredModel,
682
- };
683
- }
684
-
685
- export function writeRotationConfig(next: RotationConfigUpdate): RotationConfig {
686
- const current = readRotationConfig();
687
- const merged: RotationConfig = {
688
- defaultFallbackStrength: normalizeStrength(next.defaultFallbackStrength) ?? current.defaultFallbackStrength ?? DEFAULT_FALLBACK_STRENGTH,
689
- revertWhenAvailable: next.revertWhenAvailable ?? current.revertWhenAvailable ?? true,
690
- preferredModel: Object.prototype.hasOwnProperty.call(next, "preferredModel")
691
- ? next.preferredModel || undefined
692
- : current.preferredModel,
693
- };
694
- writeJsonFile(ROTATION_CONFIG_FILE, merged);
695
- return merged;
696
- }
697
-
698
- export function clearPreferredModel(): RotationConfig {
699
- return writeRotationConfig({ preferredModel: null });
700
- }
701
-
702
- export function rememberPreferredModel(model: ModelRef): RotationConfig {
703
- return writeRotationConfig({ preferredModel: model });
704
- }
705
-
706
- // `readModelLadders()` always fills both providers from the builtin defaults, so this
707
- // only returns undefined (never throws) in the defensive case where a rung is still
708
- // genuinely missing; callers must treat that as a graceful "no fallback available" case
709
- // rather than a config error to surface mid-turn.
710
- export function resolveFallbackTarget(
711
- currentProviderId: ManagedProviderId,
712
- currentModelId?: string,
713
- config = readRotationConfig(),
714
- currentThinkingLevel?: string,
715
- ): FallbackTarget | undefined {
716
- const ladders = readModelLadders();
717
- const defaultStrength = config.defaultFallbackStrength ?? DEFAULT_FALLBACK_STRENGTH;
718
- const sourceStrength = findStrengthForModelId(currentProviderId, currentModelId, defaultStrength, ladders, currentThinkingLevel);
719
- const strength = sourceStrength ?? defaultStrength;
720
- const targetProvider = currentProviderId === ANTHROPIC_PROVIDER_ID ? OPENAI_CODEX_PROVIDER_ID : ANTHROPIC_PROVIDER_ID;
721
- const targetLadder = ladders?.[PROVIDERS[targetProvider].ladderProviderId];
722
- const candidates = strength === defaultStrength ? [strength] : [strength, defaultStrength];
723
- const fallbackRef = candidates.map((candidate) => targetLadder?.[candidate]).find((ref): ref is string => typeof ref === "string" && ref.trim().length > 0);
724
- if (!fallbackRef) {
725
- return undefined;
726
- }
727
- const parsed = splitModelRef(fallbackRef);
728
- const resolvedStrength = candidates.find((candidate) => targetLadder?.[candidate] === fallbackRef) ?? strength;
729
- return { ...parsed, strength: resolvedStrength };
730
- }
731
-
732
- export function shouldRestorePreferredModel(config = readRotationConfig(), now = Date.now()): boolean {
733
- if (!config.revertWhenAvailable || !config.preferredModel) return false;
734
- return Boolean(findAvailableSubscription(config.preferredModel.providerId, now));
735
- }
736
-
737
- export async function restorePreferredModelIfPossible(
738
- piApi: ExtensionAPI,
739
- ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
740
- ): Promise<boolean> {
741
- const config = readRotationConfig();
742
- if (!shouldRestorePreferredModel(config, Date.now())) return false;
743
- const preferred = config.preferredModel;
744
- if (!preferred) return false;
745
- const current = getCurrentModelRef(ctx.model);
746
- if (current?.providerId === preferred.providerId && current.modelId === preferred.modelId) {
747
- clearPreferredModel();
748
- return true;
749
- }
750
- const model = ctx.modelRegistry.find(preferred.providerId, preferred.modelId);
751
- if (!model) return false;
752
- const switched = await piApi.setModel(model);
753
- if (switched) clearPreferredModel();
754
- return switched;
755
- }
756
-
757
- export async function switchToFallbackIfPossible(
758
- piApi: ExtensionAPI,
759
- ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
760
- preferredModel = getCurrentModelRef(ctx.model),
761
- ): Promise<FallbackTarget | undefined> {
762
- if (!preferredModel) return undefined;
763
- rememberPreferredModel(preferredModel);
764
- const currentThinkingLevel = piApi.getThinkingLevel?.();
765
- const target = resolveFallbackTarget(preferredModel.providerId, preferredModel.modelId, readRotationConfig(), currentThinkingLevel);
766
- if (!target) return undefined;
767
- const current = getCurrentModelRef(ctx.model);
768
- const model = ctx.modelRegistry.find(target.providerId, target.modelId);
769
- if (!model) return undefined;
770
- if (current?.providerId !== target.providerId || current.modelId !== target.modelId) {
771
- const switched = await piApi.setModel(model);
772
- if (!switched) return undefined;
773
- }
774
- if (target.thinkingLevel) piApi.setThinkingLevel(target.thinkingLevel);
775
- return target;
776
- }
777
-
778
- export function formatStatusLine(providerId: ManagedProviderId, now = Date.now()): string {
779
- const pool = readSubscriptionPool(providerId);
780
- const ready = pool.filter((entry) => !entry.rateLimitedUntil || entry.rateLimitedUntil <= now);
781
- const label = getProviderLabel(providerId);
782
- if (ready.length === 0) {
783
- const fallback = resolveFallbackTarget(providerId, undefined, readRotationConfig());
784
- return fallback ? `${label} pool exhausted -> ${fallback.label}` : `${label} pool exhausted (no fallback configured)`;
785
- }
786
- return `${label} ${ready[0]?.label ?? providerId} (${ready.length}/${pool.length} ready)`;
787
- }
1
+ // Provider pool state belongs to crouter core so every human surface and the rotation runtime share one invariant-preserving implementation.
2
+ export * from "../../../core/subscription-state.js";