@cortexkit/anthropic-auth-core 1.16.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/accounts.js CHANGED
@@ -5,6 +5,7 @@ import { dirname, join } from 'node:path';
5
5
  import { parseRetryAfterHeader, refreshClaudeOAuthToken } from "./auth.js";
6
6
  import { CACHE_1H_MODES, CLAUDE_CODE_VERSION, DEFAULT_CACHE_1H_MODE, } from "./constants.js";
7
7
  import { log, logger } from "./logger.js";
8
+ import { tokenFingerprint } from "./token-fingerprint.js";
8
9
  const setRefreshLockRenewalTimeout = globalThis.setTimeout.bind(globalThis);
9
10
  const clearRefreshLockRenewalTimeout = globalThis.clearTimeout.bind(globalThis);
10
11
  export const ACCOUNT_FILE_NAME = 'anthropic-auth.json';
@@ -124,6 +125,28 @@ function normalizeAccount(value) {
124
125
  lastRefreshError: normalizeOperationError(value.lastRefreshError),
125
126
  lastQuotaRefreshError: normalizeOperationError(value.lastQuotaRefreshError),
126
127
  quota: normalizeQuota(value.quota),
128
+ profile: normalizeOAuthAccountProfile(value.profile),
129
+ };
130
+ }
131
+ function normalizeOAuthAccountProfile(value) {
132
+ if (!isRecord(value))
133
+ return undefined;
134
+ if (typeof value.tier !== 'string' ||
135
+ !value.tier.trim() ||
136
+ typeof value.orgType !== 'string' ||
137
+ !value.orgType.trim() ||
138
+ typeof value.checkedAt !== 'number' ||
139
+ !Number.isFinite(value.checkedAt)) {
140
+ return undefined;
141
+ }
142
+ return {
143
+ tier: value.tier.trim(),
144
+ orgType: value.orgType.trim(),
145
+ checkedAt: value.checkedAt,
146
+ ...(typeof value.tokenFingerprint === 'string' &&
147
+ value.tokenFingerprint.trim() && {
148
+ tokenFingerprint: value.tokenFingerprint.trim(),
149
+ }),
127
150
  };
128
151
  }
129
152
  function normalizeOperationError(value) {
@@ -220,8 +243,56 @@ function normalizeQuota(value) {
220
243
  // — only inputs that already carried an array reach this line.
221
244
  quota.scoped = scoped;
222
245
  }
246
+ if (isRecord(value.extraUsage)) {
247
+ const used = normalizeQuotaMoney(value.extraUsage.used);
248
+ const limit = normalizeQuotaMoney(value.extraUsage.limit);
249
+ if (used && limit && typeof value.extraUsage.exhausted === 'boolean') {
250
+ quota.extraUsage = {
251
+ used,
252
+ limit,
253
+ ...(typeof value.extraUsage.utilizationPercent === 'number' &&
254
+ Number.isFinite(value.extraUsage.utilizationPercent) && {
255
+ utilizationPercent: value.extraUsage.utilizationPercent,
256
+ }),
257
+ ...(typeof value.extraUsage.severity === 'string' && {
258
+ severity: value.extraUsage.severity,
259
+ }),
260
+ exhausted: value.extraUsage.exhausted,
261
+ };
262
+ }
263
+ }
264
+ if (typeof value.bindingWindow === 'string' && value.bindingWindow.trim()) {
265
+ quota.bindingWindow = value.bindingWindow.trim();
266
+ }
267
+ if (value.bindingWindowSource === 'poll' ||
268
+ value.bindingWindowSource === 'headers') {
269
+ quota.bindingWindowSource = value.bindingWindowSource;
270
+ }
271
+ if (typeof value.fallbackAdvised === 'boolean') {
272
+ quota.fallbackAdvised = value.fallbackAdvised;
273
+ }
274
+ if (value.source === 'poll' || value.source === 'headers') {
275
+ quota.source = value.source;
276
+ }
223
277
  return Object.keys(quota).length ? quota : undefined;
224
278
  }
