@cortexkit/opencode-antigravity-auth 1.0.0 → 1.1.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.
Files changed (61) hide show
  1. package/README.md +24 -8
  2. package/dist/index.js +1450 -467
  3. package/dist/index.js.map +4 -4
  4. package/dist/src/plugin/accounts.d.ts +36 -9
  5. package/dist/src/plugin/accounts.d.ts.map +1 -1
  6. package/dist/src/plugin/accounts.js +269 -57
  7. package/dist/src/plugin/accounts.js.map +1 -1
  8. package/dist/src/plugin/agy-request-metadata.d.ts +2 -0
  9. package/dist/src/plugin/agy-request-metadata.d.ts.map +1 -0
  10. package/dist/src/plugin/agy-request-metadata.js +2 -0
  11. package/dist/src/plugin/agy-request-metadata.js.map +1 -0
  12. package/dist/src/plugin/auth-doctor.d.ts +1 -1
  13. package/dist/src/plugin/auth-doctor.d.ts.map +1 -1
  14. package/dist/src/plugin/auth-doctor.js +9 -0
  15. package/dist/src/plugin/auth-doctor.js.map +1 -1
  16. package/dist/src/plugin/debug.d.ts.map +1 -1
  17. package/dist/src/plugin/debug.js +3 -2
  18. package/dist/src/plugin/debug.js.map +1 -1
  19. package/dist/src/plugin/gemini-dump.d.ts.map +1 -1
  20. package/dist/src/plugin/gemini-dump.js +8 -4
  21. package/dist/src/plugin/gemini-dump.js.map +1 -1
  22. package/dist/src/plugin/image-saver.d.ts +2 -21
  23. package/dist/src/plugin/image-saver.d.ts.map +1 -1
  24. package/dist/src/plugin/image-saver.js +29 -63
  25. package/dist/src/plugin/image-saver.js.map +1 -1
  26. package/dist/src/plugin/request-helpers.d.ts +1 -0
  27. package/dist/src/plugin/request-helpers.d.ts.map +1 -1
  28. package/dist/src/plugin/request-helpers.js +9 -4
  29. package/dist/src/plugin/request-helpers.js.map +1 -1
  30. package/dist/src/plugin/request.d.ts +15 -4
  31. package/dist/src/plugin/request.d.ts.map +1 -1
  32. package/dist/src/plugin/request.js +255 -71
  33. package/dist/src/plugin/request.js.map +1 -1
  34. package/dist/src/plugin/session-context.d.ts +21 -0
  35. package/dist/src/plugin/session-context.d.ts.map +1 -0
  36. package/dist/src/plugin/session-context.js +63 -0
  37. package/dist/src/plugin/session-context.js.map +1 -0
  38. package/dist/src/plugin/storage.d.ts +6 -0
  39. package/dist/src/plugin/storage.d.ts.map +1 -1
  40. package/dist/src/plugin/storage.js +27 -6
  41. package/dist/src/plugin/storage.js.map +1 -1
  42. package/dist/src/plugin/ui/auth-menu.d.ts +1 -1
  43. package/dist/src/plugin/ui/auth-menu.d.ts.map +1 -1
  44. package/dist/src/plugin/ui/auth-menu.js +3 -1
  45. package/dist/src/plugin/ui/auth-menu.js.map +1 -1
  46. package/dist/src/plugin/ui/quota-status.d.ts.map +1 -1
  47. package/dist/src/plugin/ui/quota-status.js +2 -1
  48. package/dist/src/plugin/ui/quota-status.js.map +1 -1
  49. package/dist/src/plugin.d.ts +16 -0
  50. package/dist/src/plugin.d.ts.map +1 -1
  51. package/dist/src/plugin.js +288 -102
  52. package/dist/src/plugin.js.map +1 -1
  53. package/package.json +2 -2
  54. package/dist/src/plugin/transform/claude.d.ts +0 -92
  55. package/dist/src/plugin/transform/claude.d.ts.map +0 -1
  56. package/dist/src/plugin/transform/claude.js +0 -280
  57. package/dist/src/plugin/transform/claude.js.map +0 -1
  58. package/dist/src/plugin/transform/gemini.d.ts +0 -100
  59. package/dist/src/plugin/transform/gemini.d.ts.map +0 -1
  60. package/dist/src/plugin/transform/gemini.js +0 -446
  61. package/dist/src/plugin/transform/gemini.js.map +0 -1
