@cortexkit/anthropic-auth-core 1.10.2 → 1.11.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,9 +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 { ClaudeOAuthRefreshError, refreshClaudeOAuthToken } from "./auth.js";
5
+ import { parseRetryAfterHeader, refreshClaudeOAuthToken } from "./auth.js";
6
6
  import { CACHE_1H_MODES, CLAUDE_CODE_VERSION, DEFAULT_CACHE_1H_MODE, } from "./constants.js";
7
- import { log } from "./logger.js";
7
+ import { log, logger } from "./logger.js";
8
8
  const setRefreshLockRenewalTimeout = globalThis.setTimeout.bind(globalThis);
9
9
  const clearRefreshLockRenewalTimeout = globalThis.clearTimeout.bind(globalThis);
10
10
  export const ACCOUNT_FILE_NAME = 'anthropic-auth.json';
@@ -136,12 +136,19 @@ function normalizeOperationError(value) {
136
136
  return undefined;
137
137
  const nextRetryAt = Number(value.nextRetryAt);
138
138
  const retryCount = Number(value.retryCount);
139
+ const status = Number(value.status);
139
140
  return {
140
141
  message: value.message,
141
142
  checkedAt,
142
143
  nextRetryAt: Number.isFinite(nextRetryAt) ? nextRetryAt : undefined,
143
144
  retryCount: Number.isFinite(retryCount) ? retryCount : undefined,
144
145
  tokenHash: typeof value.tokenHash === 'string' ? value.tokenHash : undefined,
146
+ // Preserve the dead-token discriminators across save/load. Without these,
147
+ // a retry-exhausted transient (permanent=false, 24h backoff) would lose its
148
+ // flag on reload and the 24h-delay heuristic would wrongly re-classify it
149
+ // permanent → false "needs re-login" nag.
150
+ status: Number.isFinite(status) ? status : undefined,
151
+ permanent: typeof value.permanent === 'boolean' ? value.permanent : undefined,
145
152
  };
146
153
  }
147
154
  function normalizeQuota(value) {
@@ -169,6 +176,15 @@ function normalizeQuota(value) {
169
176
  }
170
177
  return Object.keys(quota).length ? quota : undefined;
171
178
  }