279
+ function normalizeQuotaMoney(value) {
280
+ if (!isRecord(value))
281
+ return undefined;
282
+ if (typeof value.amountMinor !== 'number' ||
283
+ !Number.isFinite(value.amountMinor) ||
284
+ typeof value.currency !== 'string' ||
285
+ !value.currency.trim() ||
286
+ typeof value.exponent !== 'number' ||
287
+ !Number.isFinite(value.exponent)) {
288
+ return undefined;
289
+ }
290
+ return {
291
+ amountMinor: value.amountMinor,
292
+ currency: value.currency.trim(),
293
+ exponent: value.exponent,
294
+ };
295
+ }
225
296
  // Fresh empty storage shell — main OpenCode OAuth account, no fallback
226
297
  // accounts. Returns a new object each call so mutating callers don't alias.
227
298
  export function createEmptyStorage() {
@@ -236,7 +307,11 @@ function normalizeStorage(value) {
236
307
  return null;
237
308
  return {
238
309
  version: 1,
239
- main: { type: 'opencode', provider: 'anthropic' },
310
+ main: {
311
+ type: 'opencode',
312
+ provider: 'anthropic',
313
+ profile: normalizeOAuthAccountProfile(isRecord(value.main) ? value.main.profile : undefined),
314
+ },
240
315
  routing: isRecord(value.routing) ? value.routing : undefined,
241
316
  fallbackOn: Array.isArray(value.fallbackOn)
242
317
  ? value.fallbackOn.filter((status) => Number.isInteger(status))
@@ -330,6 +405,11 @@ function mergeConfigAndState(configValue, stateValue) {
330
405
  : [];
331
406
  return {
332
407
  ...configValue,
408
+ main: {
409
+ type: 'opencode',
410
+ provider: 'anthropic',
411
+ profile: normalizeOAuthAccountProfile(mainState?.profile),
412
+ },
333
413
  refresh: objectWithDefinedEntries({
334
414
  ...refreshConfig,
335
415
  mainLastRefreshError: mainRefreshSource.lastRefreshError,
@@ -392,52 +472,127 @@ function accountRuntimeState(account) {
392
472
  lastRefreshError: account.lastRefreshError,
393
473
  lastQuotaRefreshError: account.lastQuotaRefreshError,
394
474
  quota: account.quota,
475
+ profile: account.profile,
395
476
  });
396
477
  }
397
478
  function quotaSnapshotCheckedAt(quota) {
398
479
  return Math.max(quota?.five_hour?.checkedAt ?? 0, quota?.seven_day?.checkedAt ?? 0, ...(quota?.scoped?.map((window) => window.checkedAt) ?? []), quota?.checkedAt ?? 0);
399
480
  }
481
+ function quotaSourcePrecedence(quota) {
482
+ if (quota?.source === 'poll')
483
+ return 2;
484
+ if (quota?.source === 'headers')
485
+ return 1;
486
+ return 0;
487
+ }
488
+ function mergeHeaderScopedQuota(existing, incoming) {
489
+ if (!('scoped' in existing))
490
+ return incoming.scoped;
491
+ if (!Array.isArray(existing.scoped) || existing.scoped.length === 0) {
492
+ return existing.scoped;
493
+ }
494
+ if (!Array.isArray(incoming.scoped) || incoming.scoped.length === 0) {
495
+ return existing.scoped;
496
+ }
497
+ const merged = new Map(incoming.scoped.map((window) => [window.id, window]));
498
+ for (const window of existing.scoped) {
499
+ const candidate = merged.get(window.id);
500
+ if (!candidate || window.checkedAt >= candidate.checkedAt) {
501
+ merged.set(window.id, window);
502
+ }
503
+ }
504
+ return [...merged.values()];
505
+ }
506
+ function mergeHeaderOwnedWindow(existingSnapshot, incomingSnapshot, key) {
507
+ const existing = existingSnapshot[key];
508
+ const incoming = incomingSnapshot[key];
509
+ if (!incoming)
510
+ return existing;
511
+ if (!existing)
512
+ return incoming;
513
+ if (incoming.checkedAt > existing.checkedAt)
514
+ return incoming;
515
+ if (incoming.checkedAt < existing.checkedAt)
516
+ return existing;
517
+ return quotaSourcePrecedence(existingSnapshot) >
518
+ quotaSourcePrecedence(incomingSnapshot)
519
+ ? existing
520
+ : incoming;
521
+ }
522
+ function mergeHeaderQuotaForPersistence(existing, incoming) {
523
+ if (!existing || incoming.source !== 'headers')
524
+ return incoming;
525
+ const preservePollBinding = existing.bindingWindowSource === 'poll';
526
+ return {
527
+ ...existing,
528
+ ...incoming,
529
+ five_hour: mergeHeaderOwnedWindow(existing, incoming, 'five_hour'),
530
+ seven_day: mergeHeaderOwnedWindow(existing, incoming, 'seven_day'),
531
+ scoped: mergeHeaderScopedQuota(existing, incoming),
532
+ extraUsage: existing.extraUsage ?? incoming.extraUsage,
533
+ bindingWindow: preservePollBinding
534
+ ? existing.bindingWindow
535
+ : (incoming.bindingWindow ?? existing.bindingWindow),
536
+ bindingWindowSource: preservePollBinding
537
+ ? 'poll'
538
+ : (incoming.bindingWindowSource ?? existing.bindingWindowSource),
539
+ };
540
+ }
400
541
  function mergeAccountRuntimeState(existing, incoming) {
401
542
  if (!isRecord(existing))
402
543
  return incoming;
403
544
  const existingEntry = existing;
404
- const existingQuotaCheckedAt = quotaSnapshotCheckedAt(existingEntry.quota);
405
- const incomingQuotaCheckedAt = quotaSnapshotCheckedAt(incoming.quota);
406
545
  const tokenChanged = Boolean((existingEntry.access &&
407
546
  incoming.access &&
408
547
  existingEntry.access !== incoming.access) ||
409
548
  (existingEntry.refresh &&
410
549
  incoming.refresh &&
411
550
  existingEntry.refresh !== incoming.refresh));
412
- if (existingQuotaCheckedAt > incomingQuotaCheckedAt) {
551
+ const mergesHeaderQuota = Boolean(!tokenChanged && incoming.quota?.source === 'headers');
552
+ const effectiveIncoming = mergesHeaderQuota && incoming.quota
553
+ ? {
554
+ ...incoming,
555
+ quota: mergeHeaderQuotaForPersistence(existingEntry.quota, incoming.quota),
556
+ }
557
+ : incoming;
558
+ const existingQuotaCheckedAt = quotaSnapshotCheckedAt(existingEntry.quota);
559
+ const incomingQuotaCheckedAt = quotaSnapshotCheckedAt(effectiveIncoming.quota);
560
+ const existingQuotaWinsEqualTimestamp = Boolean(existingQuotaCheckedAt === incomingQuotaCheckedAt &&
561
+ quotaSourcePrecedence(existingEntry.quota) >
562
+ quotaSourcePrecedence(effectiveIncoming.quota));
563
+ if (!mergesHeaderQuota &&
564
+ (existingQuotaCheckedAt > incomingQuotaCheckedAt ||
565
+ existingQuotaWinsEqualTimestamp)) {
413
566
  const existingRefreshAt = existingEntry.lastRefreshedAt ?? 0;
414
- const incomingRefreshAt = incoming.lastRefreshedAt ?? 0;
567
+ const incomingRefreshAt = effectiveIncoming.lastRefreshedAt ?? 0;
415
568
  if (tokenChanged && incomingRefreshAt <= existingRefreshAt) {
416
569
  const merged = { ...existingEntry };
417
- if (typeof incoming.lastUsed === 'number' &&
570
+ if (typeof effectiveIncoming.lastUsed === 'number' &&
418
571
  (!(typeof existingEntry.lastUsed === 'number') ||
419
- incoming.lastUsed > existingEntry.lastUsed)) {
420
- merged.lastUsed = incoming.lastUsed;
572
+ effectiveIncoming.lastUsed > existingEntry.lastUsed)) {
573
+ merged.lastUsed = effectiveIncoming.lastUsed;
421
574
  }
422
575
  return merged;
423
576
  }
424
- const merged = { ...existingEntry, ...incoming };
577
+ const merged = {
578
+ ...existingEntry,
579
+ ...effectiveIncoming,
580
+ };
425
581
  if (tokenChanged) {
426
- if ('quota' in incoming) {
427
- merged.quota = existingEntry.quota;
428
- if ('lastQuotaRefreshError' in existingEntry) {
429
- merged.lastQuotaRefreshError = existingEntry.lastQuotaRefreshError;
430
- }
431
- else {
432
- delete merged.lastQuotaRefreshError;
433
- }
582
+ if (!('profile' in effectiveIncoming))
583
+ delete merged.profile;
584
+ if (effectiveIncoming.quota?.source) {
585
+ merged.quota = effectiveIncoming.quota;
434
586
  }
435
587
  else {
436
588
  delete merged.quota;
589
+ }
590
+ if (!('lastQuotaRefreshError' in effectiveIncoming)) {
437
591
  delete merged.lastQuotaRefreshError;
438
592
  }
439
- if (!('lastRefreshError' in incoming))
593
+ if (!('lastRefreshError' in effectiveIncoming)) {
440
594
  delete merged.lastRefreshError;
595
+ }
441
596
  return merged;
442
597
  }
443
598
  return {
@@ -446,11 +601,20 @@ function mergeAccountRuntimeState(existing, incoming) {
446
601
  lastQuotaRefreshError: existingEntry.lastQuotaRefreshError,
447
602
  };
448
603
  }
449
- const merged = { ...existingEntry, ...incoming };
450
- if (!('lastQuotaRefreshError' in incoming)) {
604
+ const merged = {
605
+ ...existingEntry,
606
+ ...effectiveIncoming,
607
+ };
608
+ if (tokenChanged) {
609
+ if (!('profile' in effectiveIncoming))
610
+ delete merged.profile;
611
+ if (!effectiveIncoming.quota?.source)
612
+ delete merged.quota;
613
+ }
614
+ if (!('lastQuotaRefreshError' in effectiveIncoming)) {
451
615
  delete merged.lastQuotaRefreshError;
452
616
  }
453
- if (!('lastRefreshError' in incoming)) {
617
+ if (!('lastRefreshError' in effectiveIncoming)) {
454
618
  delete merged.lastRefreshError;
455
619
  }
456
620
  return merged;
@@ -475,7 +639,7 @@ function configFromStorage(storage) {
475
639
  : undefined;
476
640
  return omitUndefinedTopLevel({
477
641
  version: 1,
478
- main: storage.main,
642
+ main: { type: 'opencode', provider: 'anthropic' },
479
643
  routing: storage.routing,
480
644
  fallbackOn: storage.fallbackOn,
481
645
  refresh,
@@ -550,24 +714,45 @@ function mergeAccountsForSave(existing, incoming, options) {
550
714
  }
551
715
  const ACCOUNT_CONFIG_LOCK_TTL_MS = 10_000;
552
716
  const ACCOUNT_CONFIG_LOCK_WAIT_MS = 12_000;
553
- async function acquireAccountConfigWriteLock(path) {
717
+ const ACCOUNT_STATE_LOCK_TTL_MS = 10_000;
718
+ const ACCOUNT_STATE_LOCK_WAIT_MS = 12_000;
719
+ async function acquireAccountWriteLock(input) {
720
+ const { path, name, ttlMs, waitMs, description } = input;
554
721
  await mkdir(dirname(path), { recursive: true });
555
- const deadline = Date.now() + ACCOUNT_CONFIG_LOCK_WAIT_MS;
722
+ const deadline = Date.now() + waitMs;
556
723
  while (true) {
557
724
  const lock = await acquireRefreshFileLock({
558
- name: 'config-write',
559
- ttlMs: ACCOUNT_CONFIG_LOCK_TTL_MS,
725
+ name,
726
+ ttlMs,
560
727
  path,
561
728
  renew: true,
562
729
  });
563
730
  if (lock)
564
731
  return lock;
565
732
  if (Date.now() >= deadline) {
566
- throw new Error('Timed out waiting for the account configuration write lock');
733
+ throw new Error(`Timed out waiting for the account ${description} lock`);
567
734
  }
568
735
  await new Promise((resolve) => setTimeout(resolve, 25));
569
736
  }
570
737
  }
738
+ function acquireAccountConfigWriteLock(path) {
739
+ return acquireAccountWriteLock({
740
+ path,
741
+ name: 'config-write',
742
+ ttlMs: ACCOUNT_CONFIG_LOCK_TTL_MS,
743
+ waitMs: ACCOUNT_CONFIG_LOCK_WAIT_MS,
744
+ description: 'configuration write',
745
+ });
746
+ }
747
+ function acquireAccountStateWriteLock(path) {
748
+ return acquireAccountWriteLock({
749
+ path,
750
+ name: 'state-write',
751
+ ttlMs: ACCOUNT_STATE_LOCK_TTL_MS,
752
+ waitMs: ACCOUNT_STATE_LOCK_WAIT_MS,
753
+ description: 'state write',
754
+ });
755
+ }
571
756
  export function saveAccounts(storage, path = getAccountStoragePath(), options = {}) {
572
757
  const resolvedPath = path;
573
758
  return enqueueSave(() => saveAccountsLocked(storage, resolvedPath, options));
@@ -583,28 +768,55 @@ async function saveAccountsLocked(storage, path, options) {
583
768
  const existing = await loadExistingTopLevelFields(path);
584
769
  const nextConfig = { ...existing, ...configFromStorage(nextStorage) };
585
770
  await writeJsonAtomic(path, nextConfig);
586
- await saveAccountStateUnlocked(nextStorage, path, {
587
- mainQuota: true,
588
- mainRefresh: true,
589
- accounts: true,
590
- });
771
+ // Config precedes state everywhere both locks are needed; reversing this
772
+ // order can deadlock profile mutations against full account saves.
773
+ const stateLock = await acquireAccountStateWriteLock(path);
774
+ try {
775
+ await saveAccountStateUnlocked(nextStorage, path, {
776
+ mainQuota: true,
777
+ mainRefresh: true,
778
+ accounts: true,
779
+ });
780
+ }
781
+ finally {
782
+ await stateLock.release();
783
+ }
591
784
  }
592
785
  finally {
593
786
  await lock.release();
594
787
  }
595
788
  }
789
+ function applyMainProfileStatePatch(state, storage) {
790
+ state.main = state.main ?? {};
791
+ state.main.profile = storage.main?.profile;
792
+ }
596
793
  function applyMainQuotaStatePatch(state, storage) {
597
794
  state.main = state.main ?? {};
795
+ const incomingQuota = storage.quota?.mainQuota;
796
+ const sameToken = Boolean(state.main.quotaToken &&
797
+ storage.quota?.mainQuotaToken &&
798
+ state.main.quotaToken === storage.quota.mainQuotaToken);
799
+ const effectiveIncomingQuota = sameToken && incomingQuota?.source === 'headers'
800
+ ? mergeHeaderQuotaForPersistence(state.main.quota, incomingQuota)
801
+ : incomingQuota;
802
+ const mergesHeaderQuota = Boolean(sameToken && incomingQuota?.source === 'headers');
598
803
  const existingCheckedAt = typeof state.main.quotaCheckedAt === 'number'
599
804
  ? state.main.quotaCheckedAt
600
805
  : quotaSnapshotCheckedAt(state.main.quota);
601
806
  const incomingCheckedAt = typeof storage.quota?.mainQuotaCheckedAt === 'number'
602
807
  ? storage.quota.mainQuotaCheckedAt
603
- : quotaSnapshotCheckedAt(storage.quota?.mainQuota);
604
- if (existingCheckedAt > incomingCheckedAt)
808
+ : quotaSnapshotCheckedAt(effectiveIncomingQuota);
809
+ if (!mergesHeaderQuota &&
810
+ (existingCheckedAt > incomingCheckedAt ||
811
+ (existingCheckedAt === incomingCheckedAt &&
812
+ quotaSourcePrecedence(state.main.quota) >
813
+ quotaSourcePrecedence(effectiveIncomingQuota)))) {
605
814
  return;
606
- state.main.quota = storage.quota?.mainQuota;
607
- state.main.quotaCheckedAt = storage.quota?.mainQuotaCheckedAt;
815
+ }
816
+ state.main.quota = effectiveIncomingQuota;
817
+ state.main.quotaCheckedAt = mergesHeaderQuota
818
+ ? Math.max(existingCheckedAt, incomingCheckedAt)
819
+ : storage.quota?.mainQuotaCheckedAt;
608
820
  state.main.quotaToken = storage.quota?.mainQuotaToken;
609
821
  state.main.lastQuotaApiError = storage.quota?.mainLastQuotaApiError;
610
822
  }
@@ -625,12 +837,97 @@ function pruneUndefined(value) {
625
837
  .map(([key, entry]) => [key, pruneUndefined(entry)]));
626
838
  }
627
839
  export function saveAccountState(storage, path = getAccountStoragePath(), scope = {
840
+ mainProfile: true,
628
841
  mainQuota: true,
629
842
  mainRefresh: true,
630
843
  accounts: true,
631
844
  }) {
632
845
  const resolvedPath = path;
633
- return enqueueSave(() => saveAccountStateUnlocked(storage, resolvedPath, scope));
846
+ return enqueueSave(async () => {
847
+ const lock = await acquireAccountStateWriteLock(resolvedPath);
848
+ try {
849
+ await saveAccountStateUnlocked(storage, resolvedPath, scope);
850
+ }
851
+ finally {
852
+ await lock.release();
853
+ }
854
+ });
855
+ }
856
+ export function saveOAuthProfileState(input, path = getAccountStoragePath()) {
857
+ const resolvedPath = path;
858
+ return enqueueSave(async () => {
859
+ const configLock = await acquireAccountConfigWriteLock(resolvedPath);
860
+ try {
861
+ const stateLock = await acquireAccountStateWriteLock(resolvedPath);
862
+ try {
863
+ const current = await loadAccounts(resolvedPath);
864
+ const statePath = getAccountStatePath(resolvedPath);
865
+ const existing = (await readJsonIfPresent(statePath)).value;
866
+ const next = isRecord(existing)
867
+ ? { ...existing, version: 1 }
868
+ : { version: 1 };
869
+ const { accountId, expectedTokenFingerprint, profile } = input;
870
+ if (profile?.tokenFingerprint &&
871
+ profile.tokenFingerprint !== expectedTokenFingerprint) {
872
+ return false;
873
+ }
874
+ if (accountId === 'main') {
875
+ next.main = { ...(next.main ?? {}) };
876
+ const existingProfile = normalizeOAuthAccountProfile(next.main.profile);
877
+ const persistedToken = next.main.profileToken ?? existingProfile?.tokenFingerprint;
878
+ if (!profile &&
879
+ existingProfile &&
880
+ persistedToken === expectedTokenFingerprint) {
881
+ return false;
882
+ }
883
+ if (profile) {
884
+ if (persistedToken && persistedToken !== expectedTokenFingerprint) {
885
+ return false;
886
+ }
887
+ if (existingProfile &&
888
+ existingProfile.checkedAt > profile.checkedAt) {
889
+ return false;
890
+ }
891
+ }
892
+ next.main.profile = profile;
893
+ next.main.profileToken = expectedTokenFingerprint;
894
+ }
895
+ else {
896
+ const account = current?.accounts.find((candidate) => candidate.id === accountId && isOAuthAccount(candidate));
897
+ if (!account?.access ||
898
+ tokenFingerprint(account.access) !== expectedTokenFingerprint) {
899
+ return false;
900
+ }
901
+ next.accounts = {
902
+ ...(isRecord(next.accounts) ? next.accounts : {}),
903
+ };
904
+ const existingEntry = isRecord(next.accounts[accountId])
905
+ ? { ...next.accounts[accountId] }
906
+ : {};
907
+ const existingProfile = normalizeOAuthAccountProfile(existingEntry.profile);
908
+ if (!profile &&
909
+ existingProfile?.tokenFingerprint === expectedTokenFingerprint) {
910
+ return false;
911
+ }
912
+ if (profile &&
913
+ existingProfile &&
914
+ existingProfile.checkedAt > profile.checkedAt) {
915
+ return false;
916
+ }
917
+ existingEntry.profile = profile;
918
+ next.accounts[accountId] = existingEntry;
919
+ }
920
+ await writeJsonAtomic(statePath, pruneUndefined(next));
921
+ return true;
922
+ }
923
+ finally {
924
+ await stateLock.release();
925
+ }
926
+ }
927
+ finally {
928
+ await configLock.release();
929
+ }
930
+ });
634
931
  }
635
932
  async function saveAccountStateUnlocked(storage, path, scope) {
636
933
  const statePath = getAccountStatePath(path);
@@ -638,6 +935,8 @@ async function saveAccountStateUnlocked(storage, path, scope) {
638
935
  const next = isRecord(existing)
639
936
  ? { ...existing, version: 1 }
640
937
  : { version: 1 };
938
+ if (scope.mainProfile)
939
+ applyMainProfileStatePatch(next, storage);
641
940
  if (scope.mainQuota)
642
941
  applyMainQuotaStatePatch(next, storage);
643
942
  if (scope.mainRefresh)
@@ -773,7 +1072,11 @@ export async function acquireRefreshFileLock(options) {
773
1072
  await writeFile(evictOwnerPath, `${JSON.stringify({ ownerId: evictOwnerId, createdAt: now() })}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
774
1073
  }
775
1074
  catch (error) {
776
- if (error.code === 'ENOENT')
1075
+ const code = error.code;
1076
+ // Another contender can rename the marker directory between mkdir and
1077
+ // this exclusive create. Darwin/Bun reports that lost-parent race as
1078
+ // either ENOENT or EINVAL; both mean this contender lost the marker.
1079
+ if (code === 'ENOENT' || code === 'EINVAL')
777
1080
  return false;
778
1081
  await releaseEvictionMarker();
779
1082
  throw error;
@@ -1396,25 +1699,35 @@ export async function addAccountPersistent(account, path = getAccountStoragePath
1396
1699
  await saveAccounts(storage, path);
1397
1700
  }
1398
1701
  export function getQuotaNextRefreshAt(quota, storage, now) {
1702
+ const intervalMs = getQuotaCheckIntervalMs(storage);
1399
1703
  if (!quotaEnabled(storage))
1400
- return now + getQuotaCheckIntervalMs(storage);
1704
+ return now + intervalMs;
1705
+ const windowFreshnessDeadline = Math.min(...[
1706
+ ...['five_hour', 'seven_day'].map((key) => quota?.[key]?.checkedAt),
1707
+ ...(quota?.scoped ?? []).map((window) => window.checkedAt),
1708
+ ]
1709
+ .filter((checkedAt) => Number.isFinite(checkedAt))
1710
+ .map((checkedAt) => checkedAt + intervalMs));
1711
+ const capAtOldestWindow = (candidate) => Number.isFinite(windowFreshnessDeadline)
1712
+ ? Math.min(candidate, windowFreshnessDeadline)
1713
+ : candidate;
1401
1714
  const thresholds = getQuotaMinimumRemainingThresholds(storage);
1402
1715
  const blockedResetTimes = [];
1403
1716
  for (const key of ['five_hour', 'seven_day']) {
1404
1717
  const window = quota?.[key];
1405
1718
  if (!window)
1406
- return now + getQuotaCheckIntervalMs(storage);
1719
+ return capAtOldestWindow(now + intervalMs);
1407
1720
  if (window.remainingPercent >= thresholds[key])
1408
1721
  continue;
1409
1722
  const resetTime = window.resetsAt ? Date.parse(window.resetsAt) : Number.NaN;
1410
1723
  if (!Number.isFinite(resetTime) || resetTime <= now) {
1411
- return now + getQuotaCheckIntervalMs(storage);
1724
+ return capAtOldestWindow(now + intervalMs);
1412
1725
  }
1413
1726
  blockedResetTimes.push(resetTime);
1414
1727
  }
1415
1728
  if (!blockedResetTimes.length)
1416
- return now + getQuotaCheckIntervalMs(storage);
1417
- return Math.min(...blockedResetTimes) + 60_000;
1729
+ return capAtOldestWindow(now + intervalMs);
1730
+ return capAtOldestWindow(Math.min(...blockedResetTimes) + 60_000);
1418
1731
  }
1419
1732
  function tokenNeedsRefresh(account, storage, now) {
1420
1733
  return (!account.access ||
@@ -1530,6 +1843,61 @@ function mapScopedWeeklyLimits(limits, checkedAt) {
1530
1843
  }
1531
1844
  return scoped;
1532
1845
  }
1846
+ function mapExtraUsage(usage) {
1847
+ if (usage.extra_usage?.is_enabled !== true)
1848
+ return undefined;
1849
+ const usedAmount = usage.extra_usage.used_credits;
1850
+ const limitAmount = usage.extra_usage.monthly_limit;
1851
+ if (typeof usedAmount !== 'number' ||
1852
+ !Number.isFinite(usedAmount) ||
1853
+ typeof limitAmount !== 'number' ||
1854
+ !Number.isFinite(limitAmount)) {
1855
+ return undefined;
1856
+ }
1857
+ const rawCurrency = usage.spend?.limit?.currency;
1858
+ const currency = rawCurrency == null ? 'USD' : nonEmptyString(rawCurrency);
1859
+ const rawExponent = usage.spend?.limit?.exponent;
1860
+ const moneyExponent = rawExponent == null ? 2 : rawExponent;
1861
+ if (!currency ||
1862
+ !/^[A-Za-z]{3}$/.test(currency) ||
1863
+ !Number.isInteger(moneyExponent) ||
1864
+ moneyExponent < 0 ||
1865
+ moneyExponent > 20) {
1866
+ return undefined;
1867
+ }
1868
+ return {
1869
+ used: { amountMinor: usedAmount, currency, exponent: moneyExponent },
1870
+ limit: { amountMinor: limitAmount, currency, exponent: moneyExponent },
1871
+ ...(typeof usage.extra_usage.utilization === 'number' &&
1872
+ Number.isFinite(usage.extra_usage.utilization) && {
1873
+ utilizationPercent: usage.extra_usage.utilization,
1874
+ }),
1875
+ ...(nonEmptyString(usage.spend?.severity) && {
1876
+ severity: nonEmptyString(usage.spend?.severity),
1877
+ }),
1878
+ exhausted: usedAmount >= limitAmount,
1879
+ };
1880
+ }
1881
+ function mapBindingWindow(limits) {
1882
+ if (!Array.isArray(limits))
1883
+ return undefined;
1884
+ const active = limits.find((limit) => limit?.is_active === true);
1885
+ if (!active)
1886
+ return undefined;
1887
+ if (active.kind === 'session')
1888
+ return 'five_hour';
1889
+ if (active.kind === 'weekly_all')
1890
+ return 'seven_day';
1891
+ if (active.kind !== 'weekly_scoped' || active.group !== 'weekly') {
1892
+ return undefined;
1893
+ }
1894
+ const modelName = nonEmptyString(active.scope?.model?.display_name);
1895
+ if (!modelName)
1896
+ return undefined;
1897
+ const identity = nonEmptyString(active.scope?.model?.id) ?? modelName;
1898
+ const slug = slugForQuotaIdentity(identity);
1899
+ return slug ? `claude-weekly-scoped-${slug}` : undefined;
1900
+ }
1533
1901
  function mapUsageWindow(window, checkedAt) {
1534
1902
  if (typeof window?.utilization !== 'number')
1535
1903
  return undefined;
@@ -1565,10 +1933,17 @@ export async function fetchOAuthQuotaSnapshot(input) {
1565
1933
  }
1566
1934
  const checkedAt = input.now?.() ?? Date.now();
1567
1935
  const usage = (await response.json());
1936
+ const bindingWindow = mapBindingWindow(usage.limits);
1568
1937
  return {
1569
1938
  five_hour: mapUsageWindow(usage.five_hour, checkedAt),
1570
1939
  seven_day: mapUsageWindow(usage.seven_day, checkedAt),
1571
1940
  scoped: mapScopedWeeklyLimits(usage.limits, checkedAt),
1941
+ extraUsage: mapExtraUsage(usage),
1942
+ ...(bindingWindow && {
1943
+ bindingWindow,
1944
+ bindingWindowSource: 'poll',
1945
+ }),
1946
+ source: 'poll',
1572
1947
  checkedAt,
1573
1948
  };
1574
1949
  }
@@ -1587,6 +1962,7 @@ export function upsertAccount(storage, account) {
1587
1962
  addedAt: storage.accounts[index]?.addedAt ?? account.addedAt,
1588
1963
  ...(account.type === 'oauth' && {
1589
1964
  quota: account.quota,
1965
+ profile: account.profile,
1590
1966
  lastRefreshedAt: account.lastRefreshedAt,
1591
1967
  lastRefreshError: account.lastRefreshError,
1592
1968
  lastQuotaRefreshError: account.lastQuotaRefreshError,
@@ -39,6 +39,7 @@ export interface AccountListItem {
39
39
  role: 'main' | 'fallback';
40
40
  enabled: boolean;
41
41
  quotaPercent: number | null;
42
+ tierLabel?: string;
42
43
  }
43
44
  export declare function buildAccountList(storage: AccountStorage): AccountListItem[];
44
45
  export declare function executeAccountCommand(input: {