@@ -1,5 +1,5 @@
1
1
  import { formatRefreshParts, parseRefreshParts } from "./auth";
2
- import { loadAccounts, saveAccounts } from "./storage";
2
+ import { loadAccounts, saveAccounts, saveAccountsReplace } from "./storage";
3
3
  import { getHealthTracker, getTokenTracker, selectHybridAccount } from "./rotation";
4
4
  import { generateFingerprint, updateFingerprintVersion, MAX_FINGERPRINT_HISTORY } from "./fingerprint";
5
5
  import { getModelFamily } from "./transform/model-resolver";
@@ -193,6 +193,8 @@ export function computeSoftQuotaCacheTtlMs(ttlConfig, refreshIntervalMinutes) {
193
193
  }
194
194
  return ttlConfig * 60 * 1000;
195
195
  }
196
+ const ACCOUNT_SESSION_STATE_TTL_MS = 24 * 60 * 60 * 1000;
197
+ const MAX_ACCOUNT_SESSION_STATES = 256;
196
198
  /**
197
199
  * In-memory multi-account manager with sticky account selection.
198
200
  *
@@ -221,6 +223,7 @@ export class AccountManager {
221
223
  sessionStartTime = Date.now();
222
224
  sessionRequestCounts = new Map();
223
225
  sessionUsedAccounts = new Set();
226
+ requestSessionStates = new Map();
224
227
  static async loadFromDisk(authFallback) {
225
228
  const stored = await loadAccounts();
226
229
  return new AccountManager(authFallback, stored);
@@ -266,9 +269,14 @@ export class AccountManager {
266
269
  cachedQuota: acc.cachedQuota,
267
270
  cachedQuotaUpdatedAt: acc.cachedQuotaUpdatedAt,
268
271
  dailyRequestCounts: acc.dailyRequestCounts,
269
- verificationRequired: acc.verificationRequired, verificationRequiredAt: acc.verificationRequiredAt,
272
+ verificationRequired: acc.verificationRequired,
273
+ verificationRequiredAt: acc.verificationRequiredAt,
270
274
  verificationRequiredReason: acc.verificationRequiredReason,
271
275
  verificationUrl: acc.verificationUrl,
276
+ accountIneligible: acc.accountIneligible,
277
+ accountIneligibleAt: acc.accountIneligibleAt,
278
+ accountIneligibleReason: acc.accountIneligibleReason,
279
+ eligibilityStateUpdatedAt: acc.eligibilityStateUpdatedAt,
272
280
  };
273
281
  })
274
282
  .filter((a) => a !== null);
@@ -350,11 +358,111 @@ export class AccountManager {
350
358
  getEnabledAccounts() {
351
359
  return this.accounts.filter((account) => account.enabled !== false);
352
360
  }
361
+ getEffectiveSoftQuotaThreshold(thresholdPercent) {
362
+ // Soft-quota protection only has a purpose when another enabled account
363
+ // exists to rotate to. Never block the sole usable account.
364
+ return this.getEnabledAccounts().length > 1 ? thresholdPercent : 100;
365
+ }
353
366
  getAccountsSnapshot() {
354
367
  return this.accounts.map((a) => ({ ...a, parts: { ...a.parts }, rateLimitResetTimes: { ...a.rateLimitResetTimes } }));
355
368
  }
356
- getCurrentAccountForFamily(family) {
357
- const currentIndex = this.currentAccountIndexByFamily[family];
369
+ getRequestSessionState(identity) {
370
+ const now = nowMs();
371
+ this.pruneRequestSessionStates(now, identity.id);
372
+ const existing = this.requestSessionStates.get(identity.id);
373
+ if (existing) {
374
+ existing.lastAccessedAt = now;
375
+ if (identity.parentId) {
376
+ existing.parentId = identity.parentId;
377
+ }
378
+ return existing;
379
+ }
380
+ const state = {
381
+ parentId: identity.parentId ?? null,
382
+ currentAccountIndexByFamily: { claude: -1, gemini: -1 },
383
+ cursorByFamily: { ...this.cursorByFamily },
384
+ offsetAppliedByFamily: { claude: false, gemini: false },
385
+ usedAccounts: new Set(),
386
+ lastAccessedAt: now,
387
+ };
388
+ this.requestSessionStates.set(identity.id, state);
389
+ return state;
390
+ }
391
+ pruneRequestSessionStates(now, preservedId) {
392
+ const expiry = now - ACCOUNT_SESSION_STATE_TTL_MS;
393
+ for (const [id, state] of this.requestSessionStates) {
394
+ if (id !== preservedId && state.lastAccessedAt < expiry) {
395
+ this.requestSessionStates.delete(id);
396
+ }
397
+ }
398
+ if (this.requestSessionStates.size < MAX_ACCOUNT_SESSION_STATES || this.requestSessionStates.has(preservedId)) {
399
+ return;
400
+ }
401
+ let oldestId = null;
402
+ let oldestAccess = Number.POSITIVE_INFINITY;
403
+ for (const [id, state] of this.requestSessionStates) {
404
+ if (id !== preservedId && state.lastAccessedAt < oldestAccess) {
405
+ oldestId = id;
406
+ oldestAccess = state.lastAccessedAt;
407
+ }
408
+ }
409
+ if (oldestId) {
410
+ this.requestSessionStates.delete(oldestId);
411
+ }
412
+ }
413
+ getActiveIndex(family, identity) {
414
+ return identity
415
+ ? this.getRequestSessionState(identity).currentAccountIndexByFamily[family]
416
+ : this.currentAccountIndexByFamily[family];
417
+ }
418
+ setActiveIndex(family, index, identity) {
419
+ if (!identity) {
420
+ this.currentAccountIndexByFamily[family] = index;
421
+ return;
422
+ }
423
+ const state = this.getRequestSessionState(identity);
424
+ state.currentAccountIndexByFamily[family] = index;
425
+ if (!state.parentId) {
426
+ // Preserve a useful persisted starting point without coupling active root sessions.
427
+ this.currentAccountIndexByFamily[family] = index;
428
+ }
429
+ }
430
+ getCursor(family, identity) {
431
+ return identity
432
+ ? this.getRequestSessionState(identity).cursorByFamily[family]
433
+ : this.cursorByFamily[family];
434
+ }
435
+ advanceCursor(family, identity) {
436
+ const nextGlobalCursor = this.cursorByFamily[family] + 1;
437
+ this.cursorByFamily[family] = nextGlobalCursor;
438
+ if (identity) {
439
+ this.getRequestSessionState(identity).cursorByFamily[family] += 1;
440
+ }
441
+ }
442
+ getUsedAccounts(identity) {
443
+ return identity ? this.getRequestSessionState(identity).usedAccounts : this.sessionUsedAccounts;
444
+ }
445
+ preferAccountOutsideParent(accounts, family, identity) {
446
+ if (!identity) {
447
+ return accounts;
448
+ }
449
+ const parentId = this.getRequestSessionState(identity).parentId;
450
+ if (!parentId) {
451
+ return accounts;
452
+ }
453
+ const parentState = this.requestSessionStates.get(parentId);
454
+ const parentIndex = parentState?.currentAccountIndexByFamily[family] ?? -1;
455
+ if (parentIndex < 0) {
456
+ return accounts;
457
+ }
458
+ const isolated = accounts.filter((account) => account.index !== parentIndex);
459
+ return isolated.length > 0 ? isolated : accounts;
460
+ }
461
+ deleteSessionState(sessionId) {
462
+ this.requestSessionStates.delete(sessionId);
463
+ }
464
+ getCurrentAccountForFamily(family, identity) {
465
+ const currentIndex = this.getActiveIndex(family, identity);
358
466
  if (currentIndex >= 0 && currentIndex < this.accounts.length) {
359
467
  const account = this.accounts[currentIndex] ?? null;
360
468
  // Only return account if it's enabled - disabled accounts should not be selected
@@ -364,9 +472,9 @@ export class AccountManager {
364
472
  }
365
473
  return null;
366
474
  }
367
- markSwitched(account, reason, family) {
475
+ markSwitched(account, reason, family, identity) {
368
476
  account.lastSwitchReason = reason;
369
- this.currentAccountIndexByFamily[family] = account.index;
477
+ this.setActiveIndex(family, account.index, identity);
370
478
  }
371
479
  /**
372
480
  * Check if we should show an account switch toast.
@@ -383,41 +491,56 @@ export class AccountManager {
383
491
  this.lastToastAccountIndex = accountIndex;
384
492
  this.lastToastTime = nowMs();
385
493
  }
386
- getCurrentOrNextForFamily(family, model, strategy = 'sticky', headerStyle = 'antigravity', pidOffsetEnabled = false, softQuotaThresholdPercent = 100, softQuotaCacheTtlMs = 10 * 60 * 1000) {
494
+ getCurrentOrNextForFamily(family, model, strategy = 'sticky', headerStyle = 'antigravity', pidOffsetEnabled = false, softQuotaThresholdPercent = 100, softQuotaCacheTtlMs = 10 * 60 * 1000, identity) {
387
495
  const quotaKey = getQuotaKey(family, headerStyle, model);
496
+ const effectiveSoftQuotaThreshold = this.getEffectiveSoftQuotaThreshold(softQuotaThresholdPercent);
497
+ // OpenCode may run many root and child sessions concurrently in one plugin
498
+ // process. Pin each exact session until its account becomes unavailable.
499
+ if (identity) {
500
+ const pinned = this.getCurrentAccountForFamily(family, identity);
501
+ if (pinned) {
502
+ clearExpiredRateLimits(pinned);
503
+ const unavailable = isRateLimitedForHeaderStyle(pinned, family, headerStyle, model) ||
504
+ isOverSoftQuotaThreshold(pinned, family, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, model) ||
505
+ this.isAccountCoolingDown(pinned);
506
+ if (!unavailable) {
507
+ this.markTouchedForQuota(pinned, quotaKey);
508
+ return pinned;
509
+ }
510
+ }
511
+ }
388
512
  if (strategy === 'round-robin') {
389
- const next = this.getNextForFamily(family, model, headerStyle, softQuotaThresholdPercent, softQuotaCacheTtlMs);
513
+ const next = this.getNextForFamily(family, model, headerStyle, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, identity);
390
514
  if (next) {
391
515
  this.markTouchedForQuota(next, quotaKey);
392
- this.currentAccountIndexByFamily[family] = next.index;
516
+ this.setActiveIndex(family, next.index, identity);
393
517
  }
394
518
  return next;
395
519
  }
396
520
  if (strategy === 'hybrid') {
397
521
  const healthTracker = getHealthTracker();
398
522
  const tokenTracker = getTokenTracker();
399
- const accountsWithMetrics = this.accounts
400
- .filter(acc => acc.enabled !== false)
401
- .map(acc => {
523
+ const eligibleAccounts = this.preferAccountOutsideParent(this.accounts.filter(acc => acc.enabled !== false), family, identity);
524
+ const accountsWithMetrics = eligibleAccounts.map(acc => {
402
525
  clearExpiredRateLimits(acc);
403
526
  return {
404
527
  index: acc.index,
405
528
  lastUsed: acc.lastUsed,
406
529
  healthScore: healthTracker.getScore(acc.index),
407
530
  isRateLimited: isRateLimitedForFamily(acc, family, model) ||
408
- isOverSoftQuotaThreshold(acc, family, softQuotaThresholdPercent, softQuotaCacheTtlMs, model),
531
+ isOverSoftQuotaThreshold(acc, family, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, model),
409
532
  isCoolingDown: this.isAccountCoolingDown(acc),
410
533
  };
411
534
  });
412
535
  // Get current account index for stickiness
413
- const currentIndex = this.currentAccountIndexByFamily[family] ?? null;
536
+ const currentIndex = this.getActiveIndex(family, identity);
414
537
  const selectedIndex = selectHybridAccount(accountsWithMetrics, tokenTracker, currentIndex);
415
538
  if (selectedIndex !== null) {
416
539
  const selected = this.accounts[selectedIndex];
417
540
  if (selected) {
418
541
  selected.lastUsed = nowMs();
419
542
  this.markTouchedForQuota(selected, quotaKey);
420
- this.currentAccountIndexByFamily[family] = selected.index;
543
+ this.setActiveIndex(family, selected.index, identity);
421
544
  return selected;
422
545
  }
423
546
  }
@@ -425,50 +548,57 @@ export class AccountManager {
425
548
  // Fallback: sticky selection (used when hybrid finds no candidates)
426
549
  // PID-based offset for multi-session distribution (opt-in)
427
550
  // Different sessions (PIDs) will prefer different starting accounts
428
- if (pidOffsetEnabled && !this.sessionOffsetApplied[family] && this.accounts.length > 1) {
551
+ const offsetApplied = identity
552
+ ? this.getRequestSessionState(identity).offsetAppliedByFamily
553
+ : this.sessionOffsetApplied;
554
+ if (pidOffsetEnabled && !offsetApplied[family] && this.accounts.length > 1) {
429
555
  const pidOffset = process.pid % this.accounts.length;
430
- const baseIndex = this.currentAccountIndexByFamily[family] ?? 0;
556
+ const activeIndex = this.getActiveIndex(family, identity);
557
+ const baseIndex = activeIndex >= 0 ? activeIndex : this.getCursor(family, identity);
431
558
  const newIndex = (baseIndex + pidOffset) % this.accounts.length;
432
559
  debugLogToFile(`[Account] Applying PID offset: pid=${process.pid} offset=${pidOffset} family=${family} index=${baseIndex}->${newIndex}`);
433
- this.currentAccountIndexByFamily[family] = newIndex;
434
- this.sessionOffsetApplied[family] = true;
560
+ this.setActiveIndex(family, newIndex, identity);
561
+ offsetApplied[family] = true;
435
562
  }
436
- const current = this.getCurrentAccountForFamily(family);
563
+ const current = this.getCurrentAccountForFamily(family, identity);
437
564
  if (current) {
438
565
  clearExpiredRateLimits(current);
439
566
  const isLimitedForRequestedStyle = isRateLimitedForHeaderStyle(current, family, headerStyle, model);
440
- const isOverThreshold = isOverSoftQuotaThreshold(current, family, softQuotaThresholdPercent, softQuotaCacheTtlMs, model);
567
+ const isOverThreshold = isOverSoftQuotaThreshold(current, family, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, model);
441
568
  if (!isLimitedForRequestedStyle && !isOverThreshold && !this.isAccountCoolingDown(current)) {
442
569
  this.markTouchedForQuota(current, quotaKey);
443
570
  return current;
444
571
  }
445
572
  }
446
- const next = this.getNextForFamily(family, model, headerStyle, softQuotaThresholdPercent, softQuotaCacheTtlMs);
573
+ const next = this.getNextForFamily(family, model, headerStyle, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, identity);
447
574
  if (next) {
448
575
  this.markTouchedForQuota(next, quotaKey);
449
- this.currentAccountIndexByFamily[family] = next.index;
576
+ this.setActiveIndex(family, next.index, identity);
450
577
  }
451
578
  return next;
452
579
  }
453
- getNextForFamily(family, model, headerStyle = "antigravity", softQuotaThresholdPercent = 100, softQuotaCacheTtlMs = 10 * 60 * 1000) {
454
- const available = this.accounts.filter((a) => {
455
- clearExpiredRateLimits(a);
456
- return a.enabled !== false &&
457
- !isRateLimitedForHeaderStyle(a, family, headerStyle, model) &&
458
- !isOverSoftQuotaThreshold(a, family, softQuotaThresholdPercent, softQuotaCacheTtlMs, model) &&
459
- !this.isAccountCoolingDown(a);
580
+ getNextForFamily(family, model, headerStyle = "antigravity", softQuotaThresholdPercent = 100, softQuotaCacheTtlMs = 10 * 60 * 1000, identity) {
581
+ const effectiveSoftQuotaThreshold = this.getEffectiveSoftQuotaThreshold(softQuotaThresholdPercent);
582
+ const allAvailable = this.accounts.filter((account) => {
583
+ clearExpiredRateLimits(account);
584
+ return account.enabled !== false &&
585
+ !isRateLimitedForHeaderStyle(account, family, headerStyle, model) &&
586
+ !isOverSoftQuotaThreshold(account, family, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, model) &&
587
+ !this.isAccountCoolingDown(account);
460
588
  });
589
+ const available = this.preferAccountOutsideParent(allAvailable, family, identity);
461
590
  if (available.length === 0) {
462
591
  return null;
463
592
  }
464
- const sessionUsed = available.filter(a => this.sessionUsedAccounts.has(a.index));
593
+ const usedAccounts = this.getUsedAccounts(identity);
594
+ const sessionUsed = available.filter(account => usedAccounts.has(account.index));
465
595
  const candidates = sessionUsed.length > 0 ? sessionUsed : available;
466
- const cursor = this.cursorByFamily[family];
596
+ const cursor = this.getCursor(family, identity);
467
597
  const account = candidates[cursor % candidates.length];
468
598
  if (!account) {
469
599
  return null;
470
600
  }
471
- this.cursorByFamily[family] = cursor + 1;
601
+ this.advanceCursor(family, identity);
472
602
  return account;
473
603
  }
474
604
  markRateLimited(account, retryAfterMs, family, headerStyle = "antigravity", model) {
@@ -486,16 +616,16 @@ export class AccountManager {
486
616
  account.lastUsed = nowMs();
487
617
  }
488
618
  }
489
- recordSessionUsage(accountIndex) {
490
- this.sessionUsedAccounts.add(accountIndex);
619
+ recordSessionUsage(accountIndex, identity) {
620
+ this.getUsedAccounts(identity).add(accountIndex);
491
621
  }
492
- wasUsedInSession(accountIndex) {
493
- return this.sessionUsedAccounts.has(accountIndex);
622
+ wasUsedInSession(accountIndex, identity) {
623
+ return this.getUsedAccounts(identity).has(accountIndex);
494
624
  }
495
- shouldProactivelyRotate(family, model, thresholdPercent, cacheTtlMs) {
625
+ shouldProactivelyRotate(family, model, thresholdPercent, cacheTtlMs, identity) {
496
626
  if (thresholdPercent <= 0)
497
627
  return false;
498
- const current = this.getCurrentAccountForFamily(family);
628
+ const current = this.getCurrentAccountForFamily(family, identity);
499
629
  if (!current || !current.cachedQuota || current.cachedQuotaUpdatedAt == null)
500
630
  return false;
501
631
  const age = nowMs() - current.cachedQuotaUpdatedAt;
@@ -508,9 +638,9 @@ export class AccountManager {
508
638
  const remainingPercent = Math.max(0, Math.min(100, groupData.remainingFraction * 100));
509
639
  return remainingPercent < thresholdPercent;
510
640
  }
511
- proactivelyRotateForFamily(family, model, headerStyle, softQuotaThresholdPercent, softQuotaCacheTtlMs) {
512
- const currentIndex = this.currentAccountIndexByFamily[family];
513
- const candidates = this.accounts.filter(acc => {
641
+ proactivelyRotateForFamily(family, model, headerStyle, softQuotaThresholdPercent, softQuotaCacheTtlMs, identity) {
642
+ const currentIndex = this.getActiveIndex(family, identity);
643
+ const candidates = this.preferAccountOutsideParent(this.accounts.filter(acc => {
514
644
  if (acc.enabled === false)
515
645
  return false;
516
646
  if (acc.index === currentIndex)
@@ -523,10 +653,11 @@ export class AccountManager {
523
653
  if (this.isAccountCoolingDown(acc))
524
654
  return false;
525
655
  return true;
526
- });
656
+ }), family, identity);
527
657
  if (candidates.length === 0)
528
658
  return null;
529
- const warmCandidates = candidates.filter(a => this.sessionUsedAccounts.has(a.index));
659
+ const usedAccounts = this.getUsedAccounts(identity);
660
+ const warmCandidates = candidates.filter(account => usedAccounts.has(account.index));
530
661
  const pool = warmCandidates.length > 0 ? warmCandidates : candidates;
531
662
  const quotaGroup = resolveQuotaGroup(family, model);
532
663
  pool.sort((a, b) => {
@@ -539,7 +670,7 @@ export class AccountManager {
539
670
  return null;
540
671
  const quotaKey = getQuotaKey(family, headerStyle, model);
541
672
  this.markTouchedForQuota(selected, quotaKey);
542
- this.currentAccountIndexByFamily[family] = selected.index;
673
+ this.setActiveIndex(family, selected.index, identity);
543
674
  return selected;
544
675
  }
545
676
  markRateLimitedWithReason(account, family, headerStyle, model, reason, retryAfterMs, failureTtlMs = 3600_000) {
@@ -679,6 +810,9 @@ export class AccountManager {
679
810
  if (!account) {
680
811
  return false;
681
812
  }
813
+ if (enabled && account.accountIneligible) {
814
+ return false;
815
+ }
682
816
  account.enabled = enabled;
683
817
  if (!enabled) {
684
818
  for (const family of Object.keys(this.currentAccountIndexByFamily)) {
@@ -696,9 +830,18 @@ export class AccountManager {
696
830
  if (!account) {
697
831
  return false;
698
832
  }
833
+ const timestamp = nowMs();
699
834
  account.verificationRequired = true;
700
- account.verificationRequiredAt = nowMs();
835
+ account.verificationRequiredAt = timestamp;
701
836
  account.verificationRequiredReason = reason?.trim() || undefined;
837
+ if (account.accountIneligible === true ||
838
+ account.accountIneligibleAt !== undefined ||
839
+ account.accountIneligibleReason !== undefined) {
840
+ account.accountIneligible = false;
841
+ account.accountIneligibleAt = undefined;
842
+ account.accountIneligibleReason = undefined;
843
+ account.eligibilityStateUpdatedAt = timestamp;
844
+ }
702
845
  const normalizedVerifyUrl = verifyUrl?.trim();
703
846
  if (normalizedVerifyUrl) {
704
847
  account.verificationUrl = normalizedVerifyUrl;
@@ -711,23 +854,56 @@ export class AccountManager {
711
854
  }
712
855
  return true;
713
856
  }
714
- clearAccountVerificationRequired(accountIndex, enableAccount = false) {
857
+ markAccountIneligible(accountIndex, reason) {
858
+ const account = this.accounts[accountIndex];
859
+ if (!account) {
860
+ return false;
861
+ }
862
+ const timestamp = nowMs();
863
+ account.accountIneligible = true;
864
+ account.accountIneligibleAt = timestamp;
865
+ account.accountIneligibleReason = reason?.trim() || "Google marked this account as ineligible.";
866
+ account.eligibilityStateUpdatedAt = timestamp;
867
+ account.verificationRequired = false;
868
+ account.verificationRequiredAt = undefined;
869
+ account.verificationRequiredReason = undefined;
870
+ account.verificationUrl = undefined;
871
+ if (account.enabled !== false) {
872
+ this.setAccountEnabled(accountIndex, false);
873
+ }
874
+ else {
875
+ this.requestSaveToDisk();
876
+ }
877
+ return true;
878
+ }
879
+ clearAccountAccessBlocks(accountIndex, enableAccount = false) {
715
880
  const account = this.accounts[accountIndex];
716
881
  if (!account) {
717
882
  return false;
718
883
  }
719
884
  const wasVerificationRequired = account.verificationRequired === true;
720
- const hadMetadata = (account.verificationRequiredAt !== undefined ||
885
+ const wasIneligible = account.accountIneligible === true;
886
+ const hadMetadata = wasVerificationRequired || wasIneligible ||
887
+ account.verificationRequiredAt !== undefined ||
721
888
  account.verificationRequiredReason !== undefined ||
722
- account.verificationUrl !== undefined);
889
+ account.verificationUrl !== undefined ||
890
+ account.accountIneligibleAt !== undefined ||
891
+ account.accountIneligibleReason !== undefined ||
892
+ account.eligibilityStateUpdatedAt !== undefined;
723
893
  account.verificationRequired = false;
724
894
  account.verificationRequiredAt = undefined;
725
895
  account.verificationRequiredReason = undefined;
726
896
  account.verificationUrl = undefined;
727
- if (enableAccount && wasVerificationRequired && account.enabled === false) {
897
+ account.accountIneligible = false;
898
+ account.accountIneligibleAt = undefined;
899
+ account.accountIneligibleReason = undefined;
900
+ if (wasIneligible || account.eligibilityStateUpdatedAt !== undefined) {
901
+ account.eligibilityStateUpdatedAt = nowMs();
902
+ }
903
+ if (enableAccount && (wasVerificationRequired || wasIneligible) && account.enabled === false) {
728
904
  this.setAccountEnabled(accountIndex, true);
729
905
  }
730
- else if (wasVerificationRequired || hadMetadata) {
906
+ else if (hadMetadata) {
731
907
  this.requestSaveToDisk();
732
908
  }
733
909
  return true;
@@ -755,6 +931,7 @@ export class AccountManager {
755
931
  this.cursorByFamily = { claude: 0, gemini: 0 };
756
932
  this.currentAccountIndexByFamily.claude = -1;
757
933
  this.currentAccountIndexByFamily.gemini = -1;
934
+ this.requestSessionStates.clear();
758
935
  return true;
759
936
  }
760
937
  for (const family of ["claude", "gemini"]) {
@@ -768,6 +945,24 @@ export class AccountManager {
768
945
  if (this.currentAccountIndexByFamily[family] >= this.accounts.length) {
769
946
  this.currentAccountIndexByFamily[family] = -1;
770
947
  }
948
+ for (const state of this.requestSessionStates.values()) {
949
+ const currentIndex = state.currentAccountIndexByFamily[family];
950
+ if (currentIndex === idx) {
951
+ state.currentAccountIndexByFamily[family] = -1;
952
+ }
953
+ else if (currentIndex > idx) {
954
+ state.currentAccountIndexByFamily[family] -= 1;
955
+ }
956
+ if (state.cursorByFamily[family] > idx) {
957
+ state.cursorByFamily[family] -= 1;
958
+ }
959
+ state.cursorByFamily[family] %= this.accounts.length;
960
+ }
961
+ }
962
+ for (const state of this.requestSessionStates.values()) {
963
+ state.usedAccounts = new Set([...state.usedAccounts]
964
+ .filter((accountIndex) => accountIndex !== idx)
965
+ .map((accountIndex) => accountIndex > idx ? accountIndex - 1 : accountIndex));
771
966
  }
772
967
  return true;
773
968
  }
@@ -829,10 +1024,10 @@ export class AccountManager {
829
1024
  getAccounts() {
830
1025
  return [...this.accounts];
831
1026
  }
832
- async saveToDisk() {
1027
+ buildStorageSnapshot() {
833
1028
  const claudeIndex = Math.max(0, this.currentAccountIndexByFamily.claude);
834
1029
  const geminiIndex = Math.max(0, this.currentAccountIndexByFamily.gemini);
835
- const storage = {
1030
+ return {
836
1031
  version: 4,
837
1032
  accounts: this.accounts.map((a) => ({
838
1033
  email: a.email,
@@ -852,13 +1047,28 @@ export class AccountManager {
852
1047
  verificationRequiredAt: a.verificationRequiredAt,
853
1048
  verificationRequiredReason: a.verificationRequiredReason,
854
1049
  verificationUrl: a.verificationUrl,
855
- })), activeIndex: claudeIndex,
1050
+ accountIneligible: a.accountIneligible,
1051
+ accountIneligibleAt: a.accountIneligibleAt,
1052
+ accountIneligibleReason: a.accountIneligibleReason,
1053
+ eligibilityStateUpdatedAt: a.eligibilityStateUpdatedAt,
1054
+ })),
1055
+ activeIndex: claudeIndex,
856
1056
  activeIndexByFamily: {
857
1057
  claude: claudeIndex,
858
1058
  gemini: geminiIndex,
859
1059
  },
860
1060
  };
861
- await saveAccounts(storage);
1061
+ }
1062
+ async saveToDisk() {
1063
+ await saveAccounts(this.buildStorageSnapshot());
1064
+ }
1065
+ /**
1066
+ * Persist via full-file replace (no merge). Required after destructive
1067
+ * operations (account removal) so a deleted account is not resurrected by
1068
+ * mergeAccountStorage re-reading it from disk.
1069
+ */
1070
+ async saveToDiskReplace() {
1071
+ await saveAccountsReplace(this.buildStorageSnapshot());
862
1072
  }