179
+ // Fresh empty storage shell — main OpenCode OAuth account, no fallback
180
+ // accounts. Returns a new object each call so mutating callers don't alias.
181
+ export function createEmptyStorage() {
182
+ return {
183
+ version: 1,
184
+ main: { type: 'opencode', provider: 'anthropic' },
185
+ accounts: [],
186
+ };
187
+ }
172
188
  function normalizeStorage(value) {
173
189
  if (!isRecord(value) || !Array.isArray(value.accounts))
174
190
  return null;
@@ -187,6 +203,7 @@ function normalizeStorage(value) {
187
203
  costZeroing: isRecord(value.costZeroing) ? value.costZeroing : undefined,
188
204
  cacheKeep: isRecord(value.cacheKeep) ? value.cacheKeep : undefined,
189
205
  relay: isRecord(value.relay) ? value.relay : undefined,
206
+ logging: isRecord(value.logging) ? value.logging : undefined,
190
207
  killswitch: isRecord(value.killswitch) ? value.killswitch : undefined,
191
208
  accounts: value.accounts
192
209
  .map(normalizeAccount)
@@ -201,7 +218,8 @@ async function readJsonIfPresent(path) {
201
218
  if (error.code === 'ENOENT') {
202
219
  return { exists: false, value: null };
203
220
  }
204
- return { exists: false, value: null };
221
+ const cause = error instanceof Error ? error.message : String(error);
222
+ throw new Error(`account store at ${path} is corrupt or unreadable (${cause}) — fix or remove it`);
205
223
  }
206
224
  }
207
225
  function objectWithDefinedEntries(value) {
@@ -248,10 +266,14 @@ function mergeConfigAndState(configValue, stateValue) {
248
266
  }
249
267
  export async function loadAccounts(path = getAccountStoragePath()) {
250
268
  const config = await readJsonIfPresent(path);
251
- if (!config.exists)
252
- return null;
253
269
  const state = await readJsonIfPresent(getAccountStatePath(path));
254
- return normalizeStorage(mergeConfigAndState(config.value, state.value));
270
+ // Runtime-only flows (main-OAuth refresh with no fallback accounts) write the
271
+ // state file but never the config file, so the store is absent only when
272
+ // neither exists. Synthesize an empty config to merge state into otherwise.
273
+ if (!config.exists && !state.exists)
274
+ return null;
275
+ const configValue = config.exists ? config.value : createEmptyStorage();
276
+ return normalizeStorage(mergeConfigAndState(configValue, state.value));
255
277
  }
256
278
  async function loadExistingTopLevelFields(path) {
257
279
  const existing = await readJsonIfPresent(path);
@@ -356,6 +378,7 @@ function configFromStorage(storage) {
356
378
  quota,
357
379
  claudeCache: storage.claudeCache,
358
380
  dump: storage.dump,
381
+ logging: storage.logging,
359
382
  claudeFast: storage.claudeFast,
360
383
  costZeroing: storage.costZeroing,
361
384
  cacheKeep: storage.cacheKeep,
@@ -364,25 +387,16 @@ function configFromStorage(storage) {
364
387
  accounts: storage.accounts.map(accountConfig),
365
388
  });
366
389
  }
367
- function stateFromStorage(storage) {
368
- const accounts = Object.fromEntries(storage.accounts.map((account) => [
369
- account.id,
370
- accountRuntimeState(account),
371
- ]));
372
- return {
373
- version: 1,
374
- main: objectWithDefinedEntries({
375
- quota: storage.quota?.mainQuota,
376
- quotaCheckedAt: storage.quota?.mainQuotaCheckedAt,
377
- quotaToken: storage.quota?.mainQuotaToken,
378
- lastQuotaApiError: storage.quota?.mainLastQuotaApiError,
379
- lastRefreshError: storage.refresh?.mainLastRefreshError,
380
- refreshLeaseId: storage.refresh?.mainRefreshLeaseId,
381
- refreshLeaseUntil: storage.refresh?.mainRefreshLeaseUntil,
382
- refreshLeaseTokenHash: storage.refresh?.mainRefreshLeaseTokenHash,
383
- }),
384
- accounts,
385
- };
390
+ // ---------------------------------------------------------------------------
391
+ // In-process save mutex — serializes all account-store writes so concurrent
392
+ // read-modify-write callers (background timers that call saveAccountState with
393
+ // different section flags) don't lose each other's updates (#9).
394
+ // ---------------------------------------------------------------------------
395
+ let saveChain = Promise.resolve();
396
+ function enqueueSave(fn) {
397
+ return new Promise((resolve, reject) => {
398
+ saveChain = saveChain.then(() => fn().then(resolve, reject), () => fn().then(resolve, reject));
399
+ });
386
400
  }
387
401
  async function writeJsonAtomic(path, value) {
388
402
  await mkdir(dirname(path), { recursive: true });
@@ -391,13 +405,27 @@ async function writeJsonAtomic(path, value) {
391
405
  encoding: 'utf8',
392
406
  mode: 0o600,
393
407
  });
394
- await rename(tempPath, path);
408
+ try {
409
+ await rename(tempPath, path);
410
+ }
411
+ catch (error) {
412
+ await rm(tempPath, { force: true }).catch(() => { });
413
+ throw error;
414
+ }
395
415
  }
396
- export async function saveAccounts(storage, path = getAccountStoragePath()) {
416
+ export function saveAccounts(storage, path = getAccountStoragePath()) {
417
+ const resolvedPath = path;
418
+ return enqueueSave(() => saveAccountsLocked(storage, resolvedPath));
419
+ }
420
+ async function saveAccountsLocked(storage, path) {
397
421
  const existing = await loadExistingTopLevelFields(path);
398
422
  const nextConfig = { ...existing, ...configFromStorage(storage) };
399
423
  await writeJsonAtomic(path, nextConfig);
400
- await writeJsonAtomic(getAccountStatePath(path), stateFromStorage(storage));
424
+ await saveAccountStateUnlocked(storage, path, {
425
+ mainQuota: true,
426
+ mainRefresh: true,
427
+ accounts: true,
428
+ });
401
429
  }
402
430
  function applyMainQuotaStatePatch(state, storage) {
403
431
  state.main = state.main ?? {};
@@ -430,11 +458,15 @@ function pruneUndefined(value) {
430
458
  .filter(([, entry]) => entry !== undefined)
431
459
  .map(([key, entry]) => [key, pruneUndefined(entry)]));
432
460
  }
433
- export async function saveAccountState(storage, path = getAccountStoragePath(), scope = {
461
+ export function saveAccountState(storage, path = getAccountStoragePath(), scope = {
434
462
  mainQuota: true,
435
463
  mainRefresh: true,
436
464
  accounts: true,
437
465
  }) {
466
+ const resolvedPath = path;
467
+ return enqueueSave(() => saveAccountStateUnlocked(storage, resolvedPath, scope));
468
+ }
469
+ async function saveAccountStateUnlocked(storage, path, scope) {
438
470
  const statePath = getAccountStatePath(path);
439
471
  const existing = (await readJsonIfPresent(statePath)).value;
440
472
  const next = isRecord(existing)
@@ -459,6 +491,19 @@ export async function saveAccountState(storage, path = getAccountStoragePath(),
459
491
  }
460
492
  }
461
493
  }
494
+ else {
495
+ // Full save: drop any per-account state whose id is no longer present in
496
+ // storage.accounts. The scoped path above only prunes ids it was asked to
497
+ // save; on a removal the storage is saved with scope.accounts === true
498
+ // (ids === null), so without this branch the removed account's runtime
499
+ // state (quota/lastRefreshError/access/refresh/expires) would be orphaned
500
+ // in the state file and later merged onto a re-added same-id account.
501
+ const present = new Set(storage.accounts.map((account) => account.id));
502
+ for (const id of Object.keys(next.accounts)) {
503
+ if (!present.has(id))
504
+ delete next.accounts[id];
505
+ }
506
+ }
462
507
  }
463
508
  await writeJsonAtomic(statePath, pruneUndefined(next));
464
509
  }
@@ -521,26 +566,134 @@ export async function acquireRefreshFileLock(options) {
521
566
  if ('unref' in renewTimer)
522
567
  renewTimer.unref();
523
568
  }
524
- if (!(await tryAcquire())) {
525
- try {
526
- const owner = await readOwner();
527
- if (Number(owner?.expiresAt) > now())
528
- return null;
569
+ let acquired = await tryAcquire();
570
+ if (!acquired) {
571
+ const evictPath = `${lockPath}.evicting`;
572
+ const evictOwnerPath = join(evictPath, 'owner.json');
573
+ const evictOwnerId = randomUUID();
574
+ const EVICT_TTL = 5_000;
575
+ const MAX_STEAL_ATTEMPTS = 8;
576
+ async function backoff() {
577
+ await new Promise((resolve) => setTimeout(resolve, Math.floor(Math.random() * 4)));
529
578
  }
530
- catch {
579
+ async function lockIsLive() {
531
580
  try {
532
- const current = await stat(lockPath);
533
- if (current.mtimeMs + options.ttlMs > Date.now())
534
- return null;
581
+ const currentOwner = await readOwner();
582
+ return Number(currentOwner?.expiresAt) > now();
583
+ }
584
+ catch {
585
+ try {
586
+ const current = await stat(lockPath);
587
+ return current.mtimeMs + options.ttlMs > now();
588
+ }
589
+ catch {
590
+ // Lock doesn't exist — safe to acquire.
591
+ return false;
592
+ }
593
+ }
594
+ }
595
+ async function ownsEvictionMarker() {
596
+ try {
597
+ const owner = JSON.parse(await readFile(evictOwnerPath, 'utf8'));
598
+ return owner?.ownerId === evictOwnerId;
535
599
  }
536
600
  catch {
601
+ return false;
602
+ }
603
+ }
604
+ async function tryAcquireEvictionMarker() {
605
+ await mkdir(evictPath);
606
+ try {
607
+ await writeFile(evictOwnerPath, `${JSON.stringify({ ownerId: evictOwnerId, createdAt: now() })}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
608
+ }
609
+ catch (error) {
610
+ if (error.code === 'ENOENT')
611
+ return false;
612
+ await releaseEvictionMarker();
613
+ throw error;
614
+ }
615
+ await options.onStep?.('eviction-marker-acquired');
616
+ return true;
617
+ }
618
+ async function releaseEvictionMarker() {
619
+ if (await ownsEvictionMarker()) {
620
+ await rm(evictPath, { recursive: true, force: true }).catch(() => { });
621
+ }
622
+ }
623
+ for (let attempt = 0; attempt < MAX_STEAL_ATTEMPTS; attempt++) {
624
+ acquired = await tryAcquire();
625
+ if (acquired)
626
+ break;
627
+ if (await lockIsLive())
537
628
  return null;
629
+ try {
630
+ if (!(await tryAcquireEvictionMarker())) {
631
+ await backoff();
632
+ continue;
633
+ }
634
+ }
635
+ catch (evictError) {
636
+ const code = evictError.code;
637
+ if (code !== 'EEXIST')
638
+ throw evictError;
639
+ let evictStat;
640
+ try {
641
+ evictStat = await stat(evictPath);
642
+ }
643
+ catch (statError) {
644
+ if (statError.code === 'ENOENT') {
645
+ await backoff();
646
+ continue;
647
+ }
648
+ throw statError;
649
+ }
650
+ if (evictStat.mtimeMs + EVICT_TTL > now())
651
+ return null;
652
+ await options.onStep?.('stale-marker-stat');
653
+ const claimedPath = `${evictPath}.${randomUUID()}`;
654
+ try {
655
+ await rename(evictPath, claimedPath);
656
+ }
657
+ catch (renameError) {
658
+ if (renameError.code === 'ENOENT') {
659
+ await backoff();
660
+ continue;
661
+ }
662
+ throw renameError;
663
+ }
664
+ await options.onStep?.('stale-marker-claimed');
665
+ await rm(claimedPath, { recursive: true, force: true }).catch(() => { });
666
+ await backoff();
667
+ continue;
668
+ }
669
+ try {
670
+ if (await lockIsLive())
671
+ return null;
672
+ if (!(await ownsEvictionMarker()))
673
+ return null;
674
+ await options.onStep?.('stale-lock-confirmed');
675
+ if (!(await ownsEvictionMarker()))
676
+ return null;
677
+ await rm(lockPath, { recursive: true, force: true }).catch(() => { });
678
+ if (!(await ownsEvictionMarker()))
679
+ return null;
680
+ acquired = await tryAcquire();
681
+ if (!acquired)
682
+ return null;
683
+ if (!(await ownsEvictionMarker())) {
684
+ await rm(lockPath, { recursive: true, force: true }).catch(() => { });
685
+ acquired = false;
686
+ return null;
687
+ }
688
+ break;
689
+ }
690
+ finally {
691
+ await releaseEvictionMarker();
538
692
  }
539
693
  }
540
- await rm(lockPath, { recursive: true, force: true }).catch(() => { });
541
- if (!(await tryAcquire()))
542
- return null;
543
694
  }
695
+ if (!acquired)
696
+ return null;
544
697
  scheduleRenewal();
545
698
  return {
546
699
  release: async () => {
@@ -574,11 +727,7 @@ export function getCache1hPersistentMode(storage) {
574
727
  return normalizeCache1hMode(storage?.claudeCache?.mode);
575
728
  }
576
729
  export async function setCache1hPersistentEnabled(enabled, mode, path = getAccountStoragePath()) {
577
- const storage = (await loadAccounts(path)) ?? {
578
- version: 1,
579
- main: { type: 'opencode', provider: 'anthropic' },
580
- accounts: [],
581
- };
730
+ const storage = (await loadAccounts(path)) ?? createEmptyStorage();
582
731
  storage.claudeCache = {
583
732
  ...(storage.claudeCache ?? {}),
584
733
  enabled,
@@ -588,11 +737,7 @@ export async function setCache1hPersistentEnabled(enabled, mode, path = getAccou
588
737
  return storage;
589
738
  }
590
739
  export async function setCache1hPersistentMode(mode, path = getAccountStoragePath()) {
591
- const storage = (await loadAccounts(path)) ?? {
592
- version: 1,
593
- main: { type: 'opencode', provider: 'anthropic' },
594
- accounts: [],
595
- };
740
+ const storage = (await loadAccounts(path)) ?? createEmptyStorage();
596
741
  storage.claudeCache = {
597
742
  ...(storage.claudeCache ?? {}),
598
743
  enabled: storage.claudeCache?.enabled === true,
@@ -605,11 +750,7 @@ export function isDumpPersistentlyEnabled(storage) {
605
750
  return storage?.dump?.enabled === true;
606
751
  }
607
752
  export async function setDumpPersistentEnabled(enabled, path = getAccountStoragePath()) {
608
- const storage = (await loadAccounts(path)) ?? {
609
- version: 1,
610
- main: { type: 'opencode', provider: 'anthropic' },
611
- accounts: [],
612
- };
753
+ const storage = (await loadAccounts(path)) ?? createEmptyStorage();
613
754
  storage.dump = {
614
755
  ...(storage.dump ?? {}),
615
756
  enabled,
@@ -621,11 +762,7 @@ export function isFastModePersistentlyEnabled(storage) {
621
762
  return storage?.claudeFast?.enabled === true;
622
763
  }
623
764
  export async function setFastModePersistentEnabled(enabled, path = getAccountStoragePath()) {
624
- const storage = (await loadAccounts(path)) ?? {
625
- version: 1,
626
- main: { type: 'opencode', provider: 'anthropic' },
627
- accounts: [],
628
- };
765
+ const storage = (await loadAccounts(path)) ?? createEmptyStorage();
629
766
  storage.claudeFast = {
630
767
  ...(storage.claudeFast ?? {}),
631
768
  enabled,
@@ -634,11 +771,7 @@ export async function setFastModePersistentEnabled(enabled, path = getAccountSto
634
771
  return storage;
635
772
  }
636
773
  export async function setCacheKeepPersistentWindow(startHour, endHour, path = getAccountStoragePath()) {
637
- const storage = (await loadAccounts(path)) ?? {
638
- version: 1,
639
- main: { type: 'opencode', provider: 'anthropic' },
640
- accounts: [],
641
- };
774
+ const storage = (await loadAccounts(path)) ?? createEmptyStorage();
642
775
  storage.cacheKeep = {
643
776
  enabled: true,
644
777
  startHour,
@@ -648,11 +781,7 @@ export async function setCacheKeepPersistentWindow(startHour, endHour, path = ge
648
781
  return storage;
649
782
  }
650
783
  export async function setCacheKeepPersistentEnabled(enabled, path = getAccountStoragePath()) {
651
- const storage = (await loadAccounts(path)) ?? {
652
- version: 1,
653
- main: { type: 'opencode', provider: 'anthropic' },
654
- accounts: [],
655
- };
784
+ const storage = (await loadAccounts(path)) ?? createEmptyStorage();
656
785
  storage.cacheKeep = {
657
786
  ...(storage.cacheKeep ?? {}),
658
787
  enabled,
@@ -660,6 +789,18 @@ export async function setCacheKeepPersistentEnabled(enabled, path = getAccountSt
660
789
  await saveAccounts(storage, path);
661
790
  return storage;
662
791
  }
792
+ export function isCacheKeepSubagentsEnabled(storage) {
793
+ return storage?.cacheKeep?.subagents === true;
794
+ }
795
+ export async function setCacheKeepSubagentsEnabled(enabled, path = getAccountStoragePath()) {
796
+ const storage = (await loadAccounts(path)) ?? createEmptyStorage();
797
+ storage.cacheKeep = {
798
+ ...(storage.cacheKeep ?? {}),
799
+ subagents: enabled,
800
+ };
801
+ await saveAccounts(storage, path);
802
+ return storage;
803
+ }
663
804
  function getFallbackStatuses(storage) {
664
805
  return storage?.fallbackOn?.length ? storage.fallbackOn : DEFAULT_FALLBACK_ON;
665
806
  }
@@ -699,8 +840,9 @@ export function hashRefreshToken(refreshToken) {
699
840
  return createHash('sha256').update(refreshToken).digest('hex');
700
841
  }
701
842
  function isTransientRefreshError(error) {
702
- if (error instanceof ClaudeOAuthRefreshError) {
703
- return error.status === 429 || error.status >= 500;
843
+ const status = error.status;
844
+ if (typeof status === 'number' && Number.isFinite(status)) {
845
+ return status === 429 || status >= 500;
704
846
  }
705
847
  if (!(error instanceof Error))
706
848
  return false;
@@ -717,10 +859,11 @@ export function buildRefreshOperationError(input) {
717
859
  ? (input.previous.retryCount ?? 0)
718
860
  : 0;
719
861
  const retryCount = previousRetryCount + 1;
862
+ const retryAfterFromError = input.error
863
+ .retryAfter;
720
864
  let delay;
721
- if (input.error instanceof ClaudeOAuthRefreshError &&
722
- input.error.retryAfter) {
723
- delay = input.error.retryAfter * 1000;
865
+ if (typeof retryAfterFromError === 'number' && retryAfterFromError > 0) {
866
+ delay = retryAfterFromError * 1000;
724
867
  }
725
868
  else if (isTransientRefreshError(input.error)) {
726
869
  delay = Math.min(MAX_REFRESH_RETRY_DELAY_MS, MIN_REFRESH_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6));
@@ -728,14 +871,59 @@ export function buildRefreshOperationError(input) {
728
871
  else {
729
872
  delay = NON_TRANSIENT_REFRESH_RETRY_DELAY_MS;
730
873
  }
874
+ const statusFromError = input.error.status;
875
+ const status = typeof statusFromError === 'number' && Number.isFinite(statusFromError)
876
+ ? statusFromError
877
+ : undefined;
878
+ const message = formatErrorMessage(input.error);
879
+ // A token is permanently dead ONLY on 400 invalid_grant. The OAuth spec allows
880
+ // other 400s (invalid_client / invalid_request / unsupported_grant_type) that
881
+ // re-login does NOT fix — those, like a retry-exhausted / network / 429 / 5xx
882
+ // error, get a long backoff but must stay permanent=false so they are not
883
+ // falsely flagged "needs re-login". ClaudeOAuthRefreshError carries the raw
884
+ // OAuth body, and its message embeds it (`...: 400 — <body>`), so check both.
885
+ const body = typeof input.error.body === 'string'
886
+ ? input.error.body
887
+ : '';
888
+ const isInvalidGrant = body.includes('invalid_grant') || message.includes('invalid_grant');
731
889
  return {
732
- message: formatErrorMessage(input.error),
890
+ message,
733
891
  checkedAt: input.now,
734
892
  nextRetryAt: input.now + delay,
735
893
  retryCount,
736
894
  tokenHash,
895
+ status,
896
+ permanent: status === 400 && isInvalidGrant,
737
897
  };
738
898
  }
899
+ /**
900
+ * True when a refresh error means the token is permanently dead and the account
901
+ * needs a re-login (vs a transient failure that recovers).
902
+ *
903
+ * Precedence:
904
+ * 1. the explicit `permanent` flag (set at construction from 400 invalid_grant)
905
+ * — the authoritative signal; correctly classifies a retry-exhausted/network
906
+ * error (long backoff, but NOT dead) as non-permanent;
907
+ * 2. else the captured HTTP `status` — 400 (for errors built before `permanent`
908
+ * existed but after `status`);
909
+ * 3. else the legacy 24h-delay heuristic — back-compat ONLY for errors persisted
910
+ * before either field existed (e.g. an operator's already-dead token: no
911
+ * status, ~24h backoff). It still flags those until the next refresh restamps
912
+ * the error with the explicit field.
913
+ */
914
+ export function isPermanentRefreshError(error) {
915
+ if (!error)
916
+ return false;
917
+ if (typeof error.permanent === 'boolean')
918
+ return error.permanent;
919
+ if (typeof error.status === 'number')
920
+ return error.status === 400;
921
+ if (typeof error.nextRetryAt === 'number') {
922
+ return (error.nextRetryAt - error.checkedAt >=
923
+ NON_TRANSIENT_REFRESH_RETRY_DELAY_MS);
924
+ }
925
+ return false;
926
+ }
739
927
  export function refreshBackoffActive(error, refreshToken, now) {
740
928
  if (!error?.nextRetryAt || error.nextRetryAt <= now)
741
929
  return false;
@@ -747,6 +935,12 @@ export function formatRefreshBackoffMessage(error, now) {
747
935
  const seconds = Math.max(1, Math.ceil(((error.nextRetryAt ?? now) - now) / 1000));
748
936
  return `Claude OAuth refresh is backed off for ${seconds}s after: ${error.message}`;
749
937
  }
938
+ export function isQuotaPolicyAuthError(error) {
939
+ const status = error.status;
940
+ if (status === 403)
941
+ return true;
942
+ return /Claude quota check failed: 403\b/.test(formatErrorMessage(error));
943
+ }
750
944
  export function buildQuotaOperationError(input) {
751
945
  const previousRetryCount = input.previous?.retryCount ?? 0;
752
946
  const retryCount = previousRetryCount + 1;
@@ -773,6 +967,19 @@ export function getQuotaCheckIntervalMs(storage) {
773
967
  const minutes = storage?.quota?.checkIntervalMinutes ?? DEFAULT_QUOTA_CHECK_INTERVAL_MINUTES;
774
968
  return Math.max(1, minutes) * 60_000;
775
969
  }
970
+ export function getPersistedLogLevel(storage) {
971
+ return storage?.logging?.level;
972
+ }
973
+ export async function setLogLevelPersistent(level, path = getAccountStoragePath()) {
974
+ const { setLogLevel } = await import("./logger.js");
975
+ const storage = (await loadAccounts(path)) ?? createEmptyStorage();
976
+ storage.logging = {
977
+ ...(storage.logging ?? {}),
978
+ level,
979
+ };
980
+ await saveAccounts(storage, path);
981
+ setLogLevel(level);
982
+ }
776
983
  export function getPersistedMainQuota(storage) {
777
984
  if (!storage?.quota?.mainQuota || !storage.quota.mainQuotaCheckedAt)
778
985
  return null;
@@ -804,6 +1011,9 @@ export function quotaSnapshotPassesPolicy(quota, storage) {
804
1011
  const window = quota?.[key];
805
1012
  if (!window)
806
1013
  return !failClosedOnUnknownQuota(storage);
1014
+ if (!Number.isFinite(window.remainingPercent)) {
1015
+ return !failClosedOnUnknownQuota(storage);
1016
+ }
807
1017
  if (window.remainingPercent < thresholds[key])
808
1018
  return false;
809
1019
  }
@@ -818,13 +1028,15 @@ export const DEFAULT_KILLSWITCH_THRESHOLDS = {
818
1028
  seven_day: 10,
819
1029
  };
820
1030
  function normalizeKillswitchThresholds(thresholds) {
1031
+ const fiveHour = thresholds?.five_hour ?? thresholds?.['5h'];
1032
+ const sevenDay = thresholds?.seven_day ?? thresholds?.['1w'];
821
1033
  return {
822
- five_hour: thresholds?.five_hour ??
823
- thresholds?.['5h'] ??
824
- DEFAULT_KILLSWITCH_THRESHOLDS.five_hour,
825
- seven_day: thresholds?.seven_day ??
826
- thresholds?.['1w'] ??
827
- DEFAULT_KILLSWITCH_THRESHOLDS.seven_day,
1034
+ five_hour: typeof fiveHour === 'number' && Number.isFinite(fiveHour)
1035
+ ? fiveHour
1036
+ : DEFAULT_KILLSWITCH_THRESHOLDS.five_hour,
1037
+ seven_day: typeof sevenDay === 'number' && Number.isFinite(sevenDay)
1038
+ ? sevenDay
1039
+ : DEFAULT_KILLSWITCH_THRESHOLDS.seven_day,
828
1040
  };
829
1041
  }
830
1042
  export function isKillswitchEnabled(storage) {
@@ -856,6 +1068,10 @@ export function killswitchPassesPolicy(quota, storage, accountId) {
856
1068
  sawUnknownWindow = true;
857
1069
  continue;
858
1070
  }
1071
+ if (!Number.isFinite(window.remainingPercent)) {
1072
+ sawUnknownWindow = true;
1073
+ continue;
1074
+ }
859
1075
  if (window.remainingPercent < thresholds[key])
860
1076
  return false;
861
1077
  }
@@ -889,15 +1105,41 @@ export function getKillswitchConfig(storage) {
889
1105
  return storage?.killswitch ?? { enabled: false };
890
1106
  }
891
1107
  export async function setKillswitchPersistent(config, path = getAccountStoragePath()) {
892
- const storage = (await loadAccounts(path)) ?? {
893
- version: 1,
894
- main: { type: 'opencode', provider: 'anthropic' },
895
- accounts: [],
896
- };
1108
+ const storage = (await loadAccounts(path)) ?? createEmptyStorage();
897
1109
  storage.killswitch = config;
898
1110
  await saveAccounts(storage, path);
899
1111
  return storage;
900
1112
  }
1113
+ export async function removeAccountPersistent(id, path = getAccountStoragePath()) {
1114
+ const storage = await loadAccounts(path);
1115
+ if (!storage)
1116
+ return false;
1117
+ const existed = removeAccount(storage, id);
1118
+ if (existed)
1119
+ await saveAccounts(storage, path);
1120
+ return existed;
1121
+ }
1122
+ export async function reorderAccountsPersistent(orderedIds, path = getAccountStoragePath()) {
1123
+ const storage = await loadAccounts(path);
1124
+ if (!storage)
1125
+ return;
1126
+ reorderAccounts(storage, orderedIds);
1127
+ await saveAccounts(storage, path);
1128
+ }
1129
+ export async function setAccountEnabledPersistent(id, enabled, path = getAccountStoragePath()) {
1130
+ const storage = await loadAccounts(path);
1131
+ if (!storage)
1132
+ return false;
1133
+ const found = setAccountEnabled(storage, id, enabled);
1134
+ if (found)
1135
+ await saveAccounts(storage, path);
1136
+ return found;
1137
+ }
1138
+ export async function addAccountPersistent(account, path = getAccountStoragePath()) {
1139
+ const storage = (await loadAccounts(path)) ?? createEmptyStorage();
1140
+ upsertAccount(storage, account);
1141
+ await saveAccounts(storage, path);
1142
+ }
901
1143
  export function getQuotaNextRefreshAt(quota, storage, now) {
902
1144
  if (!quotaEnabled(storage))
903
1145
  return now + getQuotaCheckIntervalMs(storage);
@@ -948,11 +1190,21 @@ function cachedQuotaSnapshotStillRelevant(quota, now) {
948
1190
  return ['five_hour', 'seven_day'].every((key) => cachedQuotaWindowStillRelevant(quota?.[key], now));
949
1191
  }
950
1192
  function isTransientQuotaError(error) {
951
- const message = formatErrorMessage(error);
952
- if (/Claude quota check failed: (429|5\d\d)\b/.test(message))
1193
+ const status = error.status;
1194
+ if (typeof status === 'number' && Number.isFinite(status)) {
1195
+ if (status === 429 || status >= 500)
1196
+ return true;
1197
+ }
1198
+ const formattedMessage = formatErrorMessage(error);
1199
+ if (/Claude quota check failed: (429|5\d\d)\b/.test(formattedMessage)) {
953
1200
  return true;
1201
+ }
1202
+ if (formattedMessage.includes('Quota refresh is already in progress')) {
1203
+ return true;
1204
+ }
954
1205
  if (!(error instanceof Error))
955
1206
  return false;
1207
+ const message = error.message;
956
1208
  const code = error.code;
957
1209
  return (message.includes('fetch failed') ||
958
1210
  code === 'ECONNRESET' ||
@@ -977,6 +1229,8 @@ function clampPercent(value) {
977
1229
  function mapUsageWindow(window, checkedAt) {
978
1230
  if (typeof window?.utilization !== 'number')
979
1231
  return undefined;
1232
+ if (!Number.isFinite(window.utilization))
1233
+ return undefined;
980
1234
  const usedPercent = clampPercent(window.utilization);
981
1235
  return {
982
1236
  usedPercent,
@@ -999,7 +1253,11 @@ export async function fetchOAuthQuotaSnapshot(input) {
999
1253
  });
1000
1254
  if (!response.ok) {
1001
1255
  const body = await response.text().catch(() => '');
1002
- throw new Error(`Claude quota check failed: ${response.status} — ${body}`);
1256
+ const error = Object.assign(new Error(`Claude quota check failed: ${response.status} — ${body}`), {
1257
+ status: response.status,
1258
+ retryAfter: parseRetryAfterHeader(response.headers.get('Retry-After')),
1259
+ });
1260
+ throw error;
1003
1261
  }
1004
1262
  const checkedAt = input.now?.() ?? Date.now();
1005
1263
  const usage = (await response.json());
@@ -1013,6 +1271,46 @@ function updateStoredAccount(storage, account) {
1013
1271
  if (index >= 0)
1014
1272
  storage.accounts[index] = account;
1015
1273
  }
1274
+ export function upsertAccount(storage, account) {
1275
+ const index = storage.accounts.findIndex((candidate) => candidate.id === account.id ||
1276
+ (account.label && candidate.label === account.label));
1277
+ if (index >= 0) {
1278
+ storage.accounts[index] = {
1279
+ ...storage.accounts[index],
1280
+ ...account,
1281
+ addedAt: storage.accounts[index]?.addedAt ?? account.addedAt,
1282
+ ...(account.type === 'oauth' && {
1283
+ quota: account.quota,
1284
+ lastRefreshedAt: account.lastRefreshedAt,
1285
+ lastRefreshError: account.lastRefreshError,
1286
+ lastQuotaRefreshError: account.lastQuotaRefreshError,
1287
+ }),
1288
+ };
1289
+ return;
1290
+ }
1291
+ storage.accounts.push(account);
1292
+ }
1293
+ export function removeAccount(storage, id) {
1294
+ const index = storage.accounts.findIndex((c) => c.id === id);
1295
+ if (index < 0)
1296
+ return false;
1297
+ storage.accounts.splice(index, 1);
1298
+ return true;
1299
+ }
1300
+ export function reorderAccounts(storage, orderedIds) {
1301
+ const orderMap = new Map(orderedIds.map((id, i) => [id, i]));
1302
+ const known = storage.accounts.filter((a) => orderMap.has(a.id));
1303
+ const unknown = storage.accounts.filter((a) => !orderMap.has(a.id));
1304
+ known.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
1305
+ storage.accounts = [...known, ...unknown];
1306
+ }
1307
+ export function setAccountEnabled(storage, id, enabled) {
1308
+ const account = storage.accounts.find((c) => c.id === id);
1309
+ if (!account)
1310
+ return false;
1311
+ account.enabled = enabled;
1312
+ return true;
1313
+ }
1016
1314
  function formatErrorMessage(error) {
1017
1315
  return error instanceof Error ? error.message : String(error);
1018
1316
  }
@@ -1025,12 +1323,14 @@ function recordRefreshError(account, error, now) {
1025
1323
  });
1026
1324
  }
1027
1325
  function recordQuotaRefreshError(account, error, now) {
1326
+ if (isQuotaPolicyAuthError(error))
1327
+ return;
1028
1328
  account.lastQuotaRefreshError = buildQuotaOperationError({
1029
1329
  error,
1030
1330
  now,
1031
1331
  previous: account.lastQuotaRefreshError,
1032
1332
  });
1033
- if (error instanceof ClaudeOAuthRefreshError) {
1333
+ if (error.isRefreshError) {
1034
1334
  recordRefreshError(account, error, now);
1035
1335
  }
1036
1336
  }
@@ -1218,7 +1518,7 @@ export class FallbackAccountManager {
1218
1518
  changed = true;
1219
1519
  }
1220
1520
  catch (error) {
1221
- log('[refresh] fallback oauth background failed', {
1521
+ logger.warn('refresh', 'fallback oauth background failed', {
1222
1522
  accountId: account.id,
1223
1523
  error: error instanceof Error ? error.message : String(error),
1224
1524
  });