@cortexkit/anthropic-auth-core 1.19.1 → 1.21.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
@@ -2,10 +2,9 @@ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
- import { parseRetryAfterHeader, refreshClaudeOAuthToken } from "./auth.js";
6
- import { CACHE_1H_MODES, CLAUDE_CODE_VERSION, DEFAULT_CACHE_1H_MODE, } from "./constants.js";
7
- import { log, logger } from "./logger.js";
8
- import { tokenFingerprint } from "./token-fingerprint.js";
5
+ import { parseRetryAfterHeader, refreshClaudeOAuthToken } from './auth.js';
6
+ import { CACHE_1H_MODES, CLAUDE_CODE_VERSION, DEFAULT_CACHE_1H_MODE, } from './constants.js';
7
+ import { log, logger } from './logger.js';
9
8
  const setRefreshLockRenewalTimeout = globalThis.setTimeout.bind(globalThis);
10
9
  const clearRefreshLockRenewalTimeout = globalThis.clearTimeout.bind(globalThis);
11
10
  export const ACCOUNT_FILE_NAME = 'anthropic-auth.json';
@@ -116,6 +115,9 @@ function normalizeAccount(value) {
116
115
  return {
117
116
  ...normalizeAccountBase(value),
118
117
  type: 'oauth',
118
+ authLineageId: typeof value.authLineageId === 'string' && value.authLineageId.trim()
119
+ ? value.authLineageId
120
+ : undefined,
119
121
  access: typeof value.access === 'string' ? value.access : undefined,
120
122
  refresh: value.refresh,
121
123
  expires: typeof value.expires === 'number' ? value.expires : undefined,
@@ -126,6 +128,7 @@ function normalizeAccount(value) {
126
128
  lastQuotaRefreshError: normalizeOperationError(value.lastQuotaRefreshError),
127
129
  quota: normalizeQuota(value.quota),
128
130
  profile: normalizeOAuthAccountProfile(value.profile),
131
+ prime: normalizePrimeUsageCounters(value.prime),
129
132
  };
130
133
  }
131
134
  function normalizeOAuthAccountProfile(value) {
@@ -143,6 +146,10 @@ function normalizeOAuthAccountProfile(value) {
143
146
  tier: value.tier.trim(),
144
147
  orgType: value.orgType.trim(),
145
148
  checkedAt: value.checkedAt,
149
+ ...(typeof value.accountIdentity === 'string' &&
150
+ value.accountIdentity.trim() && {
151
+ accountIdentity: value.accountIdentity.trim(),
152
+ }),
146
153
  ...(typeof value.tokenFingerprint === 'string' &&
147
154
  value.tokenFingerprint.trim() && {
148
155
  tokenFingerprint: value.tokenFingerprint.trim(),
@@ -165,6 +172,9 @@ function normalizeOperationError(value) {
165
172
  checkedAt,
166
173
  nextRetryAt: Number.isFinite(nextRetryAt) ? nextRetryAt : undefined,
167
174
  retryCount: Number.isFinite(retryCount) ? retryCount : undefined,
175
+ accountIdentity: typeof value.accountIdentity === 'string' && value.accountIdentity.trim()
176
+ ? value.accountIdentity.trim()
177
+ : undefined,
168
178
  tokenHash: typeof value.tokenHash === 'string' ? value.tokenHash : undefined,
169
179
  // Preserve the dead-token discriminators across save/load. Without these,
170
180
  // a retry-exhausted transient (permanent=false, 24h backoff) would lose its
@@ -192,6 +202,27 @@ function normalizeQuotaWindow(value) {
192
202
  resetsAt: typeof value.resetsAt === 'string' ? value.resetsAt : undefined,
193
203
  };
194
204
  }
205
+ function normalizePrimeUsageCounters(value) {
206
+ if (!isRecord(value))
207
+ return undefined;
208
+ const count = Number(value.count);
209
+ const inputTokens = Number(value.inputTokens);
210
+ const outputTokens = Number(value.outputTokens);
211
+ const since = Number(value.since);
212
+ if (![count, inputTokens, outputTokens, since].every(Number.isFinite) ||
213
+ count < 0 ||
214
+ inputTokens < 0 ||
215
+ outputTokens < 0 ||
216
+ since < 0) {
217
+ return undefined;
218
+ }
219
+ return {
220
+ count: Math.floor(count),
221
+ inputTokens: Math.floor(inputTokens),
222
+ outputTokens: Math.floor(outputTokens),
223
+ since: Math.floor(since),
224
+ };
225
+ }
195
226
  function normalizeQuota(value) {
196
227
  if (!isRecord(value))
197
228
  return undefined;
@@ -209,6 +240,10 @@ function normalizeQuota(value) {
209
240
  if (typeof value.checkedAt === 'number' && Number.isFinite(value.checkedAt)) {
210
241
  quota.checkedAt = value.checkedAt;
211
242
  }
243
+ if (typeof value.accountIdentity === 'string' &&
244
+ value.accountIdentity.trim()) {
245
+ quota.accountIdentity = value.accountIdentity.trim();
246
+ }
212
247
  if (Array.isArray(value.scoped)) {
213
248
  const scoped = value.scoped
214
249
  .map((entry) => {
@@ -307,6 +342,9 @@ function normalizeStorage(value) {
307
342
  return null;
308
343
  return {
309
344
  version: 1,
345
+ mainAccountId: typeof value.mainAccountId === 'string' && value.mainAccountId.trim()
346
+ ? value.mainAccountId.trim()
347
+ : undefined,
310
348
  main: {
311
349
  type: 'opencode',
312
350
  provider: 'anthropic',
@@ -318,6 +356,9 @@ function normalizeStorage(value) {
318
356
  : undefined,
319
357
  refresh: isRecord(value.refresh) ? value.refresh : undefined,
320
358
  quota: isRecord(value.quota) ? value.quota : undefined,
359
+ quotaHeaderFeed: isRecord(value.quotaHeaderFeed)
360
+ ? value.quotaHeaderFeed
361
+ : undefined,
321
362
  claudeCache: isRecord(value.claudeCache) ? value.claudeCache : undefined,
322
363
  dump: isRecord(value.dump) ? value.dump : undefined,
323
364
  claudeFast: isRecord(value.claudeFast) ? value.claudeFast : undefined,
@@ -326,6 +367,35 @@ function normalizeStorage(value) {
326
367
  relay: isRecord(value.relay) ? value.relay : undefined,
327
368
  logging: isRecord(value.logging) ? value.logging : undefined,
328
369
  killswitch: isRecord(value.killswitch) ? value.killswitch : undefined,
370
+ prime: (() => {
371
+ if (!isRecord(value.prime))
372
+ return undefined;
373
+ const enabled = typeof value.prime.enabled === 'boolean'
374
+ ? value.prime.enabled
375
+ : undefined;
376
+ const main = normalizePrimeUsageCounters(value.prime.main);
377
+ const mainAuthLineageId = typeof value.prime.mainAuthLineageId === 'string' &&
378
+ value.prime.mainAuthLineageId.trim()
379
+ ? value.prime.mainAuthLineageId
380
+ : undefined;
381
+ const mainAuthLineageRefreshTokenFingerprint = typeof value.prime.mainAuthLineageRefreshTokenFingerprint ===
382
+ 'string' && value.prime.mainAuthLineageRefreshTokenFingerprint.trim()
383
+ ? value.prime.mainAuthLineageRefreshTokenFingerprint
384
+ : undefined;
385
+ if (enabled === undefined &&
386
+ !main &&
387
+ !mainAuthLineageId &&
388
+ !mainAuthLineageRefreshTokenFingerprint)
389
+ return undefined;
390
+ return {
391
+ ...(enabled !== undefined && { enabled }),
392
+ ...(main && { main }),
393
+ ...(mainAuthLineageId && { mainAuthLineageId }),
394
+ ...(mainAuthLineageRefreshTokenFingerprint && {
395
+ mainAuthLineageRefreshTokenFingerprint,
396
+ }),
397
+ };
398
+ })(),
329
399
  accounts: value.accounts
330
400
  .map(normalizeAccount)
331
401
  .filter((account) => account != null),
@@ -349,6 +419,14 @@ function objectWithDefinedEntries(value) {
349
419
  function numericField(value) {
350
420
  return typeof value === 'number' && Number.isFinite(value) ? value : 0;
351
421
  }
422
+ // A clear is a monotonic tombstone; older or absent writes must not reopen backoff.
423
+ export function mergeMainQuotaErrorClearedAt(existing, incoming) {
424
+ if (incoming === undefined || !Number.isFinite(incoming))
425
+ return existing;
426
+ if (existing === undefined || !Number.isFinite(existing))
427
+ return incoming;
428
+ return Math.max(existing, incoming);
429
+ }
352
430
  function accountCredentialTimestamp(value) {
353
431
  return Math.max(numericField(value.lastRefreshedAt), numericField(value.lastUsed), numericField(value.addedAt));
354
432
  }
@@ -393,6 +471,19 @@ function mergeConfigAndState(configValue, stateValue) {
393
471
  const refreshConfig = isRecord(configValue.refresh) ? configValue.refresh : {};
394
472
  const mainQuotaSource = mainState ?? quotaConfig;
395
473
  const mainRefreshSource = mainState ?? refreshConfig;
474
+ const mainQuotaErrorClearedAt = typeof mainState?.quotaErrorClearedAt === 'number' &&
475
+ Number.isFinite(mainState.quotaErrorClearedAt) &&
476
+ mainState.quotaErrorClearedAt >= 0
477
+ ? mainState.quotaErrorClearedAt
478
+ : undefined;
479
+ const configQuotaError = quotaConfig.mainLastQuotaApiError;
480
+ const mainLastQuotaApiError = mainState?.lastQuotaApiError ??
481
+ (mainQuotaErrorClearedAt !== undefined &&
482
+ isRecord(configQuotaError) &&
483
+ typeof configQuotaError.checkedAt === 'number' &&
484
+ configQuotaError.checkedAt <= mainQuotaErrorClearedAt
485
+ ? undefined
486
+ : configQuotaError);
396
487
  const accounts = Array.isArray(configValue.accounts)
397
488
  ? configValue.accounts.map((account) => {
398
489
  if (!isRecord(account))
@@ -422,8 +513,44 @@ function mergeConfigAndState(configValue, stateValue) {
422
513
  mainQuota: mainQuotaSource.quota,
423
514
  mainQuotaCheckedAt: mainQuotaSource.quotaCheckedAt,
424
515
  mainQuotaToken: mainQuotaSource.quotaToken,
425
- mainLastQuotaApiError: mainQuotaSource.lastQuotaApiError,
516
+ mainLastQuotaApiError,
517
+ mainQuotaErrorGeneration: mainQuotaSource.quotaErrorGeneration,
518
+ mainQuotaErrorClearedAt: mainQuotaSource.quotaErrorClearedAt,
426
519
  }),
520
+ // Carry the main-side prime counters from the state file back into the
521
+ // merged storage so a subsequent read sees the cumulative counters. The
522
+ // `enabled` flag stays sourced from the config side; main-side counters
523
+ // live exclusively on the state file.
524
+ prime: (() => {
525
+ const configPrime = isRecord(configValue.prime)
526
+ ? configValue.prime
527
+ : undefined;
528
+ const mainCounters = normalizePrimeUsageCounters(mainState?.prime);
529
+ const mainAuthLineageId = typeof mainState?.primeAuthLineageId === 'string' &&
530
+ mainState.primeAuthLineageId.trim()
531
+ ? mainState.primeAuthLineageId
532
+ : undefined;
533
+ const mainAuthLineageRefreshTokenFingerprint = typeof mainState?.primeAuthLineageRefreshTokenFingerprint ===
534
+ 'string' && mainState.primeAuthLineageRefreshTokenFingerprint.trim()
535
+ ? mainState.primeAuthLineageRefreshTokenFingerprint
536
+ : undefined;
537
+ if (!configPrime &&
538
+ !mainCounters &&
539
+ !mainAuthLineageId &&
540
+ !mainAuthLineageRefreshTokenFingerprint)
541
+ return undefined;
542
+ return {
543
+ ...(configPrime &&
544
+ typeof configPrime.enabled === 'boolean' && {
545
+ enabled: configPrime.enabled,
546
+ }),
547
+ ...(mainCounters && { main: mainCounters }),
548
+ ...(mainAuthLineageId && { mainAuthLineageId }),
549
+ ...(mainAuthLineageRefreshTokenFingerprint && {
550
+ mainAuthLineageRefreshTokenFingerprint,
551
+ }),
552
+ };
553
+ })(),
427
554
  accounts,
428
555
  };
429
556
  }
@@ -464,6 +591,7 @@ function accountRuntimeState(account) {
464
591
  });
465
592
  }
466
593
  return objectWithDefinedEntries({
594
+ authLineageId: account.authLineageId,
467
595
  access: account.access,
468
596
  refresh: account.refresh,
469
597
  expires: account.expires,
@@ -473,9 +601,11 @@ function accountRuntimeState(account) {
473
601
  lastQuotaRefreshError: account.lastQuotaRefreshError,
474
602
  quota: account.quota,
475
603
  profile: account.profile,
604
+ prime: account.prime,
476
605
  });
477
606
  }
478
- function quotaSnapshotCheckedAt(quota) {
607
+ /** Returns the latest timestamp carried by any quota snapshot window. */
608
+ export function quotaSnapshotCheckedAt(quota) {
479
609
  return Math.max(quota?.five_hour?.checkedAt ?? 0, quota?.seven_day?.checkedAt ?? 0, ...(quota?.scoped?.map((window) => window.checkedAt) ?? []), quota?.checkedAt ?? 0);
480
610
  }
481
611
  function quotaSourcePrecedence(quota) {
@@ -519,7 +649,7 @@ function mergeHeaderOwnedWindow(existingSnapshot, incomingSnapshot, key) {
519
649
  ? existing
520
650
  : incoming;
521
651
  }
522
- function mergeHeaderQuotaForPersistence(existing, incoming) {
652
+ export function mergeHeaderQuotaForPersistence(existing, incoming) {
523
653
  if (!existing || incoming.source !== 'headers')
524
654
  return incoming;
525
655
  const preservePollBinding = existing.bindingWindowSource === 'poll';
@@ -639,11 +769,13 @@ function configFromStorage(storage) {
639
769
  : undefined;
640
770
  return omitUndefinedTopLevel({
641
771
  version: 1,
772
+ mainAccountId: storage.mainAccountId,
642
773
  main: { type: 'opencode', provider: 'anthropic' },
643
774
  routing: storage.routing,
644
775
  fallbackOn: storage.fallbackOn,
645
776
  refresh,
646
777
  quota,
778
+ quotaHeaderFeed: storage.quotaHeaderFeed,
647
779
  claudeCache: storage.claudeCache,
648
780
  dump: storage.dump,
649
781
  logging: storage.logging,
@@ -652,6 +784,15 @@ function configFromStorage(storage) {
652
784
  cacheKeep: storage.cacheKeep,
653
785
  relay: storage.relay,
654
786
  killswitch: storage.killswitch,
787
+ prime: (() => {
788
+ // Config side carries ONLY the `enabled` flag — runtime counters and
789
+ // lineage bindings stay on the state file. Write `enabled` whenever it was explicitly set so a
790
+ // toggle off persists `{ enabled: false }` and is visible to a stale
791
+ // reader that only inspects the config.
792
+ if (typeof storage.prime?.enabled !== 'boolean')
793
+ return undefined;
794
+ return { enabled: storage.prime.enabled };
795
+ })(),
655
796
  accounts: storage.accounts.map(accountConfig),
656
797
  });
657
798
  }
@@ -757,6 +898,27 @@ export function saveAccounts(storage, path = getAccountStoragePath(), options =
757
898
  const resolvedPath = path;
758
899
  return enqueueSave(() => saveAccountsLocked(storage, resolvedPath, options));
759
900
  }
901
+ export async function getOrCreateMainAccountId(path = getAccountStoragePath(), createId = randomUUID) {
902
+ return enqueueSave(async () => {
903
+ const lock = await acquireAccountConfigWriteLock(path);
904
+ try {
905
+ const storage = (await loadAccounts(path)) ?? createEmptyStorage();
906
+ if (storage.mainAccountId)
907
+ return storage.mainAccountId;
908
+ const mainAccountId = createId();
909
+ const nextStorage = { ...storage, mainAccountId };
910
+ const existing = await loadExistingTopLevelFields(path);
911
+ await writeJsonAtomic(path, {
912
+ ...existing,
913
+ ...configFromStorage(nextStorage),
914
+ });
915
+ return mainAccountId;
916
+ }
917
+ finally {
918
+ await lock.release();
919
+ }
920
+ });
921
+ }
760
922
  async function saveAccountsLocked(storage, path, options) {
761
923
  const lock = await acquireAccountConfigWriteLock(path);
762
924
  try {
@@ -792,6 +954,74 @@ function applyMainProfileStatePatch(state, storage) {
792
954
  }
793
955
  function applyMainQuotaStatePatch(state, storage) {
794
956
  state.main = state.main ?? {};
957
+ const incomingError = storage.quota?.mainLastQuotaApiError;
958
+ const incomingGeneration = typeof storage.quota?.mainQuotaErrorGeneration === 'number' &&
959
+ Number.isSafeInteger(storage.quota.mainQuotaErrorGeneration) &&
960
+ storage.quota.mainQuotaErrorGeneration >= 0
961
+ ? storage.quota.mainQuotaErrorGeneration
962
+ : undefined;
963
+ const incomingClearedAt = typeof storage.quota?.mainQuotaErrorClearedAt === 'number' &&
964
+ Number.isFinite(storage.quota.mainQuotaErrorClearedAt) &&
965
+ storage.quota.mainQuotaErrorClearedAt >= 0
966
+ ? storage.quota.mainQuotaErrorClearedAt
967
+ : undefined;
968
+ const existingGeneration = typeof state.main.quotaErrorGeneration === 'number' &&
969
+ Number.isSafeInteger(state.main.quotaErrorGeneration) &&
970
+ state.main.quotaErrorGeneration >= 0
971
+ ? state.main.quotaErrorGeneration
972
+ : 0;
973
+ const existingErrorObservedAt = typeof state.main.lastQuotaApiError?.checkedAt === 'number' &&
974
+ Number.isFinite(state.main.lastQuotaApiError.checkedAt) &&
975
+ state.main.lastQuotaApiError.checkedAt >= 0
976
+ ? state.main.lastQuotaApiError.checkedAt
977
+ : undefined;
978
+ const existingClearedAt = typeof state.main.quotaErrorClearedAt === 'number' &&
979
+ Number.isFinite(state.main.quotaErrorClearedAt) &&
980
+ state.main.quotaErrorClearedAt >= 0
981
+ ? state.main.quotaErrorClearedAt
982
+ : undefined;
983
+ const existingObservedAt = Math.max(existingErrorObservedAt ?? 0, existingClearedAt ?? 0);
984
+ const incomingErrorObservedAt = typeof incomingError?.checkedAt === 'number' &&
985
+ Number.isFinite(incomingError.checkedAt) &&
986
+ incomingError.checkedAt >= 0
987
+ ? incomingError.checkedAt
988
+ : undefined;
989
+ // Error transitions are ordered by observation time, not write order. All
990
+ // instances share one host clock, so an error observed before a clear cannot
991
+ // resurrect that clear even when both processes allocated the same generation.
992
+ if (incomingError === undefined &&
993
+ incomingClearedAt !== undefined &&
994
+ incomingClearedAt >= existingObservedAt) {
995
+ state.main.lastQuotaApiError = undefined;
996
+ state.main.quotaErrorClearedAt = mergeMainQuotaErrorClearedAt(existingClearedAt, incomingClearedAt);
997
+ if (incomingGeneration !== undefined &&
998
+ incomingGeneration >= existingGeneration) {
999
+ state.main.quotaErrorGeneration = incomingGeneration;
1000
+ }
1001
+ }
1002
+ else if (incomingError) {
1003
+ const acceptsByObservation = incomingErrorObservedAt !== undefined &&
1004
+ incomingErrorObservedAt > existingObservedAt;
1005
+ const acceptsEqualObservation = incomingErrorObservedAt !== undefined &&
1006
+ incomingErrorObservedAt === existingObservedAt &&
1007
+ existingErrorObservedAt !== undefined &&
1008
+ existingClearedAt === undefined &&
1009
+ incomingGeneration !== undefined &&
1010
+ incomingGeneration >= existingGeneration;
1011
+ const acceptsLegacyGeneration = incomingErrorObservedAt === undefined &&
1012
+ incomingClearedAt === undefined &&
1013
+ incomingGeneration !== undefined &&
1014
+ incomingGeneration >= existingGeneration &&
1015
+ existingClearedAt === undefined;
1016
+ if (acceptsByObservation ||
1017
+ acceptsEqualObservation ||
1018
+ acceptsLegacyGeneration) {
1019
+ state.main.lastQuotaApiError = incomingError;
1020
+ if (incomingGeneration !== undefined) {
1021
+ state.main.quotaErrorGeneration = Math.max(existingGeneration, incomingGeneration);
1022
+ }
1023
+ }
1024
+ }
795
1025
  const incomingQuota = storage.quota?.mainQuota;
796
1026
  const sameToken = Boolean(state.main.quotaToken &&
797
1027
  storage.quota?.mainQuotaToken &&
@@ -818,7 +1048,6 @@ function applyMainQuotaStatePatch(state, storage) {
818
1048
  ? Math.max(existingCheckedAt, incomingCheckedAt)
819
1049
  : storage.quota?.mainQuotaCheckedAt;
820
1050
  state.main.quotaToken = storage.quota?.mainQuotaToken;
821
- state.main.lastQuotaApiError = storage.quota?.mainLastQuotaApiError;
822
1051
  }
823
1052
  function applyMainRefreshStatePatch(state, storage) {
824
1053
  state.main = state.main ?? {};
@@ -827,6 +1056,29 @@ function applyMainRefreshStatePatch(state, storage) {
827
1056
  state.main.refreshLeaseUntil = storage.refresh?.mainRefreshLeaseUntil;
828
1057
  state.main.refreshLeaseTokenHash = storage.refresh?.mainRefreshLeaseTokenHash;
829
1058
  }
1059
+ function applyMainPrimeStatePatch(state, storage) {
1060
+ const incoming = storage.prime?.main;
1061
+ const incomingAuthLineageId = storage.prime?.mainAuthLineageId;
1062
+ const incomingAuthLineageRefreshTokenFingerprint = storage.prime?.mainAuthLineageRefreshTokenFingerprint;
1063
+ if (!incoming &&
1064
+ !incomingAuthLineageId &&
1065
+ !incomingAuthLineageRefreshTokenFingerprint)
1066
+ return;
1067
+ state.main = state.main ?? {};
1068
+ // Last-writer-wins on prime counters — the only writer is the prime manager
1069
+ // itself, monotonically accumulating per success, so there is no race for an
1070
+ // older write to overwrite a newer one within this process. Across processes
1071
+ // the cross-process claim marker (#1247) keeps the fire exclusive.
1072
+ if (incoming)
1073
+ state.main.prime = incoming;
1074
+ if (incomingAuthLineageId) {
1075
+ state.main.primeAuthLineageId = incomingAuthLineageId;
1076
+ }
1077
+ if (incomingAuthLineageRefreshTokenFingerprint) {
1078
+ state.main.primeAuthLineageRefreshTokenFingerprint =
1079
+ incomingAuthLineageRefreshTokenFingerprint;
1080
+ }
1081
+ }
830
1082
  function pruneUndefined(value) {
831
1083
  if (Array.isArray(value))
832
1084
  return value.map(pruneUndefined);
@@ -866,38 +1118,41 @@ export function saveOAuthProfileState(input, path = getAccountStoragePath()) {
866
1118
  const next = isRecord(existing)
867
1119
  ? { ...existing, version: 1 }
868
1120
  : { version: 1 };
869
- const { accountId, expectedTokenFingerprint, profile } = input;
870
- if (profile?.tokenFingerprint &&
871
- profile.tokenFingerprint !== expectedTokenFingerprint) {
872
- return false;
873
- }
1121
+ const { accountId, accountIdentity, profile } = input;
874
1122
  if (accountId === 'main') {
875
1123
  next.main = { ...(next.main ?? {}) };
876
1124
  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
- }
1125
+ if (!profile) {
887
1126
  if (existingProfile &&
888
- existingProfile.checkedAt > profile.checkedAt) {
1127
+ (accountIdentity === undefined ||
1128
+ existingProfile.accountIdentity === undefined ||
1129
+ existingProfile.accountIdentity === accountIdentity)) {
889
1130
  return false;
890
1131
  }
1132
+ next.main.profile = undefined;
1133
+ return await writeJsonAtomic(statePath, pruneUndefined(next)).then(() => true);
891
1134
  }
892
- next.main.profile = profile;
893
- next.main.profileToken = expectedTokenFingerprint;
1135
+ if (profile.accountIdentity !== undefined &&
1136
+ profile.accountIdentity !== accountIdentity) {
1137
+ return false;
1138
+ }
1139
+ const persistedProfile = accountIdentity === undefined ||
1140
+ profile.accountIdentity === accountIdentity
1141
+ ? profile
1142
+ : { ...profile, accountIdentity };
1143
+ if (existingProfile &&
1144
+ (accountIdentity === undefined ||
1145
+ existingProfile.accountIdentity === undefined ||
1146
+ existingProfile.accountIdentity === accountIdentity) &&
1147
+ existingProfile.checkedAt > persistedProfile.checkedAt) {
1148
+ return false;
1149
+ }
1150
+ next.main.profile = persistedProfile;
894
1151
  }
895
1152
  else {
896
1153
  const account = current?.accounts.find((candidate) => candidate.id === accountId && isOAuthAccount(candidate));
897
- if (!account?.access ||
898
- tokenFingerprint(account.access) !== expectedTokenFingerprint) {
1154
+ if (!account)
899
1155
  return false;
900
- }
901
1156
  next.accounts = {
902
1157
  ...(isRecord(next.accounts) ? next.accounts : {}),
903
1158
  };
@@ -905,16 +1160,33 @@ export function saveOAuthProfileState(input, path = getAccountStoragePath()) {
905
1160
  ? { ...next.accounts[accountId] }
906
1161
  : {};
907
1162
  const existingProfile = normalizeOAuthAccountProfile(existingEntry.profile);
908
- if (!profile &&
909
- existingProfile?.tokenFingerprint === expectedTokenFingerprint) {
910
- return false;
1163
+ if (!profile) {
1164
+ if (existingProfile &&
1165
+ (accountIdentity === undefined ||
1166
+ existingProfile.accountIdentity === undefined ||
1167
+ existingProfile.accountIdentity === accountIdentity)) {
1168
+ return false;
1169
+ }
1170
+ existingEntry.profile = undefined;
911
1171
  }
912
- if (profile &&
913
- existingProfile &&
914
- existingProfile.checkedAt > profile.checkedAt) {
915
- return false;
1172
+ else {
1173
+ if (profile.accountIdentity !== undefined &&
1174
+ profile.accountIdentity !== accountIdentity) {
1175
+ return false;
1176
+ }
1177
+ const persistedProfile = accountIdentity === undefined ||
1178
+ profile.accountIdentity === accountIdentity
1179
+ ? profile
1180
+ : { ...profile, accountIdentity };
1181
+ if (existingProfile &&
1182
+ (accountIdentity === undefined ||
1183
+ existingProfile.accountIdentity === undefined ||
1184
+ existingProfile.accountIdentity === accountIdentity) &&
1185
+ existingProfile.checkedAt > persistedProfile.checkedAt) {
1186
+ return false;
1187
+ }
1188
+ existingEntry.profile = persistedProfile;
916
1189
  }
917
- existingEntry.profile = profile;
918
1190
  next.accounts[accountId] = existingEntry;
919
1191
  }
920
1192
  await writeJsonAtomic(statePath, pruneUndefined(next));
@@ -941,6 +1213,8 @@ async function saveAccountStateUnlocked(storage, path, scope) {
941
1213
  applyMainQuotaStatePatch(next, storage);
942
1214
  if (scope.mainRefresh)
943
1215
  applyMainRefreshStatePatch(next, storage);
1216
+ if (scope.mainPrime)
1217
+ applyMainPrimeStatePatch(next, storage);
944
1218
  if (scope.accounts) {
945
1219
  const ids = scope.accounts === true ? null : new Set(scope.accounts);
946
1220
  next.accounts = { ...(isRecord(next.accounts) ? next.accounts : {}) };
@@ -1284,6 +1558,134 @@ export async function setCacheKeepSubagentsEnabled(enabled, path = getAccountSto
1284
1558
  await saveAccounts(storage, path);
1285
1559
  return storage;
1286
1560
  }
1561
+ export function isPrimePersistentlyEnabled(storage) {
1562
+ return storage?.prime?.enabled === true;
1563
+ }
1564
+ export async function setPrimePersistentEnabled(enabled, path = getAccountStoragePath()) {
1565
+ const storage = (await loadAccounts(path)) ?? createEmptyStorage();
1566
+ storage.prime = {
1567
+ ...(storage.prime ?? {}),
1568
+ enabled,
1569
+ };
1570
+ await saveAccounts(storage, path);
1571
+ return storage;
1572
+ }
1573
+ /** Return the stable prime marker identity for an OAuth account. */
1574
+ export async function getOrCreatePrimeAuthLineageId(accountId, path = getAccountStoragePath()) {
1575
+ return enqueueSave(async () => {
1576
+ const configLock = await acquireAccountConfigWriteLock(path);
1577
+ try {
1578
+ const stateLock = await acquireAccountStateWriteLock(path);
1579
+ try {
1580
+ const storage = (await loadAccounts(path)) ?? createEmptyStorage();
1581
+ if (accountId === 'main') {
1582
+ const existing = storage.prime?.mainAuthLineageId;
1583
+ if (existing)
1584
+ return existing;
1585
+ const mainAccountId = storage.mainAccountId ?? randomUUID();
1586
+ if (!storage.mainAccountId) {
1587
+ const existingConfig = await loadExistingTopLevelFields(path);
1588
+ await writeJsonAtomic(path, {
1589
+ ...existingConfig,
1590
+ ...configFromStorage({ ...storage, mainAccountId }),
1591
+ });
1592
+ storage.mainAccountId = mainAccountId;
1593
+ }
1594
+ storage.prime = {
1595
+ ...(storage.prime ?? {}),
1596
+ mainAuthLineageId: mainAccountId,
1597
+ };
1598
+ await saveAccountStateUnlocked(storage, path, { mainPrime: true });
1599
+ return mainAccountId;
1600
+ }
1601
+ const account = storage.accounts.find((candidate) => candidate.id === accountId && isOAuthAccount(candidate));
1602
+ if (!account) {
1603
+ throw new Error(`getOrCreatePrimeAuthLineageId: OAuth account "${accountId}" not found`);
1604
+ }
1605
+ if (account.authLineageId)
1606
+ return account.authLineageId;
1607
+ // Upgrading changes the marker namespace once, so an already-primed
1608
+ // window may fire again; persisting the seed prevents every later token
1609
+ // rotation from repeating that migration cost.
1610
+ const authLineageId = randomUUID();
1611
+ account.authLineageId = authLineageId;
1612
+ await saveAccountStateUnlocked(storage, path, {
1613
+ accounts: [accountId],
1614
+ });
1615
+ return authLineageId;
1616
+ }
1617
+ finally {
1618
+ await stateLock.release();
1619
+ }
1620
+ }
1621
+ finally {
1622
+ await configLock.release();
1623
+ }
1624
+ });
1625
+ }
1626
+ /**
1627
+ * Atomically increment an account's cumulative prime counters and persist via
1628
+ * the scoped runtime-state path. The `main` account lives at state.main.prime;
1629
+ * every other account lives at state.accounts[id].prime. Config-side writes
1630
+ * are intentionally NOT triggered so prime counters cannot leak into
1631
+ * `anthropic-auth.json`. Callers should not depend on this function to mutate
1632
+ * the caller's storage object.
1633
+ */
1634
+ export async function incrementPrimeUsagePersistent(accountId, usage, path = getAccountStoragePath(), now = Date.now()) {
1635
+ const inputTokens = Number.isFinite(usage?.inputTokens)
1636
+ ? Math.max(0, Math.floor(usage.inputTokens))
1637
+ : 0;
1638
+ const outputTokens = Number.isFinite(usage?.outputTokens)
1639
+ ? Math.max(0, Math.floor(usage.outputTokens))
1640
+ : 0;
1641
+ return enqueueSave(async () => {
1642
+ // The config-write lock is the repository-wide outer lock for config and
1643
+ // runtime-state RMW operations; taking it before the state write preserves
1644
+ // saveAccountsLocked's config → state ordering across processes.
1645
+ const lock = await acquireAccountConfigWriteLock(path);
1646
+ try {
1647
+ const stateLock = await acquireAccountStateWriteLock(path);
1648
+ try {
1649
+ const storage = (await loadAccounts(path)) ?? createEmptyStorage();
1650
+ if (accountId === 'main') {
1651
+ const existing = storage.prime?.main;
1652
+ const next = {
1653
+ count: (existing?.count ?? 0) + 1,
1654
+ inputTokens: (existing?.inputTokens ?? 0) + inputTokens,
1655
+ outputTokens: (existing?.outputTokens ?? 0) + outputTokens,
1656
+ since: existing?.since ?? Math.floor(now),
1657
+ };
1658
+ storage.prime = { ...(storage.prime ?? {}), main: next };
1659
+ await saveAccountStateUnlocked(storage, path, { mainPrime: true });
1660
+ return next;
1661
+ }
1662
+ const index = storage.accounts.findIndex((account) => account.id === accountId);
1663
+ if (index < 0) {
1664
+ throw new Error(`incrementPrimeUsagePersistent: account "${accountId}" not found`);
1665
+ }
1666
+ const account = storage.accounts[index];
1667
+ const existing = account.prime;
1668
+ const next = {
1669
+ count: (existing?.count ?? 0) + 1,
1670
+ inputTokens: (existing?.inputTokens ?? 0) + inputTokens,
1671
+ outputTokens: (existing?.outputTokens ?? 0) + outputTokens,
1672
+ since: existing?.since ?? Math.floor(now),
1673
+ };
1674
+ storage.accounts[index] = { ...account, prime: next };
1675
+ await saveAccountStateUnlocked(storage, path, {
1676
+ accounts: [accountId],
1677
+ });
1678
+ return next;
1679
+ }
1680
+ finally {
1681
+ await stateLock.release();
1682
+ }
1683
+ }
1684
+ finally {
1685
+ await lock.release();
1686
+ }
1687
+ });
1688
+ }
1287
1689
  function getFallbackStatuses(storage) {
1288
1690
  return storage?.fallbackOn?.length ? storage.fallbackOn : DEFAULT_FALLBACK_ON;
1289
1691
  }
@@ -1337,9 +1739,8 @@ function isTransientRefreshError(error) {
1337
1739
  error.code === 'UND_ERR_CONNECT_TIMEOUT')));
1338
1740
  }
1339
1741
  export function buildRefreshOperationError(input) {
1340
- const tokenHash = hashRefreshToken(input.refreshToken);
1341
- const previousRetryCount = input.previous?.tokenHash === tokenHash
1342
- ? (input.previous.retryCount ?? 0)
1742
+ const previousRetryCount = input.previous?.accountIdentity === input.accountIdentity
1743
+ ? (input.previous?.retryCount ?? 0)
1343
1744
  : 0;
1344
1745
  const retryCount = previousRetryCount + 1;
1345
1746
  const retryAfterFromError = input.error
@@ -1374,7 +1775,7 @@ export function buildRefreshOperationError(input) {
1374
1775
  checkedAt: input.now,
1375
1776
  nextRetryAt: input.now + delay,
1376
1777
  retryCount,
1377
- tokenHash,
1778
+ accountIdentity: input.accountIdentity,
1378
1779
  status,
1379
1780
  permanent: status === 400 && isInvalidGrant,
1380
1781
  };
@@ -1407,17 +1808,40 @@ export function isPermanentRefreshError(error) {
1407
1808
  }
1408
1809
  return false;
1409
1810
  }
1410
- export function refreshBackoffActive(error, refreshToken, now) {
1811
+ export function refreshBackoffActive(error, accountIdentity, now) {
1411
1812
  if (!error?.nextRetryAt || error.nextRetryAt <= now)
1412
1813
  return false;
1413
- if (!refreshToken)
1814
+ if (!error.accountIdentity)
1815
+ return true;
1816
+ if (!accountIdentity)
1414
1817
  return true;
1415
- return error.tokenHash === hashRefreshToken(refreshToken);
1818
+ return error.accountIdentity === accountIdentity;
1416
1819
  }
1417
1820
  export function formatRefreshBackoffMessage(error, now) {
1418
1821
  const seconds = Math.max(1, Math.ceil(((error.nextRetryAt ?? now) - now) / 1000));
1419
1822
  return `Claude OAuth refresh is backed off for ${seconds}s after: ${error.message}`;
1420
1823
  }
1824
+ function createRefreshBackoffActiveError(error, now) {
1825
+ return Object.assign(new Error(formatRefreshBackoffMessage(error, now)), {
1826
+ refreshBackoffError: error,
1827
+ });
1828
+ }
1829
+ function existingRefreshBackoffError(error) {
1830
+ if (!error || typeof error !== 'object')
1831
+ return undefined;
1832
+ const existing = error
1833
+ .refreshBackoffError;
1834
+ return existing && typeof existing.message === 'string' ? existing : undefined;
1835
+ }
1836
+ export function getFallbackReauthLabels(storage) {
1837
+ if (!storage)
1838
+ return [];
1839
+ return storage.accounts
1840
+ .filter((account) => account.enabled !== false &&
1841
+ isOAuthAccount(account) &&
1842
+ isPermanentRefreshError(account.lastRefreshError))
1843
+ .map((account) => account.label?.trim() || account.id);
1844
+ }
1421
1845
  export function isQuotaPolicyAuthError(error) {
1422
1846
  const status = error.status;
1423
1847
  if (status === 403)
@@ -1425,7 +1849,9 @@ export function isQuotaPolicyAuthError(error) {
1425
1849
  return /Claude quota check failed: 403\b/.test(formatErrorMessage(error));
1426
1850
  }
1427
1851
  export function buildQuotaOperationError(input) {
1428
- const previousRetryCount = input.previous?.retryCount ?? 0;
1852
+ const previousRetryCount = input.previous?.accountIdentity === input.accountIdentity
1853
+ ? (input.previous?.retryCount ?? 0)
1854
+ : 0;
1429
1855
  const retryCount = previousRetryCount + 1;
1430
1856
  const delay = isTransientQuotaError(input.error)
1431
1857
  ? Math.min(MAX_QUOTA_RETRY_DELAY_MS, MIN_QUOTA_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6))
@@ -1435,6 +1861,9 @@ export function buildQuotaOperationError(input) {
1435
1861
  checkedAt: input.now,
1436
1862
  nextRetryAt: input.now + delay,
1437
1863
  retryCount,
1864
+ ...(input.accountIdentity !== undefined && {
1865
+ accountIdentity: input.accountIdentity,
1866
+ }),
1438
1867
  };
1439
1868
  }
1440
1869
  export function quotaBackoffActive(error, now) {
@@ -1454,7 +1883,7 @@ export function getPersistedLogLevel(storage) {
1454
1883
  return storage?.logging?.level;
1455
1884
  }
1456
1885
  export async function setLogLevelPersistent(level, path = getAccountStoragePath()) {
1457
- const { setLogLevel } = await import("./logger.js");
1886
+ const { setLogLevel } = await import('./logger.js');
1458
1887
  const storage = (await loadAccounts(path)) ?? createEmptyStorage();
1459
1888
  storage.logging = {
1460
1889
  ...(storage.logging ?? {}),
@@ -1470,6 +1899,7 @@ export function getPersistedMainQuota(storage) {
1470
1899
  quota: storage.quota.mainQuota,
1471
1900
  checkedAt: storage.quota.mainQuotaCheckedAt,
1472
1901
  tokenFingerprint: storage.quota.mainQuotaToken,
1902
+ accountIdentity: storage.quota.mainQuota.accountIdentity,
1473
1903
  };
1474
1904
  }
1475
1905
  /**
@@ -1486,15 +1916,18 @@ function failClosedOnUnknownQuota(storage) {
1486
1916
  return (storage?.quota?.failClosedOnUnknownQuota ??
1487
1917
  DEFAULT_FAIL_CLOSED_ON_UNKNOWN_QUOTA);
1488
1918
  }
1919
+ function normalizeScopedQuotaModel(value) {
1920
+ return value.toLowerCase().replace(/[^a-z0-9]/g, '');
1921
+ }
1489
1922
  function scopedQuotaModelKey(model) {
1490
1923
  if (typeof model !== 'string')
1491
1924
  return null;
1492
- const normalized = model.toLowerCase();
1925
+ const normalized = normalizeScopedQuotaModel(model);
1493
1926
  if (normalized.includes('fable'))
1494
1927
  return 'fable';
1495
1928
  if (normalized.includes('mythos'))
1496
1929
  return 'mythos';
1497
- return null;
1930
+ return normalized;
1498
1931
  }
1499
1932
  export function getScopedQuotaWindowForModel(quota, model) {
1500
1933
  const key = scopedQuotaModelKey(model);
@@ -1503,8 +1936,8 @@ export function getScopedQuotaWindowForModel(quota, model) {
1503
1936
  return quota?.scoped?.find((window) => {
1504
1937
  const haystack = [window.modelId, window.modelName, window.title]
1505
1938
  .filter((value) => typeof value === 'string')
1506
- .join(' ')
1507
- .toLowerCase();
1939
+ .map(normalizeScopedQuotaModel)
1940
+ .join(' ');
1508
1941
  return haystack.includes(key);
1509
1942
  });
1510
1943
  }
@@ -1533,6 +1966,9 @@ export function quotaSnapshotPassesPolicy(quota, storage) {
1533
1966
  }
1534
1967
  return true;
1535
1968
  }
1969
+ export function quotaSnapshotHasStandardWindows(quota) {
1970
+ return Boolean(quota?.five_hour && quota.seven_day);
1971
+ }
1536
1972
  // ---------------------------------------------------------------------------
1537
1973
  // Killswitch — hard-block requests when remaining quota drops below per-account
1538
1974
  // thresholds, even if the API would still accept them.
@@ -1997,15 +2433,20 @@ function formatErrorMessage(error) {
1997
2433
  return error instanceof Error ? error.message : String(error);
1998
2434
  }
1999
2435
  function recordRefreshError(account, error, now) {
2436
+ const existing = existingRefreshBackoffError(error);
2437
+ if (existing) {
2438
+ account.lastRefreshError = existing;
2439
+ return;
2440
+ }
2000
2441
  account.lastRefreshError = buildRefreshOperationError({
2001
2442
  error,
2002
2443
  now,
2003
- refreshToken: account.refresh,
2444
+ accountIdentity: account.id,
2004
2445
  previous: account.lastRefreshError,
2005
2446
  });
2006
2447
  }
2007
2448
  function recordQuotaRefreshError(account, error, now) {
2008
- if (isQuotaPolicyAuthError(error))
2449
+ if (isQuotaPolicyAuthError(error) || existingRefreshBackoffError(error))
2009
2450
  return;
2010
2451
  account.lastQuotaRefreshError = buildQuotaOperationError({
2011
2452
  error,
@@ -2052,10 +2493,10 @@ export class FallbackAccountManager {
2052
2493
  return;
2053
2494
  if (!account.quota)
2054
2495
  return;
2055
- const checkedAt = Math.max(account.quota.five_hour?.checkedAt ?? 0, account.quota.seven_day?.checkedAt ?? 0);
2496
+ const checkedAt = quotaSnapshotCheckedAt(account.quota);
2056
2497
  if (checkedAt <= 0)
2057
2498
  return;
2058
- const existing = this.quotaManager.getFallback(account.id, account.access);
2499
+ const existing = this.quotaManager.getFallback(account.id, account);
2059
2500
  if (existing && existing.checkedAt >= checkedAt)
2060
2501
  return;
2061
2502
  const checkInterval = getQuotaCheckIntervalMs(storage);
@@ -2063,7 +2504,7 @@ export class FallbackAccountManager {
2063
2504
  quota: account.quota,
2064
2505
  refreshAfter: checkedAt + checkInterval,
2065
2506
  checkedAt,
2066
- }, account.access);
2507
+ }, account);
2067
2508
  }
2068
2509
  async load() {
2069
2510
  return loadAccounts(this.configPath);
@@ -2104,20 +2545,20 @@ export class FallbackAccountManager {
2104
2545
  for (const account of storage.accounts) {
2105
2546
  if (account.enabled === false || !isOAuthAccount(account))
2106
2547
  continue;
2548
+ let next = account;
2107
2549
  try {
2108
- let next = account;
2109
2550
  if (tokenNeedsRefresh(next, storage, this.now())) {
2110
2551
  const refreshError = next.lastRefreshError;
2111
2552
  if (refreshError &&
2112
- refreshBackoffActive(refreshError, next.refresh, this.now())) {
2113
- throw new Error(formatRefreshBackoffMessage(refreshError, this.now()));
2553
+ refreshBackoffActive(refreshError, next.id, this.now())) {
2554
+ throw createRefreshBackoffActiveError(refreshError, this.now());
2114
2555
  }
2115
2556
  next = await this.refreshAccount(next, storage);
2116
2557
  changed = true;
2117
2558
  }
2118
2559
  this.seedFallbackQuota(next, storage);
2119
2560
  const stale = this.quotaManager
2120
- ? this.quotaManager.isFallbackStale(next.id, next.access, options.modelId)
2561
+ ? this.quotaManager.isFallbackStale(next.id, next.access, options.modelId, next)
2121
2562
  : quotaIsStale(next, storage, this.now(), options.modelId);
2122
2563
  // Skip the request-time refresh when this account's quota API is
2123
2564
  // backed off (recent 429/5xx). Hitting it again would extend the
@@ -2125,7 +2566,7 @@ export class FallbackAccountManager {
2125
2566
  // the background refreshQuotaForDueAccounts() guard.
2126
2567
  if (stale &&
2127
2568
  !quotaBackoffActive(next.lastQuotaRefreshError, this.now())) {
2128
- next = await this.refreshAccountQuota(next, storage);
2569
+ next = (await this.refreshAccountQuota(next, storage)).account;
2129
2570
  changed = true;
2130
2571
  }
2131
2572
  // Single source of truth: evaluate quota policy from the unified
@@ -2137,20 +2578,21 @@ export class FallbackAccountManager {
2137
2578
  usable.push(next);
2138
2579
  }
2139
2580
  catch (error) {
2140
- if (canUseCachedQuotaAfterRefreshError(account, storage, error, this.now())) {
2581
+ if (canUseCachedQuotaAfterRefreshError(next, storage, error, this.now())) {
2141
2582
  log('[refresh] fallback quota using cached quota after refresh error', {
2142
- accountId: account.id,
2583
+ accountId: next.id,
2143
2584
  error: formatErrorMessage(error),
2144
2585
  });
2145
- if (this.accountPassesQuotaPolicy(this.quotaPolicyAccount(account), storage, {
2586
+ if (this.accountPassesQuotaPolicy(this.quotaPolicyAccount(next), storage, {
2146
2587
  modelId: options.modelId,
2147
2588
  })) {
2148
- usable.push(account);
2589
+ usable.push(next);
2149
2590
  }
2150
2591
  }
2151
2592
  else if (!failClosedOnUnknownQuota(storage) &&
2152
- quotaSnapshotPassesModelScope(account.quota, options.modelId)) {
2153
- usable.push(account);
2593
+ !refreshBackoffActive(next.lastRefreshError, next.id, this.now()) &&
2594
+ quotaSnapshotPassesModelScope(next.quota, options.modelId)) {
2595
+ usable.push(next);
2154
2596
  }
2155
2597
  }
2156
2598
  }
@@ -2181,7 +2623,7 @@ export class FallbackAccountManager {
2181
2623
  quotaPolicyAccount(account) {
2182
2624
  if (!this.quotaManager)
2183
2625
  return account;
2184
- const cached = this.quotaManager.getFallback(account.id, account.access)?.quota;
2626
+ const cached = this.quotaManager.getFallback(account.id, account)?.quota;
2185
2627
  return cached ? { ...account, quota: cached } : account;
2186
2628
  }
2187
2629
  async refreshDueAccounts() {
@@ -2194,7 +2636,7 @@ export class FallbackAccountManager {
2194
2636
  continue;
2195
2637
  if (!tokenNeedsRefresh(account, storage, this.now()))
2196
2638
  continue;
2197
- if (refreshBackoffActive(account.lastRefreshError, account.refresh, this.now())) {
2639
+ if (refreshBackoffActive(account.lastRefreshError, account.id, this.now())) {
2198
2640
  // Backoff skips are steady-state while a fallback account is waiting for
2199
2641
  // its next retry. Logging every background tick from every OpenCode
2200
2642
  // process creates noise without adding new diagnostic signal; the
@@ -2237,7 +2679,7 @@ export class FallbackAccountManager {
2237
2679
  let next = account;
2238
2680
  try {
2239
2681
  if (tokenNeedsRefresh(next, storage, this.now())) {
2240
- if (refreshBackoffActive(next.lastRefreshError, next.refresh, this.now())) {
2682
+ if (refreshBackoffActive(next.lastRefreshError, next.id, this.now())) {
2241
2683
  continue;
2242
2684
  }
2243
2685
  next = await this.refreshAccount(next, storage);
@@ -2284,8 +2726,8 @@ export class FallbackAccountManager {
2284
2726
  if (tokenNeedsRefresh(next, storage, this.now())) {
2285
2727
  const refreshError = next.lastRefreshError;
2286
2728
  if (refreshError &&
2287
- refreshBackoffActive(refreshError, next.refresh, this.now())) {
2288
- throw new Error(formatRefreshBackoffMessage(refreshError, this.now()));
2729
+ refreshBackoffActive(refreshError, next.id, this.now())) {
2730
+ throw createRefreshBackoffActiveError(refreshError, this.now());
2289
2731
  }
2290
2732
  next = await this.refreshAccount(next, storage);
2291
2733
  changed = true;
@@ -2358,9 +2800,9 @@ export class FallbackAccountManager {
2358
2800
  }
2359
2801
  const refreshError = latestAccount.lastRefreshError;
2360
2802
  if (refreshError &&
2361
- refreshBackoffActive(refreshError, latestAccount.refresh, this.now())) {
2803
+ refreshBackoffActive(refreshError, latestAccount.id, this.now())) {
2362
2804
  updateStoredAccount(storage, latestAccount);
2363
- throw new Error(formatRefreshBackoffMessage(refreshError, this.now()));
2805
+ throw createRefreshBackoffActiveError(refreshError, this.now());
2364
2806
  }
2365
2807
  }
2366
2808
  return null;
@@ -2411,11 +2853,14 @@ export class FallbackAccountManager {
2411
2853
  });
2412
2854
  const refreshed = await refreshClaudeOAuthToken({
2413
2855
  refreshToken,
2856
+ authLineageId: sourceAccount.authLineageId,
2414
2857
  fetchImpl: this.fetchImpl,
2415
2858
  now: this.now,
2416
2859
  });
2417
2860
  sourceAccount.access = refreshed.access;
2418
2861
  sourceAccount.refresh = refreshed.refresh;
2862
+ sourceAccount.authLineageId =
2863
+ refreshed.authLineageId ?? sourceAccount.authLineageId;
2419
2864
  sourceAccount.expires = refreshed.expires;
2420
2865
  sourceAccount.lastRefreshedAt =
2421
2866
  refreshed.expires - refreshed.expiresIn * 1000;
@@ -2443,15 +2888,18 @@ export class FallbackAccountManager {
2443
2888
  // deduplication and 429 backoff gating around the same quota API. Fall back
2444
2889
  // to a direct fetch only when no QuotaManager is wired (e.g. in isolation).
2445
2890
  const fetchSnapshot = (accessToken) => this.quotaManager
2446
- ? this.quotaManager.refreshFallback(target.id, accessToken)
2891
+ ? this.quotaManager.refreshFallbackWithMetadata(target.id, accessToken, target)
2447
2892
  : fetchOAuthQuotaSnapshot({
2448
2893
  accessToken,
2449
2894
  fetchImpl: this.fetchImpl,
2450
2895
  now: this.now,
2451
- });
2896
+ }).then((quota) => ({ quota, fetched: true }));
2452
2897
  const fetchStartedAt = this.now();
2898
+ let fetched = false;
2453
2899
  try {
2454
- target.quota = await fetchSnapshot(target.access);
2900
+ const result = await fetchSnapshot(target.access);
2901
+ target.quota = result.quota;
2902
+ fetched = result.fetched;
2455
2903
  }
2456
2904
  catch (error) {
2457
2905
  const message = error instanceof Error ? error.message : String(error);
@@ -2463,7 +2911,9 @@ export class FallbackAccountManager {
2463
2911
  if (!target.access)
2464
2912
  throw error;
2465
2913
  // 401 does not arm QuotaManager backoff, so this retry proceeds.
2466
- target.quota = await fetchSnapshot(target.access);
2914
+ const result = await fetchSnapshot(target.access);
2915
+ target.quota = result.quota;
2916
+ fetched = result.fetched;
2467
2917
  }
2468
2918
  const latestStorage = await this.load();
2469
2919
  const latestAccount = latestStorage?.accounts.find((candidate) => candidate.id === target.id && isOAuthAccount(candidate));
@@ -2472,7 +2922,7 @@ export class FallbackAccountManager {
2472
2922
  latestAccount.access !== target.access) {
2473
2923
  this.seedFallbackQuota(latestAccount, latestStorage);
2474
2924
  updateStoredAccount(storage, latestAccount);
2475
- return latestAccount;
2925
+ return { account: latestAccount, fetched: false };
2476
2926
  }
2477
2927
  if (latestStorage &&
2478
2928
  latestAccount?.access === target.access &&
@@ -2481,7 +2931,7 @@ export class FallbackAccountManager {
2481
2931
  quotaSnapshotIsFresh(latestAccount.quota, latestStorage, this.now())) {
2482
2932
  this.seedFallbackQuota(latestAccount, latestStorage);
2483
2933
  updateStoredAccount(storage, latestAccount);
2484
- return latestAccount;
2934
+ return { account: latestAccount, fetched };
2485
2935
  }
2486
2936
  target.lastQuotaRefreshError = undefined;
2487
2937
  updateStoredAccount(storage, target);
@@ -2494,8 +2944,8 @@ export class FallbackAccountManager {
2494
2944
  quota: target.quota,
2495
2945
  refreshAfter: now + getQuotaCheckIntervalMs(storage),
2496
2946
  checkedAt: now,
2497
- }, target.access);
2947
+ }, target);
2498
2948
  }
2499
- return target;
2949
+ return { account: target, fetched };
2500
2950
  }
2501
2951
  }