863
1073
  requestSaveToDisk() {
864
1074
  if (this.savePending) {
@@ -1086,7 +1296,7 @@ export class AccountManager {
1086
1296
  };
1087
1297
  }
1088
1298
  isAccountOverSoftQuota(account, family, thresholdPercent, cacheTtlMs, model) {
1089
- return isOverSoftQuotaThreshold(account, family, thresholdPercent, cacheTtlMs, model);
1299
+ return isOverSoftQuotaThreshold(account, family, this.getEffectiveSoftQuotaThreshold(thresholdPercent), cacheTtlMs, model);
1090
1300
  }
1091
1301
  getAccountsForQuotaCheck() {
1092
1302
  return this.accounts.map((a) => ({
@@ -1116,7 +1326,7 @@ export class AccountManager {
1116
1326
  if (thresholdPercent >= 100)
1117
1327
  return false;
1118
1328
  const enabled = this.accounts.filter(a => a.enabled !== false);
1119
- if (enabled.length === 0)
1329
+ if (enabled.length <= 1)
1120
1330
  return false;
1121
1331
  return enabled.every(a => isOverSoftQuotaThreshold(a, family, thresholdPercent, cacheTtlMs, model));
1122
1332
  }
@@ -1132,6 +1342,8 @@ export class AccountManager {
1132
1342
  const enabled = this.accounts.filter(a => a.enabled !== false);
1133
1343
  if (enabled.length === 0)
1134
1344
  return null;
1345
+ if (enabled.length === 1)
1346
+ return 0;
1135
1347
  // If any account is available (not over threshold), no wait needed
1136
1348
  const available = enabled.filter(a => !isOverSoftQuotaThreshold(a, family, thresholdPercent, cacheTtlMs, model));
1137
1349
  if (available.length > 0)