@cortexkit/antigravity-auth-core 2.2.1 → 3.0.1

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 (253) hide show
  1. package/README.md +24 -2
  2. package/dist/abort-forwarding.d.ts +3 -0
  3. package/dist/abort-forwarding.d.ts.map +1 -0
  4. package/dist/abort-forwarding.js +19 -0
  5. package/dist/abort-forwarding.js.map +1 -0
  6. package/dist/account-identity.d.ts +52 -0
  7. package/dist/account-identity.d.ts.map +1 -0
  8. package/dist/account-identity.js +66 -0
  9. package/dist/account-identity.js.map +1 -0
  10. package/dist/account-manager.d.ts +228 -43
  11. package/dist/account-manager.d.ts.map +1 -1
  12. package/dist/account-manager.js +942 -903
  13. package/dist/account-manager.js.map +1 -1
  14. package/dist/account-migration.d.ts +309 -0
  15. package/dist/account-migration.d.ts.map +1 -0
  16. package/dist/account-migration.js +2284 -0
  17. package/dist/account-migration.js.map +1 -0
  18. package/dist/account-repository-codecs.d.ts +82 -0
  19. package/dist/account-repository-codecs.d.ts.map +1 -0
  20. package/dist/account-repository-codecs.js +609 -0
  21. package/dist/account-repository-codecs.js.map +1 -0
  22. package/dist/account-repository-types.d.ts +831 -0
  23. package/dist/account-repository-types.d.ts.map +1 -0
  24. package/dist/account-repository-types.js +137 -0
  25. package/dist/account-repository-types.js.map +1 -0
  26. package/dist/account-repository.d.ts +442 -0
  27. package/dist/account-repository.d.ts.map +1 -0
  28. package/dist/account-repository.js +2253 -0
  29. package/dist/account-repository.js.map +1 -0
  30. package/dist/account-selector.d.ts +470 -0
  31. package/dist/account-selector.d.ts.map +1 -0
  32. package/dist/account-selector.js +1383 -0
  33. package/dist/account-selector.js.map +1 -0
  34. package/dist/account-storage.d.ts.map +1 -1
  35. package/dist/account-storage.js +5 -0
  36. package/dist/account-storage.js.map +1 -1
  37. package/dist/agy-request-metadata.d.ts.map +1 -1
  38. package/dist/agy-request-metadata.js +8 -21
  39. package/dist/agy-request-metadata.js.map +1 -1
  40. package/dist/agy-transport.d.ts.map +1 -1
  41. package/dist/agy-transport.js +21 -35
  42. package/dist/agy-transport.js.map +1 -1
  43. package/dist/antigravity-command-menu.d.ts +171 -0
  44. package/dist/antigravity-command-menu.d.ts.map +1 -0
  45. package/dist/antigravity-command-menu.js +523 -0
  46. package/dist/antigravity-command-menu.js.map +1 -0
  47. package/dist/atomic-write.d.ts +18 -1
  48. package/dist/atomic-write.d.ts.map +1 -1
  49. package/dist/atomic-write.js +15 -3
  50. package/dist/atomic-write.js.map +1 -1
  51. package/dist/common-auth-embedded/NOTICE.txt +21 -0
  52. package/dist/common-auth-embedded/auth-menu/accounts.d.ts +97 -0
  53. package/dist/common-auth-embedded/auth-menu/accounts.js +387 -0
  54. package/dist/common-auth-embedded/auth-menu/ansi.d.ts +26 -0
  55. package/dist/common-auth-embedded/auth-menu/ansi.js +98 -0
  56. package/dist/common-auth-embedded/auth-menu/confirm.d.ts +7 -0
  57. package/dist/common-auth-embedded/auth-menu/confirm.js +21 -0
  58. package/dist/common-auth-embedded/auth-menu/doctor.d.ts +52 -0
  59. package/dist/common-auth-embedded/auth-menu/doctor.js +81 -0
  60. package/dist/common-auth-embedded/auth-menu/index.d.ts +17 -0
  61. package/dist/common-auth-embedded/auth-menu/index.js +12 -0
  62. package/dist/common-auth-embedded/auth-menu/login.d.ts +55 -0
  63. package/dist/common-auth-embedded/auth-menu/login.js +83 -0
  64. package/dist/common-auth-embedded/auth-menu/menu.d.ts +59 -0
  65. package/dist/common-auth-embedded/auth-menu/menu.js +73 -0
  66. package/dist/common-auth-embedded/auth-menu/opencode-v1.d.ts +49 -0
  67. package/dist/common-auth-embedded/auth-menu/opencode-v1.js +47 -0
  68. package/dist/common-auth-embedded/auth-menu/select.d.ts +21 -0
  69. package/dist/common-auth-embedded/auth-menu/select.js +159 -0
  70. package/dist/common-auth-embedded/auth-menu/terminal.d.ts +43 -0
  71. package/dist/common-auth-embedded/auth-menu/terminal.js +19 -0
  72. package/dist/common-auth-embedded/claustrum/consumer.d.ts +102 -0
  73. package/dist/common-auth-embedded/claustrum/consumer.js +298 -0
  74. package/dist/common-auth-embedded/claustrum/custody.d.ts +175 -0
  75. package/dist/common-auth-embedded/claustrum/custody.js +354 -0
  76. package/dist/common-auth-embedded/claustrum/enrollment.d.ts +158 -0
  77. package/dist/common-auth-embedded/claustrum/enrollment.js +759 -0
  78. package/dist/common-auth-embedded/claustrum/errors.d.ts +16 -0
  79. package/dist/common-auth-embedded/claustrum/errors.js +8 -0
  80. package/dist/common-auth-embedded/claustrum/host-slot.d.ts +39 -0
  81. package/dist/common-auth-embedded/claustrum/host-slot.js +72 -0
  82. package/dist/common-auth-embedded/claustrum/index.d.ts +18 -0
  83. package/dist/common-auth-embedded/claustrum/index.js +22 -0
  84. package/dist/common-auth-embedded/claustrum/interlock.d.ts +27 -0
  85. package/dist/common-auth-embedded/claustrum/interlock.js +29 -0
  86. package/dist/common-auth-embedded/claustrum/roster.d.ts +208 -0
  87. package/dist/common-auth-embedded/claustrum/roster.js +536 -0
  88. package/dist/common-auth-embedded/commands/builtins.d.ts +71 -0
  89. package/dist/common-auth-embedded/commands/builtins.js +534 -0
  90. package/dist/common-auth-embedded/commands/index.d.ts +10 -0
  91. package/dist/common-auth-embedded/commands/index.js +5 -0
  92. package/dist/common-auth-embedded/commands/menu.d.ts +61 -0
  93. package/dist/common-auth-embedded/commands/menu.js +300 -0
  94. package/dist/common-auth-embedded/commands/model.d.ts +238 -0
  95. package/dist/common-auth-embedded/commands/model.js +16 -0
  96. package/dist/common-auth-embedded/commands/pi.d.ts +23 -0
  97. package/dist/common-auth-embedded/commands/pi.js +206 -0
  98. package/dist/common-auth-embedded/commands/seam.d.ts +72 -0
  99. package/dist/common-auth-embedded/commands/seam.js +301 -0
  100. package/dist/common-auth-embedded/fs/atomic-write.d.ts +20 -0
  101. package/dist/common-auth-embedded/fs/atomic-write.js +80 -0
  102. package/dist/common-auth-embedded/fs/index.d.ts +7 -0
  103. package/dist/common-auth-embedded/fs/index.js +6 -0
  104. package/dist/common-auth-embedded/fs/lock-constants.d.ts +14 -0
  105. package/dist/common-auth-embedded/fs/lock-constants.js +14 -0
  106. package/dist/common-auth-embedded/fs/refresh-file-lock.d.ts +33 -0
  107. package/dist/common-auth-embedded/fs/refresh-file-lock.js +501 -0
  108. package/dist/common-auth-embedded/fs/with-lock.d.ts +33 -0
  109. package/dist/common-auth-embedded/fs/with-lock.js +48 -0
  110. package/dist/common-auth-embedded/logger/capture-sink.d.ts +14 -0
  111. package/dist/common-auth-embedded/logger/capture-sink.js +13 -0
  112. package/dist/common-auth-embedded/logger/engine.d.ts +100 -0
  113. package/dist/common-auth-embedded/logger/engine.js +246 -0
  114. package/dist/common-auth-embedded/logger/index.d.ts +6 -0
  115. package/dist/common-auth-embedded/logger/index.js +3 -0
  116. package/dist/common-auth-embedded/logger/redact.d.ts +11 -0
  117. package/dist/common-auth-embedded/logger/redact.js +83 -0
  118. package/dist/common-auth-embedded/pi-slot/index.d.ts +3 -0
  119. package/dist/common-auth-embedded/pi-slot/index.js +2 -0
  120. package/dist/common-auth-embedded/pi-slot/lock.js +212 -0
  121. package/dist/common-auth-embedded/pi-slot/slot.d.ts +49 -0
  122. package/dist/common-auth-embedded/pi-slot/slot.js +383 -0
  123. package/dist/common-auth-embedded/quota/codec.d.ts +11 -0
  124. package/dist/common-auth-embedded/quota/codec.js +12 -0
  125. package/dist/common-auth-embedded/quota/format.d.ts +46 -0
  126. package/dist/common-auth-embedded/quota/format.js +138 -0
  127. package/dist/common-auth-embedded/quota/index.d.ts +10 -0
  128. package/dist/common-auth-embedded/quota/index.js +5 -0
  129. package/dist/common-auth-embedded/quota/map.d.ts +63 -0
  130. package/dist/common-auth-embedded/quota/map.js +105 -0
  131. package/dist/common-auth-embedded/quota/merge.d.ts +48 -0
  132. package/dist/common-auth-embedded/quota/merge.js +162 -0
  133. package/dist/common-auth-embedded/quota/projection.d.ts +64 -0
  134. package/dist/common-auth-embedded/quota/projection.js +134 -0
  135. package/dist/common-auth-embedded/routing/admission.d.ts +93 -0
  136. package/dist/common-auth-embedded/routing/admission.js +142 -0
  137. package/dist/common-auth-embedded/routing/index.d.ts +8 -0
  138. package/dist/common-auth-embedded/routing/index.js +4 -0
  139. package/dist/common-auth-embedded/routing/ordered.d.ts +47 -0
  140. package/dist/common-auth-embedded/routing/ordered.js +58 -0
  141. package/dist/common-auth-embedded/routing/pins.d.ts +22 -0
  142. package/dist/common-auth-embedded/routing/pins.js +34 -0
  143. package/dist/common-auth-embedded/routing/sticky.d.ts +158 -0
  144. package/dist/common-auth-embedded/routing/sticky.js +339 -0
  145. package/dist/common-auth-embedded/source-output.json +1090 -0
  146. package/dist/common-auth-embedded/store/attribution.d.ts +28 -0
  147. package/dist/common-auth-embedded/store/attribution.js +60 -0
  148. package/dist/common-auth-embedded/store/errors.d.ts +55 -0
  149. package/dist/common-auth-embedded/store/errors.js +38 -0
  150. package/dist/common-auth-embedded/store/hooks.d.ts +12 -0
  151. package/dist/common-auth-embedded/store/hooks.js +34 -0
  152. package/dist/common-auth-embedded/store/identity.d.ts +39 -0
  153. package/dist/common-auth-embedded/store/identity.js +79 -0
  154. package/dist/common-auth-embedded/store/index.d.ts +21 -0
  155. package/dist/common-auth-embedded/store/index.js +7 -0
  156. package/dist/common-auth-embedded/store/mutate.d.ts +189 -0
  157. package/dist/common-auth-embedded/store/mutate.js +500 -0
  158. package/dist/common-auth-embedded/store/pool.d.ts +196 -0
  159. package/dist/common-auth-embedded/store/pool.js +120 -0
  160. package/dist/common-auth-embedded/store/provider-state.d.ts +131 -0
  161. package/dist/common-auth-embedded/store/provider-state.js +226 -0
  162. package/dist/common-auth-embedded/store/publication.d.ts +47 -0
  163. package/dist/common-auth-embedded/store/publication.js +304 -0
  164. package/dist/common-auth-embedded/store/pull.d.ts +36 -0
  165. package/dist/common-auth-embedded/store/pull.js +143 -0
  166. package/dist/common-auth-embedded/store/refresh-lock.d.ts +63 -0
  167. package/dist/common-auth-embedded/store/refresh-lock.js +136 -0
  168. package/dist/common-auth-embedded/store/refresh.d.ts +72 -0
  169. package/dist/common-auth-embedded/store/refresh.js +225 -0
  170. package/dist/common-auth-embedded/store/reserved.js +13 -0
  171. package/dist/common-auth-embedded/store/rows.d.ts +313 -0
  172. package/dist/common-auth-embedded/store/rows.js +1258 -0
  173. package/dist/common-auth-embedded/store/runtime.d.ts +36 -0
  174. package/dist/common-auth-embedded/store/runtime.js +68 -0
  175. package/dist/common-auth-embedded/store/schema.d.ts +443 -0
  176. package/dist/common-auth-embedded/store/schema.js +754 -0
  177. package/dist/common-auth-embedded/store/settings.d.ts +63 -0
  178. package/dist/common-auth-embedded/store/settings.js +120 -0
  179. package/dist/common-auth-embedded/store/torn.d.ts +78 -0
  180. package/dist/common-auth-embedded/store/torn.js +294 -0
  181. package/dist/common-auth-embedded/util/any-signal.js +42 -0
  182. package/dist/common-auth-runtime.d.ts +73 -0
  183. package/dist/common-auth-runtime.d.ts.map +1 -0
  184. package/dist/common-auth-runtime.js +65 -0
  185. package/dist/common-auth-runtime.js.map +1 -0
  186. package/dist/credential-redaction.d.ts +13 -0
  187. package/dist/credential-redaction.d.ts.map +1 -0
  188. package/dist/credential-redaction.js +230 -0
  189. package/dist/credential-redaction.js.map +1 -0
  190. package/dist/fetch-timeout.d.ts +4 -2
  191. package/dist/fetch-timeout.d.ts.map +1 -1
  192. package/dist/fetch-timeout.js +121 -14
  193. package/dist/fetch-timeout.js.map +1 -1
  194. package/dist/fingerprint.d.ts +3 -3
  195. package/dist/fingerprint.d.ts.map +1 -1
  196. package/dist/fingerprint.js +4 -4
  197. package/dist/fingerprint.js.map +1 -1
  198. package/dist/index.d.ts +16 -1
  199. package/dist/index.d.ts.map +1 -1
  200. package/dist/index.js +15 -0
  201. package/dist/index.js.map +1 -1
  202. package/dist/model-registry.d.ts +16 -0
  203. package/dist/model-registry.d.ts.map +1 -1
  204. package/dist/model-registry.js +242 -30
  205. package/dist/model-registry.js.map +1 -1
  206. package/dist/quota-manager.d.ts.map +1 -1
  207. package/dist/quota-manager.js +35 -10
  208. package/dist/quota-manager.js.map +1 -1
  209. package/dist/quota-presentation.d.ts +10 -0
  210. package/dist/quota-presentation.d.ts.map +1 -0
  211. package/dist/quota-presentation.js +60 -0
  212. package/dist/quota-presentation.js.map +1 -0
  213. package/dist/quota-types.d.ts +2 -0
  214. package/dist/quota-types.d.ts.map +1 -1
  215. package/dist/rotation.d.ts +8 -0
  216. package/dist/rotation.d.ts.map +1 -1
  217. package/dist/rotation.js +24 -0
  218. package/dist/rotation.js.map +1 -1
  219. package/dist/transform/agy-generation.d.ts +3 -0
  220. package/dist/transform/agy-generation.d.ts.map +1 -0
  221. package/dist/transform/agy-generation.js +28 -0
  222. package/dist/transform/agy-generation.js.map +1 -0
  223. package/dist/transform/claude.d.ts +7 -3
  224. package/dist/transform/claude.d.ts.map +1 -1
  225. package/dist/transform/claude.js +8 -2
  226. package/dist/transform/claude.js.map +1 -1
  227. package/dist/transform/index.d.ts +1 -0
  228. package/dist/transform/index.d.ts.map +1 -1
  229. package/dist/transform/index.js +1 -0
  230. package/dist/transform/index.js.map +1 -1
  231. package/dist/transform/model-resolver.d.ts +2 -1
  232. package/dist/transform/model-resolver.d.ts.map +1 -1
  233. package/dist/transform/model-resolver.js +46 -5
  234. package/dist/transform/model-resolver.js.map +1 -1
  235. package/dist/transform/types.d.ts +1 -1
  236. package/dist/transform/types.d.ts.map +1 -1
  237. package/dist/vault-account-source.d.ts +183 -0
  238. package/dist/vault-account-source.d.ts.map +1 -0
  239. package/dist/vault-account-source.js +296 -0
  240. package/dist/vault-account-source.js.map +1 -0
  241. package/dist/vault-enrollment-authority.d.ts +16 -0
  242. package/dist/vault-enrollment-authority.d.ts.map +1 -0
  243. package/dist/vault-enrollment-authority.js +19 -0
  244. package/dist/vault-enrollment-authority.js.map +1 -0
  245. package/dist/vault-family.d.ts +11 -0
  246. package/dist/vault-family.d.ts.map +1 -0
  247. package/dist/vault-family.js +24 -0
  248. package/dist/vault-family.js.map +1 -0
  249. package/dist/vault-provider-state.d.ts +162 -0
  250. package/dist/vault-provider-state.d.ts.map +1 -0
  251. package/dist/vault-provider-state.js +422 -0
  252. package/dist/vault-provider-state.js.map +1 -0
  253. package/package.json +39 -2
@@ -1,10 +1,12 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { rowRefKey, sameRowRef } from "./account-identity.js";
3
+ import { AccountRepositoryError, applyCooldownObservation, applyRateLimitObservation, applySwitch, } from "./account-repository.js";
4
+ import { AccountSelector, clampNonNegativeInt, } from "./account-selector.js";
2
5
  import { AccountStorageLockContentionError } from "./account-storage.js";
3
6
  import { formatRefreshParts, parseRefreshParts } from "./auth.js";
4
- import { generateFingerprint, MAX_FINGERPRINT_HISTORY, updateFingerprintVersion, } from "./fingerprint.js";
5
- import { getQuotaGroupForModel } from "./model-registry.js";
7
+ import { generateFingerprint, updateFingerprintVersion, } from "./fingerprint.js";
6
8
  import { normalizeLegacyCachedQuota, } from "./quota-types.js";
7
- import { getHealthTracker, getTokenTracker, selectHybridAccount, } from "./rotation.js";
9
+ import { getHealthTracker, getTokenTracker, HealthScoreTracker, TokenBucketTracker, } from "./rotation.js";
8
10
  function isStorageLockContention(error) {
9
11
  if (error instanceof AccountStorageLockContentionError)
10
12
  return true;
@@ -13,13 +15,7 @@ function isStorageLockContention(error) {
13
15
  message.includes('ELOCKED'));
14
16
  }
15
17
  export { calculateBackoffMs, computeSoftQuotaCacheTtlMs, parseRateLimitReason, } from "./rotation.js";
16
- import { calculateBackoffMs } from "./rotation.js";
17
- function clampNonNegativeInt(value, fallback) {
18
- if (typeof value !== 'number' || !Number.isFinite(value)) {
19
- return fallback;
20
- }
21
- return value < 0 ? 0 : Math.floor(value);
22
- }
18
+ export { resolveQuotaGroup } from "./account-selector.js";
23
19
  /**
24
20
  * Opaque identity for a refresh token.
25
21
  *
@@ -30,107 +26,150 @@ function clampNonNegativeInt(value, fallback) {
30
26
  function quotaAccountIdentity(refreshToken) {
31
27
  return createHash('sha256').update(refreshToken).digest('hex').slice(0, 16);
32
28
  }
33
- function getQuotaKey(family, headerStyle, model) {
34
- if (family === 'claude') {
35
- return 'claude';
36
- }
37
- const base = headerStyle === 'gemini-cli' ? 'gemini-cli' : 'gemini-antigravity';
38
- if (model) {
39
- return `${base}:${model}`;
29
+ /** Applies a coalesced row write to the metadata read under the row lock. */
30
+ function applyPendingRowWrite(metadata, pending) {
31
+ let next = metadata;
32
+ const resets = {};
33
+ const cleared = [];
34
+ for (const [key, value] of pending.rateLimits) {
35
+ if (value === 'clear')
36
+ cleared.push(key);
37
+ else
38
+ resets[key] = value;
39
+ }
40
+ if (Object.keys(resets).length > 0) {
41
+ next = applyRateLimitObservation(next, resets);
42
+ }
43
+ if (cleared.length > 0 && next.rateLimitResetTimes != null) {
44
+ const rates = { ...next.rateLimitResetTimes };
45
+ for (const key of cleared)
46
+ delete rates[key];
47
+ next = { ...next, rateLimitResetTimes: rates };
48
+ }
49
+ if (pending.cooldown !== undefined) {
50
+ next = applyCooldownObservation(next, pending.cooldown.value);
51
+ }
52
+ if (pending.switchReason !== undefined) {
53
+ next = applySwitch(next, pending.switchReason);
54
+ }
55
+ if (pending.lastUsed !== undefined && pending.lastUsed > next.lastUsed) {
56
+ next = { ...next, lastUsed: pending.lastUsed };
57
+ }
58
+ return next;
59
+ }
60
+ /**
61
+ * Repository failures a save reports but does not fail on: the write was
62
+ * about a credential the row no longer holds (it is dropped, as intended),
63
+ * or it met lock contention and was queued again for the next save.
64
+ */
65
+ const TOLERATED_FAILURES = new Set([
66
+ 'attribution',
67
+ 'unknown-row',
68
+ 'lock-contention',
69
+ ]);
70
+ /**
71
+ * Thrown by a save, flush or dispose of a repository-backed manager when a
72
+ * write failed. `message` and `failure` are the first failure; `report`
73
+ * holds every failure the repository reported while draining.
74
+ */
75
+ export class AccountManagerPersistError extends Error {
76
+ failure;
77
+ report;
78
+ constructor(failure, report) {
79
+ super(failure.message);
80
+ this.name = 'AccountManagerPersistError';
81
+ this.failure = failure;
82
+ this.report = report;
40
83
  }
41
- return base;
42
84
  }
43
- function isRateLimitedForQuotaKey(account, key, now) {
44
- const resetTime = account.rateLimitResetTimes[key];
45
- return resetTime !== undefined && now() < resetTime;
85
+ function present(value) {
86
+ return value === null ? undefined : value;
46
87
  }
47
- function isRateLimitedForFamily(account, family, now, model) {
48
- if (family === 'claude') {
49
- return isRateLimitedForQuotaKey(account, 'claude', now);
88
+ function quotaGroupsOf(quota) {
89
+ if (quota?.cachedQuota == null)
90
+ return undefined;
91
+ const groups = {};
92
+ for (const [name, group] of Object.entries(quota.cachedQuota)) {
93
+ groups[name] = {
94
+ modelCount: group.modelCount,
95
+ ...(group.remainingFraction != null
96
+ ? { remainingFraction: group.remainingFraction }
97
+ : {}),
98
+ ...(group.resetTime != null ? { resetTime: group.resetTime } : {}),
99
+ ...(group.windows != null
100
+ ? {
101
+ windows: group.windows.map((window) => ({
102
+ window: window.window,
103
+ remainingFraction: window.remainingFraction,
104
+ resetTime: window.resetTime,
105
+ })),
106
+ }
107
+ : {}),
108
+ };
50
109
  }
51
- const antigravityIsLimited = isRateLimitedForHeaderStyle(account, family, 'antigravity', now, model);
52
- const cliIsLimited = isRateLimitedForHeaderStyle(account, family, 'gemini-cli', now, model);
53
- return antigravityIsLimited && cliIsLimited;
110
+ return groups;
54
111
  }
55
- function isRateLimitedForHeaderStyle(account, family, headerStyle, now, model) {
56
- clearExpiredRateLimits(account, now);
57
- if (family === 'claude') {
58
- return isRateLimitedForQuotaKey(account, 'claude', now);
59
- }
60
- // Check model-specific quota first if provided
61
- if (model) {
62
- const modelKey = getQuotaKey(family, headerStyle, model);
63
- if (isRateLimitedForQuotaKey(account, modelKey, now)) {
64
- return true;
65
- }
112
+ function storedQuotaGroups(groups) {
113
+ const stored = {};
114
+ for (const [name, group] of Object.entries(groups)) {
115
+ if (group === undefined)
116
+ continue;
117
+ stored[name] = {
118
+ modelCount: group.modelCount,
119
+ ...(group.remainingFraction !== undefined
120
+ ? { remainingFraction: group.remainingFraction }
121
+ : {}),
122
+ ...(group.resetTime !== undefined ? { resetTime: group.resetTime } : {}),
123
+ ...(group.windows !== undefined
124
+ ? { windows: group.windows.map((window) => ({ ...window })) }
125
+ : {}),
126
+ };
66
127
  }
67
- // Then check base family quota
68
- const baseKey = getQuotaKey(family, headerStyle);
69
- return isRateLimitedForQuotaKey(account, baseKey, now);
128
+ return stored;
70
129
  }
71
- function clearExpiredRateLimits(account, clock) {
72
- const now = clock();
73
- const keys = Object.keys(account.rateLimitResetTimes);
74
- for (const key of keys) {
75
- const resetTime = account.rateLimitResetTimes[key];
76
- if (resetTime !== undefined && now >= resetTime) {
77
- delete account.rateLimitResetTimes[key];
78
- }
79
- }
130
+ /** Why a repository row cannot be routed to, or undefined when it can. */
131
+ function unroutableReason(row) {
132
+ if (row.invalid !== undefined)
133
+ return `invalid ${row.invalid}`;
134
+ if (row.torn)
135
+ return 'torn';
136
+ if (row.unbound)
137
+ return `unbound credential (${row.stamp})`;
138
+ if (row.credential === undefined)
139
+ return 'no credential';
140
+ if (row.metadata.status === 'dropped') {
141
+ return `metadata not shown (${row.metadata.reason})`;
142
+ }
143
+ if (row.metadata.status === 'absent')
144
+ return 'metadata absent';
145
+ return undefined;
146
+ }
147
+ /** When the account's newest access evidence was observed, if ever. */
148
+ function accessEvidenceTime(account) {
149
+ const times = [
150
+ account.verificationRequiredAt,
151
+ account.accountIneligibleAt,
152
+ account.eligibilityStateUpdatedAt,
153
+ ].filter((time) => time !== undefined);
154
+ return times.length === 0 ? undefined : Math.max(...times);
80
155
  }
81
156
  /**
82
- * Resolve the quota group for soft quota checks.
83
- *
84
- * When a model string is available we use the model-registry lookup first,
85
- * then fall back to substring matching. When model is null/undefined we
86
- * fall back based on family:
87
- * - Claude → "non-gemini" quota group
88
- * - Gemini → "gemini" quota group
89
- *
90
- * @param family - The model family ("claude" | "gemini")
91
- * @param model - Optional model string for precise resolution
92
- * @returns The QuotaGroup to use for soft quota checks
157
+ * The request counts of the later day; on the same day, the larger count of
158
+ * each family, since both only ever grow within a day.
93
159
  */
94
- export function resolveQuotaGroup(family, model) {
95
- if (model) {
96
- const registryGroup = getQuotaGroupForModel(model);
97
- if (registryGroup)
98
- return registryGroup;
99
- const lower = model.toLowerCase();
100
- // Check Claude / GPT-OSS substrings BEFORE the `gemini` substring so
101
- // a `gemini-claude-*` alias (Claude route exposed under a `gemini-`
102
- // namespace) attributes to the non-gemini pool. The model-registry
103
- // check above already handles registered aliases; this substring
104
- // fallback mirrors the same precedence rule for unregistered models.
105
- if (lower.includes('claude') || lower.includes('gpt-oss')) {
106
- return 'non-gemini';
107
- }
108
- if (lower.includes('gemini'))
109
- return 'gemini';
110
- }
111
- return family === 'claude' ? 'non-gemini' : 'gemini';
112
- }
113
- function isOverSoftQuotaThreshold(account, family, thresholdPercent, cacheTtlMs, now, model) {
114
- if (thresholdPercent >= 100)
115
- return false;
116
- if (!account.cachedQuota)
117
- return false;
118
- if (account.cachedQuotaUpdatedAt == null)
119
- return false;
120
- const age = now() - account.cachedQuotaUpdatedAt;
121
- if (age > cacheTtlMs)
122
- return false;
123
- const quotaGroup = resolveQuotaGroup(family, model);
124
- const groupData = account.cachedQuota[quotaGroup];
125
- if (groupData?.remainingFraction == null)
126
- return false;
127
- const remainingFraction = Math.max(0, Math.min(1, groupData.remainingFraction));
128
- const usedPercent = (1 - remainingFraction) * 100;
129
- const isOverThreshold = usedPercent >= thresholdPercent;
130
- return isOverThreshold;
160
+ function laterCounts(a, b) {
161
+ if (a === undefined)
162
+ return b;
163
+ if (b === undefined)
164
+ return a;
165
+ if (a.date !== b.date)
166
+ return a.date > b.date ? a : b;
167
+ return {
168
+ date: a.date,
169
+ claude: Math.max(a.claude, b.claude),
170
+ gemini: Math.max(a.gemini, b.gemini),
171
+ };
131
172
  }
132
- const ACCOUNT_SESSION_STATE_TTL_MS = 24 * 60 * 60 * 1000;
133
- const MAX_ACCOUNT_SESSION_STATES = 256;
134
173
  /**
135
174
  * In-memory multi-account manager with sticky account selection.
136
175
  *
@@ -138,55 +177,83 @@ const MAX_ACCOUNT_SESSION_STATES = 256;
138
177
  * Rate limits are tracked per-model-family (claude/gemini) so an account
139
178
  * rate-limited for Claude can still be used for Gemini.
140
179
  *
141
- * Source of truth for the pool is `antigravity-accounts.json`.
180
+ * Persists either to the pre-store pool file `antigravity-accounts.json`
181
+ * (whole-pool snapshots through an `AccountStorageStore`) or, when built with
182
+ * `fromRepository`, to the account repository, where every change is an
183
+ * attributed write on the row and credential the account was loaded from.
142
184
  */
143
185
  export class AccountManager {
144
- accounts = [];
145
- cursorByFamily = { claude: 0, gemini: 0 };
146
- currentAccountIndexByFamily = {
147
- claude: -1,
148
- gemini: -1,
149
- };
150
- sessionOffsetApplied = {
151
- claude: false,
152
- gemini: false,
153
- };
154
- lastToastAccountIndex = -1;
155
- lastToastTime = 0;
186
+ /**
187
+ * Owns all of this manager's selection state: its accounts, the
188
+ * selection, cursors, session pins, trackers, toast and usage counts.
189
+ * The manager itself handles credentials and persistence.
190
+ */
191
+ selection;
192
+ /**
193
+ * The selector's accounts in pool order: a view of its array, not a copy
194
+ * and not a second list.
195
+ */
196
+ get accounts() {
197
+ return this.selection.rows;
198
+ }
156
199
  savePending = false;
157
200
  saveTimeout = null;
158
201
  saveInFlight = null;
159
202
  disposed = false;
160
203
  savePromiseResolvers = [];
161
- sessionStartTime;
162
- sessionRequestCounts = new Map();
163
- sessionUsedAccounts = new Set();
164
- requestSessionStates = new Map();
165
204
  store;
205
+ repository;
206
+ /** Coalesced absolute-state writes per repository credential. */
207
+ pendingRowWrites = new Map();
208
+ /** Latest selection per routing target, written at the next save. */
209
+ pendingSelections = new Map();
166
210
  storagePath;
211
+ /** A promise chain that applies reloads in request order. */
212
+ reloading = Promise.resolve();
167
213
  onDiagnostic;
168
214
  now;
169
- random;
170
- pid;
171
215
  constructor(authFallback, stored, options) {
216
+ if ((options.store === undefined) === (options.repository === undefined)) {
217
+ throw new Error('AccountManager needs exactly one of a pool-file store and an account repository');
218
+ }
219
+ if (options.repository !== undefined && (stored || authFallback)) {
220
+ throw new Error('a repository-backed AccountManager is loaded with AccountManager.fromRepository');
221
+ }
172
222
  this.store = options.store;
223
+ this.repository = options.repository;
173
224
  this.storagePath = options.storagePath ?? '';
174
225
  this.onDiagnostic = options.onDiagnostic;
175
226
  this.now = options.now ?? (() => Date.now());
176
- this.random = options.random ?? (() => Math.random());
177
- this.pid = options.pid ?? process.pid;
178
- this.sessionStartTime = this.now();
227
+ const repositoryBacked = options.repository !== undefined;
228
+ this.selection = new AccountSelector({
229
+ sink: repositoryBacked
230
+ ? this.repositorySink()
231
+ : {
232
+ requestSave: () => this.requestSaveToDisk(),
233
+ usage: () => this.requestSaveToDisk(),
234
+ stale: (account, label) => this.ignoreStale(account, label),
235
+ },
236
+ // A repository-backed manager gets its own new trackers. A pool-file
237
+ // manager without supplied trackers shares the process-wide ones
238
+ // (`getHealthTracker`, `getTokenTracker`), as it always has.
239
+ healthTracker: options.healthTracker ??
240
+ (repositoryBacked ? new HealthScoreTracker() : getHealthTracker),
241
+ tokenTracker: options.tokenTracker ??
242
+ (repositoryBacked ? new TokenBucketTracker() : getTokenTracker),
243
+ now: this.now,
244
+ random: options.random ?? (() => Math.random()),
245
+ pid: options.pid ?? process.pid,
246
+ onDiagnostic: options.onDiagnostic,
247
+ });
179
248
  const authParts = authFallback
180
249
  ? parseRefreshParts(authFallback.refresh)
181
250
  : null;
182
251
  if (stored && stored.accounts.length === 0) {
183
- this.accounts = [];
184
- this.cursorByFamily = { claude: 0, gemini: 0 };
185
252
  return;
186
253
  }
187
254
  if (stored && stored.accounts.length > 0) {
188
255
  const baseNow = this.now();
189
- this.accounts = stored.accounts
256
+ const accounts = stored.accounts
190
257
  .map((acc, index) => {
191
258
  if (!acc.refreshToken || typeof acc.refreshToken !== 'string') {
192
259
  return null;
@@ -245,20 +312,21 @@ export class AccountManager {
245
312
  // Saved fingerprints may carry an older version string; this ensures
246
313
  // they always reflect the latest fetched (or fallback) version.
247
314
  let fingerprintVersionChanged = false;
248
- for (const acc of this.accounts) {
315
+ for (const acc of accounts) {
249
316
  if (acc.fingerprint && updateFingerprintVersion(acc.fingerprint)) {
250
317
  fingerprintVersionChanged = true;
251
318
  }
252
319
  }
320
+ // The stored selection is resolved against the stored accounts, before
321
+ // an auth-fallback account is added.
253
322
  const legacyCursor = clampNonNegativeInt(stored.activeIndex, 0);
254
- if (this.accounts.length > 0) {
255
- const defaultIndex = legacyCursor % this.accounts.length;
256
- this.currentAccountIndexByFamily.claude =
257
- clampNonNegativeInt(stored.activeIndexByFamily?.claude, defaultIndex) % this.accounts.length;
258
- this.currentAccountIndexByFamily.gemini =
259
- clampNonNegativeInt(stored.activeIndexByFamily?.gemini, defaultIndex) % this.accounts.length;
260
- this.cursorByFamily.claude = this.currentAccountIndexByFamily.claude;
261
- this.cursorByFamily.gemini = this.currentAccountIndexByFamily.gemini;
323
+ const selection = {};
324
+ if (accounts.length > 0) {
325
+ const defaultIndex = legacyCursor % accounts.length;
326
+ for (const family of ['claude', 'gemini']) {
327
+ selection[family] =
328
+ clampNonNegativeInt(stored.activeIndexByFamily?.[family], defaultIndex) % accounts.length;
329
+ }
262
330
  }
263
331
  // Persist updated fingerprint versions to disk
264
332
  if (fingerprintVersionChanged) {
@@ -266,11 +334,11 @@ export class AccountManager {
266
334
  }
267
335
  // If current auth isn't in the loaded accounts, add it to the pool
268
336
  if (authFallback && authParts?.refreshToken) {
269
- const hasMatching = this.accounts.some((acc) => acc.parts.refreshToken === authParts.refreshToken);
337
+ const hasMatching = accounts.some((acc) => acc.parts.refreshToken === authParts.refreshToken);
270
338
  if (!hasMatching) {
271
339
  const now = this.now();
272
340
  const newAccount = {
273
- index: this.accounts.length,
341
+ index: accounts.length,
274
342
  email: undefined,
275
343
  addedAt: now,
276
344
  lastUsed: 0,
@@ -283,16 +351,17 @@ export class AccountManager {
283
351
  fingerprint: generateFingerprint(),
284
352
  fingerprintHistory: [],
285
353
  };
286
- this.accounts.push(newAccount);
354
+ accounts.push(newAccount);
287
355
  }
288
356
  }
357
+ this.selection.resetAccounts(accounts, selection);
289
358
  return;
290
359
  }
291
360
  if (authFallback) {
292
361
  const parts = parseRefreshParts(authFallback.refresh);
293
362
  if (parts.refreshToken) {
294
363
  const now = this.now();
295
- this.accounts = [
364
+ this.selection.resetAccounts([
296
365
  {
297
366
  index: 0,
298
367
  email: undefined,
@@ -305,142 +374,573 @@ export class AccountManager {
305
374
  rateLimitResetTimes: {},
306
375
  touchedForQuota: {},
307
376
  },
308
- ];
309
- this.cursorByFamily = { claude: 0, gemini: 0 };
310
- this.currentAccountIndexByFamily.claude = 0;
311
- this.currentAccountIndexByFamily.gemini = 0;
377
+ ], { claude: 0, gemini: 0 });
312
378
  }
313
379
  }
314
380
  }
315
- getAccountCount() {
316
- return this.getEnabledAccounts().length;
317
- }
318
- getTotalAccountCount() {
319
- return this.accounts.length;
320
- }
321
- getEnabledAccounts() {
322
- return this.accounts.filter((account) => account.enabled !== false);
323
- }
324
- getEffectiveSoftQuotaThreshold(thresholdPercent) {
325
- // Soft-quota protection only has a purpose when another enabled account
326
- // exists to rotate to. Never block the sole usable account.
327
- return this.getEnabledAccounts().length > 1 ? thresholdPercent : 100;
381
+ /**
382
+ * Where a repository-backed manager's selector reports transitions. Each
383
+ * becomes a write attributed to the exact account it was decided for.
384
+ * Rate limits, cooldowns, switches, `lastUsed` and selections are queued
385
+ * and coalesced until the next save; request counts, enabled flags,
386
+ * access verdicts and fingerprints are written immediately.
387
+ */
388
+ repositorySink() {
389
+ return {
390
+ rateLimit: (account, key, resetAt) => this.queueRowWrite(account, (pending) => {
391
+ pending.rateLimits.set(key, resetAt);
392
+ }),
393
+ cooldown: (account, value) => this.queueRowWrite(account, (pending) => {
394
+ pending.cooldown = { value };
395
+ }),
396
+ switched: (account, reason) => this.queueRowWrite(account, (pending) => {
397
+ pending.switchReason = reason;
398
+ }),
399
+ lastUsed: (account, at) => this.queueRowWrite(account, (pending) => {
400
+ pending.lastUsed = Math.max(pending.lastUsed ?? 0, at);
401
+ }),
402
+ selection: (family, account) => this.queueSelection(family, account),
403
+ usage: (account, family, at) => this.dispatch(account, 'request count', (repository, ref) => repository.recordUsage(ref, { family, at })),
404
+ enabled: (account, enabled) => this.dispatch(account, 'enabled flag', (repository, ref) => repository.setEnabled(ref, { enabled, actor: 'user' })),
405
+ accessVerdict: (account, verdict) => this.persistAccessVerdict(account, verdict),
406
+ fingerprint: (account) => this.persistFingerprint(account),
407
+ requestSave: () => this.requestSaveToDisk(),
408
+ stale: (account, label) => this.ignoreStale(account, label),
409
+ };
328
410
  }
329
- getAccountsSnapshot() {
330
- return this.accounts.map((a) => ({
331
- ...a,
332
- parts: { ...a.parts },
333
- rateLimitResetTimes: { ...a.rateLimitResetTimes },
334
- }));
411
+ /**
412
+ * A manager over a ready repository read. Rows with missing or unsafe
413
+ * credentials, or absent/dropped provider metadata, are excluded from
414
+ * serving and reported through `onDiagnostic`. Stored row references
415
+ * choose the active account; legacy numeric positions in the account list
416
+ * are used only when no stored reference selects an admitted row.
417
+ */
418
+ static fromRepository(read, options) {
419
+ if (read.status !== 'ready') {
420
+ throw new Error(`the account repository is not ready (${read.status}); resolve it before routing`);
421
+ }
422
+ const manager = new AccountManager(undefined, null, options);
423
+ manager.loadRepositoryRows(read);
424
+ return manager;
335
425
  }
336
- getRequestSessionState(identity) {
337
- const now = this.now();
338
- this.pruneRequestSessionStates(now, identity.id);
339
- const existing = this.requestSessionStates.get(identity.id);
340
- if (existing) {
341
- existing.lastAccessedAt = now;
342
- if (identity.parentId) {
343
- existing.parentId = identity.parentId;
344
- }
345
- return existing;
346
- }
347
- const state = {
348
- parentId: identity.parentId ?? null,
349
- currentAccountIndexByFamily: { claude: -1, gemini: -1 },
350
- cursorByFamily: { ...this.cursorByFamily },
351
- offsetAppliedByFamily: { claude: false, gemini: false },
352
- usedAccounts: new Set(),
353
- lastAccessedAt: now,
426
+ /**
427
+ * The account a ready repository row describes, or undefined (reported
428
+ * through `onDiagnostic`) for a row that cannot be routed to. `fingerprint`
429
+ * says whether the stored fingerprint was kept, generated because the row
430
+ * had none, or brought to the current runtime version.
431
+ */
432
+ accountFromRow(row, index, baseNow) {
433
+ const reason = unroutableReason(row);
434
+ const credential = row.credential;
435
+ if (reason !== undefined || credential === undefined) {
436
+ this.onDiagnostic?.('Skipped an account row that cannot be routed to', {
437
+ rowId: row.ref.id,
438
+ reason: reason ?? 'no credential',
439
+ });
440
+ return undefined;
441
+ }
442
+ const meta = row.metadata.status === 'present' ? row.metadata.metadata : undefined;
443
+ const quota = row.quota.status === 'present' ? row.quota.quota : undefined;
444
+ const rateLimitResetTimes = {};
445
+ for (const [key, value] of Object.entries(meta?.rateLimitResetTimes ?? {})) {
446
+ if (typeof value === 'number')
447
+ rateLimitResetTimes[key] = value;
448
+ }
449
+ const projectId = present(meta?.projectId);
450
+ const managedProjectId = present(meta?.managedProjectId);
451
+ const counts = present(meta?.dailyRequestCounts);
452
+ const account = {
453
+ index,
454
+ ref: row.ref,
455
+ email: present(meta?.email),
456
+ label: present(meta?.label),
457
+ addedAt: meta?.addedAt ?? row.storeAddedAt ?? baseNow,
458
+ lastUsed: meta?.lastUsed ?? 0,
459
+ parts: {
460
+ refreshToken: credential.refreshToken,
461
+ projectId,
462
+ managedProjectId,
463
+ },
464
+ projectId,
465
+ managedProjectId,
466
+ access: credential.accessToken,
467
+ expires: credential.expiresAt,
468
+ enabled: row.enabled,
469
+ rateLimitResetTimes,
470
+ lastSwitchReason: present(meta?.lastSwitchReason),
471
+ coolingDownUntil: present(meta?.coolingDownUntil),
472
+ cooldownReason: present(meta?.cooldownReason),
473
+ touchedForQuota: {},
474
+ // Copy the fingerprint before storing it: the manager updates it in
475
+ // place, and the caller owns the read.
476
+ fingerprint: structuredClone(present(meta?.fingerprint)),
477
+ fingerprintHistory: structuredClone(present(meta?.fingerprintHistory)) ?? [],
478
+ cachedQuota: normalizeLegacyCachedQuota(quotaGroupsOf(quota)),
479
+ cachedQuotaAccountId: present(quota?.cachedQuotaAccountId),
480
+ cachedQuotaUpdatedAt: present(quota?.cachedQuotaUpdatedAt),
481
+ capturedTierId: present(meta?.capturedTierId),
482
+ capturedPaidTierId: present(meta?.capturedPaidTierId),
483
+ capturedTierAt: present(meta?.capturedTierAt),
484
+ capturedTierSchemaVersion: present(meta?.capturedTierSchemaVersion),
485
+ dailyRequestCounts: counts === undefined
486
+ ? undefined
487
+ : {
488
+ date: counts.date,
489
+ claude: counts.claude,
490
+ gemini: counts.gemini,
491
+ },
492
+ verificationRequired: present(meta?.verificationRequired),
493
+ verificationRequiredAt: present(meta?.verificationRequiredAt),
494
+ verificationRequiredReason: present(meta?.verificationRequiredReason),
495
+ verificationUrl: present(meta?.verificationUrl),
496
+ accountIneligible: present(meta?.accountIneligible),
497
+ accountIneligibleAt: present(meta?.accountIneligibleAt),
498
+ accountIneligibleReason: present(meta?.accountIneligibleReason),
499
+ eligibilityStateUpdatedAt: present(meta?.eligibilityStateUpdatedAt),
500
+ };
501
+ if (account.fingerprint === undefined) {
502
+ account.fingerprint = generateFingerprint();
503
+ return { account, fingerprint: 'generated' };
504
+ }
505
+ if (updateFingerprintVersion(account.fingerprint)) {
506
+ return { account, fingerprint: 'updated' };
507
+ }
508
+ return { account, fingerprint: 'kept' };
509
+ }
510
+ /** The stored selection's index for `family` among `accounts`. */
511
+ routedIndex(accounts, routing, family) {
512
+ const count = accounts.length;
513
+ const indexOf = (ref) => {
514
+ if (ref == null)
515
+ return undefined;
516
+ const found = accounts.findIndex((account) => account.ref !== undefined && sameRowRef(account.ref, ref));
517
+ return found < 0 ? undefined : found;
354
518
  };
355
- this.requestSessionStates.set(identity.id, state);
356
- return state;
357
- }
358
- pruneRequestSessionStates(now, preservedId) {
359
- const expiry = now - ACCOUNT_SESSION_STATE_TTL_MS;
360
- for (const [id, state] of this.requestSessionStates) {
361
- if (id !== preservedId && state.lastAccessedAt < expiry) {
362
- this.requestSessionStates.delete(id);
519
+ const defaultIndex = indexOf(routing?.activeRow) ??
520
+ clampNonNegativeInt(routing?.activeIndex, 0) % count;
521
+ return (indexOf(routing?.activeRowByFamily?.[family]) ??
522
+ clampNonNegativeInt(routing?.activeIndexByFamily?.[family], defaultIndex) % count);
523
+ }
524
+ loadRepositoryRows(read) {
525
+ const baseNow = this.now();
526
+ const accounts = [];
527
+ const generated = [];
528
+ const versionUpdated = [];
529
+ for (const row of read.rows) {
530
+ const built = this.accountFromRow(row, accounts.length, baseNow);
531
+ if (built === undefined)
532
+ continue;
533
+ if (built.fingerprint === 'generated')
534
+ generated.push(built.account);
535
+ if (built.fingerprint === 'updated')
536
+ versionUpdated.push(built.account);
537
+ accounts.push(built.account);
538
+ }
539
+ const selection = {};
540
+ if (accounts.length > 0) {
541
+ for (const family of ['claude', 'gemini']) {
542
+ selection[family] = this.routedIndex(accounts, read.routing, family);
363
543
  }
364
544
  }
365
- if (this.requestSessionStates.size < MAX_ACCOUNT_SESSION_STATES ||
366
- this.requestSessionStates.has(preservedId)) {
367
- return;
545
+ this.selection.resetAccounts(accounts, selection);
546
+ // Persist each generated or version-updated fingerprint.
547
+ for (const account of [...generated, ...versionUpdated]) {
548
+ this.persistFingerprint(account);
368
549
  }
369
- let oldestId = null;
370
- let oldestAccess = Number.POSITIVE_INFINITY;
371
- for (const [id, state] of this.requestSessionStates) {
372
- if (id !== preservedId && state.lastAccessedAt < oldestAccess) {
373
- oldestId = id;
374
- oldestAccess = state.lastAccessedAt;
550
+ }
551
+ /**
552
+ * Brings a repository-backed manager up to date with a newer ready read.
553
+ *
554
+ * Accounts are matched by their exact ref (row id, credential epoch and
555
+ * identity, absence included), never by position. An account whose ref is
556
+ * unchanged stays the same object, with its ref object, its session pins,
557
+ * health, token balance and the other in-memory state; only what the read
558
+ * stores is refreshed, newest evidence winning (see `refreshFromRow`). The
559
+ * ref an account is matched under is the one it holds now, so an account
560
+ * this manager refreshed through `refreshAccount` is matched under its
561
+ * successor ref and keeps its pins. A row whose credential was replaced or
562
+ * re-added by anything else, or that is new, becomes a new account. An
563
+ * account the read no longer holds under its ref leaves the manager: pins
564
+ * and selections on it are dropped, writes still queued for it are
565
+ * discarded, and any later write about the old object is ignored rather
566
+ * than sent. A read that is not ready throws and leaves the manager as it
567
+ * was.
568
+ *
569
+ * This method applies the read it is given and does not flush or read
570
+ * itself. The caller must flush this manager's queued writes before
571
+ * taking the read, and must apply reads in the order they were taken: an
572
+ * older read applied after a newer one would bring back accounts and refs
573
+ * the pool no longer holds (the store's ref fences still refuse their
574
+ * writes). `refreshFromRepository` does all of this.
575
+ */
576
+ reloadFromRepository(read) {
577
+ if (this.repository === undefined) {
578
+ throw new Error('reloadFromRepository needs a repository-backed manager');
579
+ }
580
+ if (read.status !== 'ready') {
581
+ throw new Error(`the account repository is not ready (${read.status}); the manager keeps its accounts`);
582
+ }
583
+ const baseNow = this.now();
584
+ const fresh = [];
585
+ const fingerprintOf = new Map();
586
+ for (const row of read.rows) {
587
+ const built = this.accountFromRow(row, fresh.length, baseNow);
588
+ if (built === undefined)
589
+ continue;
590
+ fingerprintOf.set(built.account, built.fingerprint);
591
+ fresh.push(built.account);
592
+ }
593
+ const { added } = this.selection.replaceAccounts(fresh, {
594
+ keyOf: (account) => account.ref === undefined ? undefined : rowRefKey(account.ref),
595
+ refresh: (prior, account) => this.refreshFromRow(prior, account, fingerprintOf.get(account) ?? 'kept'),
596
+ // A selection on an account that left falls back to the stored one,
597
+ // as at load; it is not written back.
598
+ selectionFallback: (family, accounts) => this.routedIndex(accounts, read.routing, family),
599
+ });
600
+ const live = new Set(this.accounts.flatMap((account) => account.ref === undefined ? [] : [rowRefKey(account.ref)]));
601
+ for (const key of [...this.pendingRowWrites.keys()]) {
602
+ if (!live.has(key))
603
+ this.pendingRowWrites.delete(key);
604
+ }
605
+ for (const account of added) {
606
+ if (fingerprintOf.get(account) !== 'kept')
607
+ this.persistFingerprint(account);
608
+ }
609
+ }
610
+ /**
611
+ * Flushes this manager's queued writes, takes a fresh read of the
612
+ * repository and applies it with `reloadFromRepository`. Calls run one at
613
+ * a time in the order they were made, so a later call always applies a
614
+ * read taken after the earlier one's. A flush failure is thrown before
615
+ * anything is read; a read that is not ready throws and changes nothing.
616
+ */
617
+ refreshFromRepository() {
618
+ const repository = this.repository;
619
+ if (repository === undefined) {
620
+ return Promise.reject(new Error('refreshFromRepository needs a repository-backed manager'));
621
+ }
622
+ const run = this.reloading.then(async () => {
623
+ await this.flushSaveToDisk();
624
+ this.reloadFromRepository(await repository.read());
625
+ });
626
+ this.reloading = run.catch(() => { });
627
+ return run;
628
+ }
629
+ /**
630
+ * Refreshes an unchanged account's stored fields from a newer read,
631
+ * keeping what this manager knows to be newer: the later `lastUsed`,
632
+ * access token and evidence times (quota, tier, access verdicts), the
633
+ * later reset of each rate limit, a cooldown or switch it has queued but
634
+ * not yet written, a rate-limit clear it has queued, and request counts
635
+ * of the later day (the larger count per family on the same day). A
636
+ * fingerprint the read lacks keeps the one this manager generated.
637
+ */
638
+ refreshFromRow(prior, fresh, fingerprint) {
639
+ const pending = prior.ref === undefined
640
+ ? undefined
641
+ : this.pendingRowWrites.get(rowRefKey(prior.ref));
642
+ const newer = (a, b) => (a ?? Number.NEGATIVE_INFINITY) >= (b ?? Number.NEGATIVE_INFINITY);
643
+ prior.email = fresh.email;
644
+ prior.label = fresh.label;
645
+ prior.addedAt = fresh.addedAt;
646
+ prior.lastUsed = Math.max(prior.lastUsed, fresh.lastUsed);
647
+ prior.parts = fresh.parts;
648
+ prior.projectId = fresh.projectId;
649
+ prior.managedProjectId = fresh.managedProjectId;
650
+ if (newer(fresh.expires, prior.expires)) {
651
+ prior.access = fresh.access;
652
+ prior.expires = fresh.expires;
653
+ }
654
+ prior.enabled = fresh.enabled;
655
+ const rates = { ...fresh.rateLimitResetTimes };
656
+ for (const [key, value] of Object.entries(prior.rateLimitResetTimes)) {
657
+ const stored = rates[key];
658
+ if (value !== undefined && (stored === undefined || value > stored)) {
659
+ rates[key] = value;
375
660
  }
376
661
  }
377
- if (oldestId) {
378
- this.requestSessionStates.delete(oldestId);
662
+ for (const [key, value] of pending?.rateLimits ?? []) {
663
+ if (value === 'clear')
664
+ delete rates[key];
665
+ }
666
+ prior.rateLimitResetTimes = rates;
667
+ if (pending?.cooldown === undefined) {
668
+ prior.coolingDownUntil = fresh.coolingDownUntil;
669
+ prior.cooldownReason = fresh.cooldownReason;
670
+ }
671
+ if (pending?.switchReason === undefined) {
672
+ prior.lastSwitchReason = fresh.lastSwitchReason;
673
+ }
674
+ if (fingerprint !== 'generated') {
675
+ prior.fingerprint = fresh.fingerprint;
676
+ prior.fingerprintHistory = fresh.fingerprintHistory;
677
+ }
678
+ if (newer(fresh.cachedQuotaUpdatedAt, prior.cachedQuotaUpdatedAt)) {
679
+ prior.cachedQuota = fresh.cachedQuota;
680
+ prior.cachedQuotaAccountId = fresh.cachedQuotaAccountId;
681
+ prior.cachedQuotaUpdatedAt = fresh.cachedQuotaUpdatedAt;
682
+ }
683
+ if (newer(fresh.capturedTierAt, prior.capturedTierAt)) {
684
+ prior.capturedTierId = fresh.capturedTierId;
685
+ prior.capturedPaidTierId = fresh.capturedPaidTierId;
686
+ prior.capturedTierAt = fresh.capturedTierAt;
687
+ prior.capturedTierSchemaVersion = fresh.capturedTierSchemaVersion;
688
+ }
689
+ if (newer(accessEvidenceTime(fresh), accessEvidenceTime(prior))) {
690
+ prior.verificationRequired = fresh.verificationRequired;
691
+ prior.verificationRequiredAt = fresh.verificationRequiredAt;
692
+ prior.verificationRequiredReason = fresh.verificationRequiredReason;
693
+ prior.verificationUrl = fresh.verificationUrl;
694
+ prior.accountIneligible = fresh.accountIneligible;
695
+ prior.accountIneligibleAt = fresh.accountIneligibleAt;
696
+ prior.accountIneligibleReason = fresh.accountIneligibleReason;
697
+ prior.eligibilityStateUpdatedAt = fresh.eligibilityStateUpdatedAt;
698
+ }
699
+ prior.dailyRequestCounts = laterCounts(prior.dailyRequestCounts, fresh.dailyRequestCounts);
700
+ }
701
+ /**
702
+ * Adopt one current row with the selected account's exact row ID,
703
+ * credential epoch and authenticated identity after its bearer changes.
704
+ * General reloads keep the token with the later expiry; here the stored
705
+ * token and expiry always win. Keep selection, request counts, and pending
706
+ * rate-limit/cooldown observations attributed to that unchanged row ref.
707
+ * This writes nothing and cannot restore missing provider metadata.
708
+ */
709
+ adoptCurrentRow(account, row) {
710
+ if (this.repository === undefined ||
711
+ !this.accounts.includes(account) ||
712
+ account.ref === undefined ||
713
+ !sameRowRef(account.ref, row.ref) ||
714
+ !account.enabled ||
715
+ !row.enabled ||
716
+ !row.usable ||
717
+ unroutableReason(row) !== undefined)
718
+ return false;
719
+ const fresh = this.accountFromRow(row, account.index, this.now());
720
+ if (fresh === undefined)
721
+ return false;
722
+ this.refreshFromRow(account, fresh.account, fresh.fingerprint);
723
+ account.access = fresh.account.access;
724
+ account.expires = fresh.account.expires;
725
+ return true;
726
+ }
727
+ // ========== Repository writes ==========
728
+ /**
729
+ * Starts one attributed repository write about `account`. Its failure is
730
+ * reported here and kept by the repository for the next flush, which
731
+ * decides whether a save fails.
732
+ */
733
+ dispatch(account, label, write) {
734
+ const repository = this.repository;
735
+ const ref = account.ref;
736
+ if (repository === undefined || ref === undefined)
737
+ return;
738
+ if (!this.selection.has(account)) {
739
+ this.ignoreStale(account, label);
740
+ return;
379
741
  }
742
+ write(repository, ref).catch((error) => {
743
+ this.onDiagnostic?.(`Account ${label} was not persisted`, {
744
+ rowId: ref.id,
745
+ error: error instanceof Error ? error.message : String(error),
746
+ });
747
+ });
380
748
  }
381
- getActiveIndex(family, identity) {
382
- return identity
383
- ? this.getRequestSessionState(identity).currentAccountIndexByFamily[family]
384
- : this.currentAccountIndexByFamily[family];
749
+ /**
750
+ * A write about an account that left the manager at a reload (its
751
+ * credential was replaced or its row removed) is not sent: it was decided
752
+ * for a credential the pool no longer holds under this manager.
753
+ */
754
+ ignoreStale(account, label) {
755
+ this.onDiagnostic?.(`Account ${label} ignored: the account is no longer loaded`, account.ref === undefined ? undefined : { rowId: account.ref.id });
385
756
  }
386
- setActiveIndex(family, index, identity) {
387
- if (!identity) {
388
- this.currentAccountIndexByFamily[family] = index;
757
+ /** Queues an absolute-state change of `account` for the next save. */
758
+ queueRowWrite(account, change) {
759
+ if (this.repository === undefined || account.ref === undefined)
760
+ return;
761
+ if (!this.selection.has(account)) {
762
+ this.ignoreStale(account, 'state change');
389
763
  return;
390
764
  }
391
- const state = this.getRequestSessionState(identity);
392
- state.currentAccountIndexByFamily[family] = index;
393
- if (!state.parentId) {
394
- // Preserve a useful persisted starting point without coupling active root sessions.
395
- this.currentAccountIndexByFamily[family] = index;
765
+ const key = rowRefKey(account.ref);
766
+ let pending = this.pendingRowWrites.get(key);
767
+ if (pending === undefined) {
768
+ pending = { ref: account.ref, rateLimits: new Map() };
769
+ this.pendingRowWrites.set(key, pending);
396
770
  }
771
+ change(pending);
772
+ this.requestSaveToDisk();
397
773
  }
398
- getCursor(family, identity) {
399
- return identity
400
- ? this.getRequestSessionState(identity).cursorByFamily[family]
401
- : this.cursorByFamily[family];
402
- }
403
- advanceCursor(family, identity) {
404
- const nextGlobalCursor = this.cursorByFamily[family] + 1;
405
- this.cursorByFamily[family] = nextGlobalCursor;
406
- if (identity) {
407
- this.getRequestSessionState(identity).cursorByFamily[family] += 1;
774
+ /** Merges a write that met lock contention back into the queue. */
775
+ requeueRowWrite(failed) {
776
+ const key = rowRefKey(failed.ref);
777
+ const newer = this.pendingRowWrites.get(key);
778
+ if (newer === undefined) {
779
+ this.pendingRowWrites.set(key, failed);
408
780
  }
781
+ else {
782
+ for (const [rateKey, value] of failed.rateLimits) {
783
+ if (!newer.rateLimits.has(rateKey))
784
+ newer.rateLimits.set(rateKey, value);
785
+ }
786
+ newer.cooldown ??= failed.cooldown;
787
+ newer.switchReason ??= failed.switchReason;
788
+ if (failed.lastUsed !== undefined &&
789
+ (newer.lastUsed === undefined || failed.lastUsed > newer.lastUsed)) {
790
+ newer.lastUsed = failed.lastUsed;
791
+ }
792
+ }
793
+ this.requestSaveToDisk();
409
794
  }
410
- getUsedAccounts(identity) {
411
- return identity
412
- ? this.getRequestSessionState(identity).usedAccounts
413
- : this.sessionUsedAccounts;
795
+ /**
796
+ * Queues the pool-wide selection of `family` for the next save. Claude's
797
+ * selection is also recorded as the `active` target, the single active
798
+ * account the pool file kept before it tracked families separately.
799
+ */
800
+ queueSelection(family, account) {
801
+ if (this.repository === undefined)
802
+ return;
803
+ const ref = account?.ref ?? null;
804
+ this.pendingSelections.set(family, ref);
805
+ if (family === 'claude')
806
+ this.pendingSelections.set('active', ref);
807
+ this.requestSaveToDisk();
414
808
  }
415
- preferAccountOutsideParent(accounts, family, identity) {
416
- if (!identity) {
417
- return accounts;
809
+ persistFingerprint(account) {
810
+ const fingerprint = account.fingerprint;
811
+ if (fingerprint === undefined)
812
+ return;
813
+ const history = [...(account.fingerprintHistory ?? [])];
814
+ this.dispatch(account, 'fingerprint', (repository, ref) => repository.recordFingerprint(ref, { fingerprint, history }));
815
+ }
816
+ persistAccessVerdict(account, verdict) {
817
+ this.dispatch(account, 'access verdict', (repository, ref) => repository.recordAccessVerdict(ref, verdict));
818
+ }
819
+ /** Starts every queued coalesced write. */
820
+ dispatchQueuedWrites() {
821
+ const repository = this.repository;
822
+ if (repository === undefined)
823
+ return;
824
+ const rows = [...this.pendingRowWrites.values()];
825
+ const selections = [...this.pendingSelections];
826
+ this.pendingRowWrites = new Map();
827
+ this.pendingSelections = new Map();
828
+ for (const pending of rows) {
829
+ repository
830
+ .updateMetadata(pending.ref, (current, row) => ({
831
+ kind: 'set',
832
+ metadata: applyPendingRowWrite(current ?? { addedAt: row.storeAddedAt ?? this.now(), lastUsed: 0 }, pending),
833
+ }))
834
+ .catch((error) => {
835
+ if (error instanceof AccountRepositoryError &&
836
+ error.failure.kind === 'lock-contention' &&
837
+ !error.failure.ambiguous) {
838
+ this.requeueRowWrite(pending);
839
+ }
840
+ });
841
+ }
842
+ for (const [target, ref] of selections) {
843
+ repository.selectAccount(target, ref).catch(() => {
844
+ // Reported by the flush that follows.
845
+ });
846
+ }
847
+ }
848
+ /**
849
+ * Starts the queued writes and waits until the repository has drained
850
+ * every write, including the ones started immediately (usage, access,
851
+ * removal). Throws `AccountManagerPersistError` for the first failure that
852
+ * is not tolerated; every failure is reported through `onDiagnostic`.
853
+ */
854
+ async flushRepositoryWrites() {
855
+ const repository = this.repository;
856
+ if (repository === undefined)
857
+ return;
858
+ this.dispatchQueuedWrites();
859
+ const report = await repository.flush();
860
+ for (const failure of report.failures) {
861
+ this.onDiagnostic?.('Account repository write failed', {
862
+ operation: failure.operation,
863
+ kind: failure.kind,
864
+ rowId: failure.rowId,
865
+ ambiguous: failure.ambiguous,
866
+ message: failure.message,
867
+ });
418
868
  }
419
- const parentId = this.getRequestSessionState(identity).parentId;
420
- if (!parentId) {
421
- return accounts;
869
+ const primary = report.failures.find((failure) => !TOLERATED_FAILURES.has(failure.kind));
870
+ if (primary !== undefined)
871
+ throw new AccountManagerPersistError(primary, report);
872
+ }
873
+ /**
874
+ * Refreshes the account's credential through the repository: the only
875
+ * path by which a successor token is stored. A rotation updates the
876
+ * account's bearer and ref (an identity learnt by the refresh is part of
877
+ * it) and reads the committed refresh token back; a contradicted identity
878
+ * disables the account in memory, as the store disabled its row.
879
+ */
880
+ async refreshAccount(account, options) {
881
+ const repository = this.repository;
882
+ const ref = account.ref;
883
+ if (repository === undefined || ref === undefined) {
884
+ throw new Error('refreshAccount needs a repository-backed account');
885
+ }
886
+ const outcome = await repository.refresh(ref, options);
887
+ if (outcome.status === 'rotated') {
888
+ account.ref = outcome.ref;
889
+ account.access = outcome.accessToken;
890
+ account.expires = outcome.expiresAt;
891
+ const read = await repository.read();
892
+ if (read.status === 'ready') {
893
+ const row = read.rows.find((candidate) => sameRowRef(candidate.ref, outcome.ref));
894
+ if (row?.credential !== undefined) {
895
+ account.parts = {
896
+ ...account.parts,
897
+ refreshToken: row.credential.refreshToken,
898
+ };
899
+ }
900
+ }
422
901
  }
423
- const parentState = this.requestSessionStates.get(parentId);
424
- const parentIndex = parentState?.currentAccountIndexByFamily[family] ?? -1;
425
- if (parentIndex < 0) {
426
- return accounts;
902
+ else if (outcome.status === 'identity-contradicted') {
903
+ account.access = undefined;
904
+ account.expires = undefined;
905
+ this.selection.setEnabledInMemory(account, false);
427
906
  }
428
- const isolated = accounts.filter((account) => account.index !== parentIndex);
429
- return isolated.length > 0 ? isolated : accounts;
907
+ return outcome;
908
+ }
909
+ /**
910
+ * The health scores this manager selects with, keyed by its current
911
+ * account indexes. Record request outcomes here, not in the process-wide
912
+ * tracker, for a repository-backed manager.
913
+ */
914
+ get healthTracker() {
915
+ return this.selection.healthTracker;
916
+ }
917
+ /** The token balances this manager selects with; see `healthTracker`. */
918
+ get tokenTracker() {
919
+ return this.selection.tokenTracker;
920
+ }
921
+ // ========== Selection, owned by `this.selection` ==========
922
+ getAccountCount() {
923
+ return this.selection.getAccountCount();
924
+ }
925
+ getTotalAccountCount() {
926
+ return this.selection.getTotalAccountCount();
927
+ }
928
+ getEnabledAccounts() {
929
+ return this.selection.getEnabledAccounts();
930
+ }
931
+ /** Copies of the accounts, each with its own credential parts and rate limits. */
932
+ getAccountsSnapshot() {
933
+ return this.accounts.map((a) => ({
934
+ ...a,
935
+ parts: { ...a.parts },
936
+ rateLimitResetTimes: { ...a.rateLimitResetTimes },
937
+ }));
430
938
  }
431
939
  deleteSessionState(sessionId) {
432
- this.requestSessionStates.delete(sessionId);
940
+ this.selection.deleteSessionState(sessionId);
433
941
  }
434
942
  getCurrentAccountForFamily(family, identity) {
435
- const currentIndex = this.getActiveIndex(family, identity);
436
- if (currentIndex >= 0 && currentIndex < this.accounts.length) {
437
- const account = this.accounts[currentIndex] ?? null;
438
- // Only return account if it's enabled - disabled accounts should not be selected
439
- if (account && account.enabled !== false) {
440
- return account;
441
- }
442
- }
443
- return null;
943
+ return this.selection.getCurrentAccountForFamily(family, identity);
444
944
  }
445
945
  /**
446
946
  * Numeric active indexes for each model family. Exposed so callers
@@ -449,29 +949,20 @@ export class AccountManager {
449
949
  * through the account-lookup layer.
450
950
  */
451
951
  getActiveIndexByFamily(identity) {
452
- return {
453
- claude: this.getActiveIndex('claude', identity),
454
- gemini: this.getActiveIndex('gemini', identity),
455
- };
952
+ return this.selection.getActiveIndexByFamily(identity);
456
953
  }
457
954
  markSwitched(account, reason, family, identity) {
458
- account.lastSwitchReason = reason;
459
- this.setActiveIndex(family, account.index, identity);
955
+ this.selection.markSwitched(account, reason, family, identity);
460
956
  }
461
957
  /**
462
958
  * Check if we should show an account switch toast.
463
959
  * Debounces repeated toasts for the same account.
464
960
  */
465
961
  shouldShowAccountToast(accountIndex, debounceMs = 30000) {
466
- const now = this.now();
467
- if (accountIndex !== this.lastToastAccountIndex) {
468
- return true;
469
- }
470
- return now - this.lastToastTime >= debounceMs;
962
+ return this.selection.shouldShowAccountToast(accountIndex, debounceMs);
471
963
  }
472
964
  markToastShown(accountIndex) {
473
- this.lastToastAccountIndex = accountIndex;
474
- this.lastToastTime = this.now();
965
+ this.selection.markToastShown(accountIndex);
475
966
  }
476
967
  getCurrentOrNextForFamily(family, model, strategy = 'sticky', headerStyle = 'antigravity', pidOffsetEnabled = false, softQuotaThresholdPercent = 100, softQuotaCacheTtlMs = 10 * 60 * 1000, identity,
477
968
  /**
@@ -483,132 +974,15 @@ export class AccountManager {
483
974
  * wait path.
484
975
  */
485
976
  excludeIndexes) {
486
- const quotaKey = getQuotaKey(family, headerStyle, model);
487
- const effectiveSoftQuotaThreshold = this.getEffectiveSoftQuotaThreshold(softQuotaThresholdPercent);
488
- // OpenCode may run many root and child sessions concurrently in one plugin
489
- // process. Pin each exact session until its account becomes unavailable.
490
- if (identity) {
491
- const pinned = this.getCurrentAccountForFamily(family, identity);
492
- if (pinned) {
493
- clearExpiredRateLimits(pinned, this.now);
494
- const unavailable = (excludeIndexes?.has(pinned.index) ?? false) ||
495
- isRateLimitedForHeaderStyle(pinned, family, headerStyle, this.now, model) ||
496
- isOverSoftQuotaThreshold(pinned, family, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, this.now, model) ||
497
- this.isAccountCoolingDown(pinned);
498
- if (!unavailable) {
499
- this.markTouchedForQuota(pinned, quotaKey);
500
- return pinned;
501
- }
502
- }
503
- }
504
- if (strategy === 'round-robin') {
505
- const next = this.getNextForFamily(family, model, headerStyle, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, identity, excludeIndexes);
506
- if (next) {
507
- this.markTouchedForQuota(next, quotaKey);
508
- this.setActiveIndex(family, next.index, identity);
509
- }
510
- return next;
511
- }
512
- if (strategy === 'hybrid') {
513
- const healthTracker = getHealthTracker();
514
- const tokenTracker = getTokenTracker();
515
- const eligibleAccounts = this.preferAccountOutsideParent(this.accounts.filter((acc) => acc.enabled !== false && !excludeIndexes?.has(acc.index)), family, identity);
516
- const accountsWithMetrics = eligibleAccounts.map((acc) => {
517
- clearExpiredRateLimits(acc, this.now);
518
- return {
519
- index: acc.index,
520
- lastUsed: acc.lastUsed,
521
- healthScore: healthTracker.getScore(acc.index),
522
- isRateLimited: isRateLimitedForHeaderStyle(acc, family, headerStyle, this.now, model) ||
523
- isOverSoftQuotaThreshold(acc, family, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, this.now, model),
524
- isCoolingDown: this.isAccountCoolingDown(acc),
525
- };
526
- });
527
- // Get current account index for stickiness
528
- const currentIndex = this.getActiveIndex(family, identity);
529
- const selectedIndex = selectHybridAccount(accountsWithMetrics, tokenTracker, currentIndex, 50, this.now);
530
- if (selectedIndex !== null) {
531
- const selected = this.accounts[selectedIndex];
532
- if (selected) {
533
- selected.lastUsed = this.now();
534
- this.markTouchedForQuota(selected, quotaKey);
535
- this.setActiveIndex(family, selected.index, identity);
536
- return selected;
537
- }
538
- }
539
- }
540
- // Fallback: sticky selection (used when hybrid finds no candidates)
541
- // PID-based offset for multi-session distribution (opt-in)
542
- // Different sessions (PIDs) will prefer different starting accounts
543
- const offsetApplied = identity
544
- ? this.getRequestSessionState(identity).offsetAppliedByFamily
545
- : this.sessionOffsetApplied;
546
- if (pidOffsetEnabled &&
547
- !offsetApplied[family] &&
548
- this.accounts.length > 1) {
549
- const pidOffset = this.pid % this.accounts.length;
550
- const activeIndex = this.getActiveIndex(family, identity);
551
- const baseIndex = activeIndex >= 0 ? activeIndex : this.getCursor(family, identity);
552
- const newIndex = (baseIndex + pidOffset) % this.accounts.length;
553
- this.onDiagnostic?.('Applying PID account offset', {
554
- pid: this.pid,
555
- offset: pidOffset,
556
- family,
557
- fromIndex: baseIndex,
558
- toIndex: newIndex,
559
- });
560
- this.setActiveIndex(family, newIndex, identity);
561
- offsetApplied[family] = true;
562
- }
563
- const current = this.getCurrentAccountForFamily(family, identity);
564
- if (current && !excludeIndexes?.has(current.index)) {
565
- clearExpiredRateLimits(current, this.now);
566
- const isLimitedForRequestedStyle = isRateLimitedForHeaderStyle(current, family, headerStyle, this.now, model);
567
- const isOverThreshold = isOverSoftQuotaThreshold(current, family, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, this.now, model);
568
- if (!isLimitedForRequestedStyle &&
569
- !isOverThreshold &&
570
- !this.isAccountCoolingDown(current)) {
571
- this.markTouchedForQuota(current, quotaKey);
572
- return current;
573
- }
574
- }
575
- const next = this.getNextForFamily(family, model, headerStyle, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, identity, excludeIndexes);
576
- if (next) {
577
- this.markTouchedForQuota(next, quotaKey);
578
- this.setActiveIndex(family, next.index, identity);
579
- }
580
- return next;
977
+ return this.selection.getCurrentOrNextForFamily(family, model, strategy, headerStyle, pidOffsetEnabled, softQuotaThresholdPercent, softQuotaCacheTtlMs, identity, excludeIndexes);
581
978
  }
582
979
  getNextForFamily(family, model, headerStyle = 'antigravity', softQuotaThresholdPercent = 100, softQuotaCacheTtlMs = 10 * 60 * 1000, identity,
583
980
  /** Indexes ruled out by the caller (e.g. killswitch pre-filter). */
584
981
  excludeIndexes) {
585
- const effectiveSoftQuotaThreshold = this.getEffectiveSoftQuotaThreshold(softQuotaThresholdPercent);
586
- const allAvailable = this.accounts.filter((account) => {
587
- clearExpiredRateLimits(account, this.now);
588
- return (account.enabled !== false &&
589
- !excludeIndexes?.has(account.index) &&
590
- !isRateLimitedForHeaderStyle(account, family, headerStyle, this.now, model) &&
591
- !isOverSoftQuotaThreshold(account, family, effectiveSoftQuotaThreshold, softQuotaCacheTtlMs, this.now, model) &&
592
- !this.isAccountCoolingDown(account));
593
- });
594
- const available = this.preferAccountOutsideParent(allAvailable, family, identity);
595
- if (available.length === 0) {
596
- return null;
597
- }
598
- const usedAccounts = this.getUsedAccounts(identity);
599
- const sessionUsed = available.filter((account) => usedAccounts.has(account.index));
600
- const candidates = sessionUsed.length > 0 ? sessionUsed : available;
601
- const cursor = this.getCursor(family, identity);
602
- const account = candidates[cursor % candidates.length];
603
- if (!account) {
604
- return null;
605
- }
606
- this.advanceCursor(family, identity);
607
- return account;
982
+ return this.selection.getNextForFamily(family, model, headerStyle, softQuotaThresholdPercent, softQuotaCacheTtlMs, identity, excludeIndexes);
608
983
  }
609
984
  markRateLimited(account, retryAfterMs, family, headerStyle = 'antigravity', model) {
610
- const key = getQuotaKey(family, headerStyle, model);
611
- account.rateLimitResetTimes[key] = this.now() + retryAfterMs;
985
+ this.selection.markRateLimited(account, retryAfterMs, family, headerStyle, model);
612
986
  }
613
987
  /**
614
988
  * Mark an account as used after a successful API request.
@@ -616,167 +990,58 @@ export class AccountManager {
616
990
  * Should be called AFTER request completion, not during account selection.
617
991
  */
618
992
  markAccountUsed(accountIndex) {
619
- const account = this.accounts.find((a) => a.index === accountIndex);
620
- if (account) {
621
- account.lastUsed = this.now();
622
- }
993
+ this.selection.markAccountUsed(accountIndex);
623
994
  }
624
995
  recordSessionUsage(accountIndex, identity) {
625
- this.getUsedAccounts(identity).add(accountIndex);
996
+ this.selection.recordSessionUsage(accountIndex, identity);
626
997
  }
627
998
  wasUsedInSession(accountIndex, identity) {
628
- return this.getUsedAccounts(identity).has(accountIndex);
999
+ return this.selection.wasUsedInSession(accountIndex, identity);
629
1000
  }
630
1001
  shouldProactivelyRotate(family, model, thresholdPercent, cacheTtlMs, identity) {
631
- if (thresholdPercent <= 0)
632
- return false;
633
- const current = this.getCurrentAccountForFamily(family, identity);
634
- if (!current?.cachedQuota || current.cachedQuotaUpdatedAt == null)
635
- return false;
636
- const age = this.now() - current.cachedQuotaUpdatedAt;
637
- if (age > cacheTtlMs)
638
- return false;
639
- const quotaGroup = resolveQuotaGroup(family, model);
640
- const groupData = current.cachedQuota[quotaGroup];
641
- if (groupData?.remainingFraction == null)
642
- return false;
643
- const remainingPercent = Math.max(0, Math.min(100, groupData.remainingFraction * 100));
644
- return remainingPercent < thresholdPercent;
1002
+ return this.selection.shouldProactivelyRotate(family, model, thresholdPercent, cacheTtlMs, identity);
645
1003
  }
646
1004
  proactivelyRotateForFamily(family, model, headerStyle, softQuotaThresholdPercent, softQuotaCacheTtlMs, identity) {
647
- const currentIndex = this.getActiveIndex(family, identity);
648
- const candidates = this.preferAccountOutsideParent(this.accounts.filter((acc) => {
649
- if (acc.enabled === false)
650
- return false;
651
- if (acc.index === currentIndex)
652
- return false;
653
- clearExpiredRateLimits(acc, this.now);
654
- if (isRateLimitedForHeaderStyle(acc, family, headerStyle, this.now, model))
655
- return false;
656
- if (isOverSoftQuotaThreshold(acc, family, softQuotaThresholdPercent, softQuotaCacheTtlMs, this.now, model))
657
- return false;
658
- if (this.isAccountCoolingDown(acc))
659
- return false;
660
- return true;
661
- }), family, identity);
662
- if (candidates.length === 0)
663
- return null;
664
- const usedAccounts = this.getUsedAccounts(identity);
665
- const warmCandidates = candidates.filter((account) => usedAccounts.has(account.index));
666
- const pool = warmCandidates.length > 0 ? warmCandidates : candidates;
667
- const quotaGroup = resolveQuotaGroup(family, model);
668
- pool.sort((a, b) => {
669
- const aRemaining = a.cachedQuota?.[quotaGroup]?.remainingFraction ?? 0;
670
- const bRemaining = b.cachedQuota?.[quotaGroup]?.remainingFraction ?? 0;
671
- return bRemaining - aRemaining;
672
- });
673
- const selected = pool[0];
674
- if (!selected)
675
- return null;
676
- const quotaKey = getQuotaKey(family, headerStyle, model);
677
- this.markTouchedForQuota(selected, quotaKey);
678
- this.setActiveIndex(family, selected.index, identity);
679
- return selected;
1005
+ return this.selection.proactivelyRotateForFamily(family, model, headerStyle, softQuotaThresholdPercent, softQuotaCacheTtlMs, identity);
680
1006
  }
681
1007
  markRateLimitedWithReason(account, family, headerStyle, model, reason, retryAfterMs, failureTtlMs = 3600_000) {
682
- const now = this.now();
683
- // TTL-based reset: if last failure was more than failureTtlMs ago, reset count
684
- if (account.lastFailureTime !== undefined &&
685
- now - account.lastFailureTime > failureTtlMs) {
686
- account.consecutiveFailures = 0;
687
- }
688
- const failures = (account.consecutiveFailures ?? 0) + 1;
689
- account.consecutiveFailures = failures;
690
- account.lastFailureTime = now;
691
- const backoffMs = calculateBackoffMs(reason, failures - 1, retryAfterMs, this.random);
692
- const key = getQuotaKey(family, headerStyle, model);
693
- account.rateLimitResetTimes[key] = now + backoffMs;
694
- return backoffMs;
1008
+ return this.selection.markRateLimitedWithReason(account, family, headerStyle, model, reason, retryAfterMs, failureTtlMs);
695
1009
  }
696
1010
  markRequestSuccess(account) {
697
- if (account.consecutiveFailures) {
698
- account.consecutiveFailures = 0;
699
- }
1011
+ this.selection.markRequestSuccess(account);
700
1012
  }
701
1013
  clearAllRateLimitsForFamily(family, model) {
702
- for (const account of this.accounts) {
703
- if (family === 'claude') {
704
- delete account.rateLimitResetTimes.claude;
705
- }
706
- else {
707
- const antigravityKey = getQuotaKey(family, 'antigravity', model);
708
- const cliKey = getQuotaKey(family, 'gemini-cli', model);
709
- delete account.rateLimitResetTimes[antigravityKey];
710
- delete account.rateLimitResetTimes[cliKey];
711
- }
712
- account.consecutiveFailures = 0;
713
- }
1014
+ this.selection.clearAllRateLimitsForFamily(family, model);
714
1015
  }
715
1016
  shouldTryOptimisticReset(family, model) {
716
- const minWaitMs = this.getMinWaitTimeForFamily(family, model);
717
- return minWaitMs > 0 && minWaitMs <= 2_000;
1017
+ return this.selection.shouldTryOptimisticReset(family, model);
718
1018
  }
719
1019
  markAccountCoolingDown(account, cooldownMs, reason) {
720
- account.coolingDownUntil = this.now() + cooldownMs;
721
- account.cooldownReason = reason;
1020
+ this.selection.markAccountCoolingDown(account, cooldownMs, reason);
722
1021
  }
723
1022
  isAccountCoolingDown(account) {
724
- if (account.coolingDownUntil === undefined) {
725
- return false;
726
- }
727
- if (this.now() >= account.coolingDownUntil) {
728
- this.clearAccountCooldown(account);
729
- return false;
730
- }
731
- return true;
1023
+ return this.selection.isAccountCoolingDown(account);
732
1024
  }
733
1025
  clearAccountCooldown(account) {
734
- delete account.coolingDownUntil;
735
- delete account.cooldownReason;
1026
+ this.selection.clearAccountCooldown(account);
736
1027
  }
737
1028
  getAccountCooldownReason(account) {
738
- return this.isAccountCoolingDown(account)
739
- ? account.cooldownReason
740
- : undefined;
1029
+ return this.selection.getAccountCooldownReason(account);
741
1030
  }
742
1031
  markTouchedForQuota(account, quotaKey) {
743
- account.touchedForQuota[quotaKey] = this.now();
1032
+ this.selection.markTouchedForQuota(account, quotaKey);
744
1033
  }
745
1034
  isFreshForQuota(account, quotaKey) {
746
- const touchedAt = account.touchedForQuota[quotaKey];
747
- if (!touchedAt)
748
- return true;
749
- const resetTime = account.rateLimitResetTimes[quotaKey];
750
- if (resetTime && touchedAt < resetTime)
751
- return true;
752
- return false;
1035
+ return this.selection.isFreshForQuota(account, quotaKey);
753
1036
  }
754
1037
  getFreshAccountsForQuota(quotaKey, family, model) {
755
- return this.accounts.filter((acc) => {
756
- clearExpiredRateLimits(acc, this.now);
757
- return (acc.enabled !== false &&
758
- this.isFreshForQuota(acc, quotaKey) &&
759
- !isRateLimitedForFamily(acc, family, this.now, model) &&
760
- !this.isAccountCoolingDown(acc));
761
- });
1038
+ return this.selection.getFreshAccountsForQuota(quotaKey, family, model);
762
1039
  }
763
1040
  isRateLimitedForHeaderStyle(account, family, headerStyle, model) {
764
- return isRateLimitedForHeaderStyle(account, family, headerStyle, this.now, model);
1041
+ return this.selection.isRateLimitedForHeaderStyle(account, family, headerStyle, model);
765
1042
  }
766
1043
  getAvailableHeaderStyle(account, family, model) {
767
- clearExpiredRateLimits(account, this.now);
768
- if (family === 'claude') {
769
- return isRateLimitedForHeaderStyle(account, family, 'antigravity', this.now)
770
- ? null
771
- : 'antigravity';
772
- }
773
- if (!isRateLimitedForHeaderStyle(account, family, 'antigravity', this.now, model)) {
774
- return 'antigravity';
775
- }
776
- if (!isRateLimitedForHeaderStyle(account, family, 'gemini-cli', this.now, model)) {
777
- return 'gemini-cli';
778
- }
779
- return null;
1044
+ return this.selection.getAvailableHeaderStyle(account, family, model);
780
1045
  }
781
1046
  /**
782
1047
  * Check if any OTHER account has antigravity quota available for the given family/model.
@@ -791,198 +1056,62 @@ export class AccountManager {
791
1056
  * @returns true if any other enabled, non-cooling-down account has antigravity available
792
1057
  */
793
1058
  hasOtherAccountWithAntigravityAvailable(currentAccountIndex, family, model) {
794
- // Claude has no gemini-cli fallback - always return false
795
- // (This method is only relevant for Gemini's dual quota pools)
796
- if (family === 'claude') {
797
- return false;
798
- }
799
- return this.accounts.some((acc) => {
800
- // Skip current account
801
- if (acc.index === currentAccountIndex) {
802
- return false;
803
- }
804
- // Skip disabled accounts
805
- if (acc.enabled === false) {
806
- return false;
807
- }
808
- // Skip cooling down accounts
809
- if (this.isAccountCoolingDown(acc)) {
810
- return false;
811
- }
812
- // Clear expired rate limits before checking
813
- clearExpiredRateLimits(acc, this.now);
814
- // Check if antigravity is available for this account
815
- return !isRateLimitedForHeaderStyle(acc, family, 'antigravity', this.now, model);
816
- });
1059
+ return this.selection.hasOtherAccountWithAntigravityAvailable(currentAccountIndex, family, model);
817
1060
  }
818
1061
  setAccountEnabled(accountIndex, enabled) {
819
- const account = this.accounts[accountIndex];
820
- if (!account) {
821
- return false;
822
- }
823
- if (enabled && account.accountIneligible) {
824
- return false;
825
- }
826
- account.enabled = enabled;
827
- if (!enabled) {
828
- for (const family of Object.keys(this.currentAccountIndexByFamily)) {
829
- if (this.currentAccountIndexByFamily[family] === accountIndex) {
830
- const next = this.accounts.find((a, i) => i !== accountIndex && a.enabled !== false);
831
- this.currentAccountIndexByFamily[family] = next?.index ?? -1;
832
- }
833
- }
834
- }
835
- this.requestSaveToDisk();
836
- return true;
1062
+ return this.selection.setAccountEnabled(accountIndex, enabled);
837
1063
  }
838
1064
  markAccountVerificationRequired(accountIndex, reason, verifyUrl) {
839
- const account = this.accounts[accountIndex];
840
- if (!account) {
841
- return false;
842
- }
843
- const timestamp = this.now();
844
- account.verificationRequired = true;
845
- account.verificationRequiredAt = timestamp;
846
- account.verificationRequiredReason = reason?.trim() || undefined;
847
- if (account.accountIneligible === true ||
848
- account.accountIneligibleAt !== undefined ||
849
- account.accountIneligibleReason !== undefined) {
850
- account.accountIneligible = false;
851
- account.accountIneligibleAt = undefined;
852
- account.accountIneligibleReason = undefined;
853
- account.eligibilityStateUpdatedAt = timestamp;
854
- }
855
- const normalizedVerifyUrl = verifyUrl?.trim();
856
- if (normalizedVerifyUrl) {
857
- account.verificationUrl = normalizedVerifyUrl;
858
- }
859
- if (account.enabled !== false) {
860
- this.setAccountEnabled(accountIndex, false);
861
- }
862
- else {
863
- this.requestSaveToDisk();
864
- }
865
- return true;
1065
+ return this.selection.markAccountVerificationRequired(accountIndex, reason, verifyUrl);
866
1066
  }
867
1067
  markAccountIneligible(accountIndex, reason) {
868
- const account = this.accounts[accountIndex];
869
- if (!account) {
870
- return false;
871
- }
872
- const timestamp = this.now();
873
- account.accountIneligible = true;
874
- account.accountIneligibleAt = timestamp;
875
- account.accountIneligibleReason =
876
- reason?.trim() || 'Google marked this account as ineligible.';
877
- account.eligibilityStateUpdatedAt = timestamp;
878
- account.verificationRequired = false;
879
- account.verificationRequiredAt = undefined;
880
- account.verificationRequiredReason = undefined;
881
- account.verificationUrl = undefined;
882
- if (account.enabled !== false) {
883
- this.setAccountEnabled(accountIndex, false);
884
- }
885
- else {
886
- this.requestSaveToDisk();
887
- }
888
- return true;
1068
+ return this.selection.markAccountIneligible(accountIndex, reason);
889
1069
  }
890
1070
  clearAccountAccessBlocks(accountIndex, enableAccount = false) {
891
- const account = this.accounts[accountIndex];
892
- if (!account) {
893
- return false;
894
- }
895
- const wasVerificationRequired = account.verificationRequired === true;
896
- const wasIneligible = account.accountIneligible === true;
897
- const hadMetadata = wasVerificationRequired ||
898
- wasIneligible ||
899
- account.verificationRequiredAt !== undefined ||
900
- account.verificationRequiredReason !== undefined ||
901
- account.verificationUrl !== undefined ||
902
- account.accountIneligibleAt !== undefined ||
903
- account.accountIneligibleReason !== undefined ||
904
- account.eligibilityStateUpdatedAt !== undefined;
905
- account.verificationRequired = false;
906
- account.verificationRequiredAt = undefined;
907
- account.verificationRequiredReason = undefined;
908
- account.verificationUrl = undefined;
909
- account.accountIneligible = false;
910
- account.accountIneligibleAt = undefined;
911
- account.accountIneligibleReason = undefined;
912
- if (wasIneligible || account.eligibilityStateUpdatedAt !== undefined) {
913
- account.eligibilityStateUpdatedAt = this.now();
914
- }
915
- if (enableAccount &&
916
- (wasVerificationRequired || wasIneligible) &&
917
- account.enabled === false) {
918
- this.setAccountEnabled(accountIndex, true);
919
- }
920
- else if (hadMetadata) {
921
- this.requestSaveToDisk();
922
- }
923
- return true;
1071
+ return this.selection.clearAccountAccessBlocks(accountIndex, enableAccount);
924
1072
  }
925
1073
  removeAccountByIndex(accountIndex) {
926
- if (accountIndex < 0 || accountIndex >= this.accounts.length) {
1074
+ const accounts = this.accounts;
1075
+ if (accountIndex < 0 || accountIndex >= accounts.length) {
927
1076
  return false;
928
1077
  }
929
- const account = this.accounts[accountIndex];
1078
+ const account = accounts[accountIndex];
930
1079
  if (!account) {
931
1080
  return false;
932
1081
  }
933
1082
  return this.removeAccount(account);
934
1083
  }
935
1084
  removeAccount(account) {
936
- const idx = this.accounts.indexOf(account);
937
- if (idx < 0) {
1085
+ if (!this.selection.has(account)) {
938
1086
  return false;
939
1087
  }
940
- this.accounts.splice(idx, 1);
941
- this.accounts.forEach((acc, index) => {
942
- acc.index = index;
943
- });
944
- if (this.accounts.length === 0) {
945
- this.cursorByFamily = { claude: 0, gemini: 0 };
946
- this.currentAccountIndexByFamily.claude = -1;
947
- this.currentAccountIndexByFamily.gemini = -1;
948
- this.requestSessionStates.clear();
949
- return true;
950
- }
951
- for (const family of ['claude', 'gemini']) {
952
- if (this.cursorByFamily[family] > idx) {
953
- this.cursorByFamily[family] -= 1;
1088
+ // Fenced on the account's credential: a row replaced or re-added since
1089
+ // it was loaded is refused rather than removed.
1090
+ this.dispatch(account, 'removal', (repository, ref) => repository.remove(ref));
1091
+ for (const [key, pending] of this.pendingRowWrites) {
1092
+ if (account.ref !== undefined && sameRowRef(pending.ref, account.ref)) {
1093
+ this.pendingRowWrites.delete(key);
954
1094
  }
955
- this.cursorByFamily[family] =
956
- this.cursorByFamily[family] % this.accounts.length;
957
- if (this.currentAccountIndexByFamily[family] > idx) {
958
- this.currentAccountIndexByFamily[family] -= 1;
959
- }
960
- if (this.currentAccountIndexByFamily[family] >= this.accounts.length) {
961
- this.currentAccountIndexByFamily[family] = -1;
962
- }
963
- for (const state of this.requestSessionStates.values()) {
964
- const currentIndex = state.currentAccountIndexByFamily[family];
965
- if (currentIndex === idx) {
966
- state.currentAccountIndexByFamily[family] = -1;
967
- }
968
- else if (currentIndex > idx) {
969
- state.currentAccountIndexByFamily[family] -= 1;
970
- }
971
- if (state.cursorByFamily[family] > idx) {
972
- state.cursorByFamily[family] -= 1;
973
- }
974
- state.cursorByFamily[family] %= this.accounts.length;
975
- }
976
- }
977
- for (const state of this.requestSessionStates.values()) {
978
- state.usedAccounts = new Set([...state.usedAccounts]
979
- .filter((accountIndex) => accountIndex !== idx)
980
- .map((accountIndex) => accountIndex > idx ? accountIndex - 1 : accountIndex));
981
1095
  }
982
- return true;
1096
+ return this.selection.removeAccount(account);
983
1097
  }
984
1098
  updateFromAuth(account, auth) {
985
1099
  const parts = parseRefreshParts(auth.refresh);
1100
+ // Only the projects packed into the host's refresh string are recorded;
1101
+ // the refresh token itself reaches the repository only through
1102
+ // `refreshAccount`.
1103
+ const projectChange = {
1104
+ ...(parts.projectId !== undefined && parts.projectId !== account.projectId
1105
+ ? { projectId: parts.projectId }
1106
+ : {}),
1107
+ ...(parts.managedProjectId !== undefined &&
1108
+ parts.managedProjectId !== account.managedProjectId
1109
+ ? { managedProjectId: parts.managedProjectId }
1110
+ : {}),
1111
+ };
1112
+ if (Object.keys(projectChange).length > 0) {
1113
+ this.dispatch(account, 'project', (repository, ref) => repository.recordProject(ref, projectChange));
1114
+ }
986
1115
  // Preserve existing projectId/managedProjectId if not in the new parts
987
1116
  account.parts = {
988
1117
  ...parts,
@@ -1005,48 +1134,15 @@ export class AccountManager {
1005
1134
  };
1006
1135
  }
1007
1136
  getMinWaitTimeForFamily(family, model, headerStyle, strict) {
1008
- const available = this.accounts.filter((a) => {
1009
- clearExpiredRateLimits(a, this.now);
1010
- return (a.enabled !== false &&
1011
- (strict && headerStyle
1012
- ? !isRateLimitedForHeaderStyle(a, family, headerStyle, this.now, model)
1013
- : !isRateLimitedForFamily(a, family, this.now, model)));
1014
- });
1015
- if (available.length > 0) {
1016
- return 0;
1017
- }
1018
- const waitTimes = [];
1019
- for (const a of this.accounts) {
1020
- if (family === 'claude') {
1021
- const t = a.rateLimitResetTimes.claude;
1022
- if (t !== undefined)
1023
- waitTimes.push(Math.max(0, t - this.now()));
1024
- }
1025
- else if (strict && headerStyle) {
1026
- const key = getQuotaKey(family, headerStyle, model);
1027
- const t = a.rateLimitResetTimes[key];
1028
- if (t !== undefined)
1029
- waitTimes.push(Math.max(0, t - this.now()));
1030
- }
1031
- else {
1032
- // For Gemini, account becomes available when EITHER pool expires for this model/family
1033
- const antigravityKey = getQuotaKey(family, 'antigravity', model);
1034
- const cliKey = getQuotaKey(family, 'gemini-cli', model);
1035
- const t1 = a.rateLimitResetTimes[antigravityKey];
1036
- const t2 = a.rateLimitResetTimes[cliKey];
1037
- const accountWait = Math.min(t1 !== undefined ? Math.max(0, t1 - this.now()) : Infinity, t2 !== undefined ? Math.max(0, t2 - this.now()) : Infinity);
1038
- if (accountWait !== Infinity)
1039
- waitTimes.push(accountWait);
1040
- }
1041
- }
1042
- return waitTimes.length > 0 ? Math.min(...waitTimes) : 0;
1137
+ return this.selection.getMinWaitTimeForFamily(family, model, headerStyle, strict);
1043
1138
  }
1044
1139
  getAccounts() {
1045
- return [...this.accounts];
1140
+ return this.selection.getAccounts();
1046
1141
  }
1047
1142
  buildStorageSnapshot() {
1048
- const claudeIndex = Math.max(0, this.currentAccountIndexByFamily.claude);
1049
- const geminiIndex = Math.max(0, this.currentAccountIndexByFamily.gemini);
1143
+ const active = this.selection.getActiveIndexByFamily();
1144
+ const claudeIndex = Math.max(0, active.claude);
1145
+ const geminiIndex = Math.max(0, active.gemini);
1050
1146
  return {
1051
1147
  version: 4,
1052
1148
  accounts: this.accounts.map((a) => ({
@@ -1095,7 +1191,17 @@ export class AccountManager {
1095
1191
  };
1096
1192
  }
1097
1193
  async saveToDisk() {
1098
- await this.store.saveMerged(this.storagePath, this.buildStorageSnapshot());
1194
+ if (this.repository !== undefined) {
1195
+ await this.flushRepositoryWrites();
1196
+ return;
1197
+ }
1198
+ await this.requireStore().saveMerged(this.storagePath, this.buildStorageSnapshot());
1199
+ }
1200
+ requireStore() {
1201
+ if (this.store === undefined) {
1202
+ throw new Error('this AccountManager persists through its repository');
1203
+ }
1204
+ return this.store;
1099
1205
  }
1100
1206
  /**
1101
1207
  * Persist via full-file replace (no merge). Required after destructive
@@ -1103,8 +1209,14 @@ export class AccountManager {
1103
1209
  * mergeAccountStorage re-reading it from disk.
1104
1210
  */
1105
1211
  async saveToDiskReplace() {
1212
+ // A repository-backed manager removes rows by attributed `remove`
1213
+ // writes, so nothing can bring a removed row back; draining suffices.
1214
+ if (this.repository !== undefined) {
1215
+ await this.flushRepositoryWrites();
1216
+ return;
1217
+ }
1106
1218
  const snapshot = this.buildStorageSnapshot();
1107
- await this.store.mutate(this.storagePath, () => snapshot);
1219
+ await this.requireStore().mutate(this.storagePath, () => snapshot);
1108
1220
  }
1109
1221
  requestSaveToDisk() {
1110
1222
  if (this.disposed || this.savePending) {
@@ -1118,6 +1230,12 @@ export class AccountManager {
1118
1230
  }, 1000);
1119
1231
  }
1120
1232
  async flushSaveToDisk() {
1233
+ // Writes such as request counts start immediately rather than waiting
1234
+ // for a scheduled save, so a repository-backed flush always drains.
1235
+ if (this.repository !== undefined) {
1236
+ await this.drainRepository();
1237
+ return;
1238
+ }
1121
1239
  if (!this.savePending) {
1122
1240
  await this.saveInFlight;
1123
1241
  return;
@@ -1134,16 +1252,53 @@ export class AccountManager {
1134
1252
  clearTimeout(this.saveTimeout);
1135
1253
  this.saveTimeout = null;
1136
1254
  }
1255
+ if (this.repository !== undefined) {
1256
+ await this.drainRepository();
1257
+ return;
1258
+ }
1137
1259
  if (this.savePending) {
1138
1260
  await this.executeSave();
1139
1261
  }
1140
1262
  await this.saveInFlight;
1141
1263
  }
1264
+ /**
1265
+ * Cancels a scheduled save and drains every repository write now. A
1266
+ * failure rejects every waiting flush and is thrown to the caller.
1267
+ */
1268
+ async drainRepository() {
1269
+ if (this.saveTimeout) {
1270
+ clearTimeout(this.saveTimeout);
1271
+ this.saveTimeout = null;
1272
+ }
1273
+ this.savePending = false;
1274
+ const resolvers = this.savePromiseResolvers;
1275
+ this.savePromiseResolvers = [];
1276
+ await this.saveInFlight;
1277
+ await this.settleRepositoryDrain(resolvers);
1278
+ }
1279
+ async settleRepositoryDrain(resolvers) {
1280
+ try {
1281
+ await this.flushRepositoryWrites();
1282
+ }
1283
+ catch (error) {
1284
+ for (const { reject } of resolvers)
1285
+ reject(error);
1286
+ throw error;
1287
+ }
1288
+ for (const { resolve } of resolvers)
1289
+ resolve();
1290
+ }
1142
1291
  async executeSave() {
1143
1292
  this.savePending = false;
1144
1293
  this.saveTimeout = null;
1145
1294
  const resolvers = this.savePromiseResolvers;
1146
1295
  this.savePromiseResolvers = [];
1296
+ if (this.repository !== undefined) {
1297
+ // A scheduled save has no caller to throw to: its failures reach
1298
+ // waiting flushes and the diagnostics.
1299
+ await this.settleRepositoryDrain(resolvers).catch(() => { });
1300
+ return;
1301
+ }
1147
1302
  try {
1148
1303
  await this.saveToDisk();
1149
1304
  for (const { resolve } of resolvers) {
@@ -1175,30 +1330,7 @@ export class AccountManager {
1175
1330
  * @returns The new fingerprint, or null if account not found
1176
1331
  */
1177
1332
  regenerateAccountFingerprint(accountIndex) {
1178
- const account = this.accounts[accountIndex];
1179
- if (!account)
1180
- return null;
1181
- // Save current fingerprint to history if it exists
1182
- if (account.fingerprint) {
1183
- const historyEntry = {
1184
- fingerprint: account.fingerprint,
1185
- timestamp: this.now(),
1186
- reason: 'regenerated',
1187
- };
1188
- if (!account.fingerprintHistory) {
1189
- account.fingerprintHistory = [];
1190
- }
1191
- // Add to beginning of history (most recent first)
1192
- account.fingerprintHistory.unshift(historyEntry);
1193
- // Trim to max history size
1194
- if (account.fingerprintHistory.length > MAX_FINGERPRINT_HISTORY) {
1195
- account.fingerprintHistory = account.fingerprintHistory.slice(0, MAX_FINGERPRINT_HISTORY);
1196
- }
1197
- }
1198
- // Generate and assign new fingerprint
1199
- account.fingerprint = generateFingerprint();
1200
- this.requestSaveToDisk();
1201
- return account.fingerprint;
1333
+ return this.selection.regenerateAccountFingerprint(accountIndex);
1202
1334
  }
1203
1335
  /**
1204
1336
  * Restore a fingerprint from history for an account.
@@ -1207,32 +1339,7 @@ export class AccountManager {
1207
1339
  * @returns The restored fingerprint, or null if account/history not found
1208
1340
  */
1209
1341
  restoreAccountFingerprint(accountIndex, historyIndex) {
1210
- const account = this.accounts[accountIndex];
1211
- if (!account)
1212
- return null;
1213
- const history = account.fingerprintHistory;
1214
- if (!history || historyIndex < 0 || historyIndex >= history.length) {
1215
- return null;
1216
- }
1217
- // Capture the fingerprint to restore BEFORE modifying history
1218
- const fingerprintToRestore = history[historyIndex].fingerprint;
1219
- // Save current fingerprint to history before restoring (if it exists)
1220
- if (account.fingerprint) {
1221
- const historyEntry = {
1222
- fingerprint: account.fingerprint,
1223
- timestamp: this.now(),
1224
- reason: 'restored',
1225
- };
1226
- account.fingerprintHistory.unshift(historyEntry);
1227
- // Trim to max history size
1228
- if (account.fingerprintHistory.length > MAX_FINGERPRINT_HISTORY) {
1229
- account.fingerprintHistory = account.fingerprintHistory.slice(0, MAX_FINGERPRINT_HISTORY);
1230
- }
1231
- }
1232
- // Restore the fingerprint
1233
- account.fingerprint = { ...fingerprintToRestore, createdAt: this.now() };
1234
- this.requestSaveToDisk();
1235
- return account.fingerprint;
1342
+ return this.selection.restoreAccountFingerprint(accountIndex, historyIndex);
1236
1343
  }
1237
1344
  /**
1238
1345
  * Get fingerprint history for an account.
@@ -1240,11 +1347,7 @@ export class AccountManager {
1240
1347
  * @returns Array of fingerprint versions, or empty array if not found
1241
1348
  */
1242
1349
  getAccountFingerprintHistory(accountIndex) {
1243
- const account = this.accounts[accountIndex];
1244
- if (!account?.fingerprintHistory) {
1245
- return [];
1246
- }
1247
- return [...account.fingerprintHistory];
1350
+ return this.selection.getAccountFingerprintHistory(accountIndex);
1248
1351
  }
1249
1352
  updateQuotaCache(accountIndex, quotaGroups, expectedRefreshToken) {
1250
1353
  const account = this.accounts[accountIndex];
@@ -1258,6 +1361,15 @@ export class AccountManager {
1258
1361
  // a different account after an index shift.
1259
1362
  account.cachedQuotaAccountId = quotaAccountIdentity(account.parts.refreshToken);
1260
1363
  account.cachedQuotaUpdatedAt = this.now();
1364
+ // A separate attributed write from the tier; the per-model readings the
1365
+ // store keeps are left as they are because this reading carries none.
1366
+ const reading = {
1367
+ schemaVersion: 1,
1368
+ cachedQuotaAccountId: account.cachedQuotaAccountId,
1369
+ cachedQuota: storedQuotaGroups(quotaGroups),
1370
+ cachedQuotaUpdatedAt: account.cachedQuotaUpdatedAt,
1371
+ };
1372
+ this.dispatch(account, 'quota reading', (repository, ref) => repository.recordQuota(ref, reading));
1261
1373
  }
1262
1374
  /**
1263
1375
  * Apply a subset of fields from a quota-fetch `updatedAccount` result onto
@@ -1292,148 +1404,107 @@ export class AccountManager {
1292
1404
  if (patch.capturedTierSchemaVersion !== undefined) {
1293
1405
  account.capturedTierSchemaVersion = patch.capturedTierSchemaVersion;
1294
1406
  }
1407
+ const tier = {};
1408
+ if (patch.capturedTierId !== undefined)
1409
+ tier.capturedTierId = patch.capturedTierId;
1410
+ if (patch.capturedPaidTierId !== undefined) {
1411
+ tier.capturedPaidTierId = patch.capturedPaidTierId;
1412
+ }
1413
+ if (patch.capturedTierAt !== undefined)
1414
+ tier.capturedTierAt = patch.capturedTierAt;
1415
+ if (patch.capturedTierSchemaVersion !== undefined) {
1416
+ tier.capturedTierSchemaVersion = patch.capturedTierSchemaVersion;
1417
+ }
1418
+ if (Object.keys(tier).length === 0)
1419
+ return;
1420
+ this.dispatch(account, 'tier', (repository, ref) => repository.updateMetadata(ref, (current, row) => {
1421
+ const base = current ?? {
1422
+ addedAt: row.storeAddedAt ?? this.now(),
1423
+ lastUsed: 0,
1424
+ };
1425
+ // A capture older than the stored one does not replace it.
1426
+ if (patch.capturedTierAt !== undefined &&
1427
+ typeof base.capturedTierAt === 'number' &&
1428
+ patch.capturedTierAt < base.capturedTierAt) {
1429
+ return { kind: 'keep' };
1430
+ }
1431
+ return { kind: 'set', metadata: { ...base, ...tier } };
1432
+ }));
1295
1433
  }
1296
1434
  /**
1297
1435
  * Record a successful API request for an account.
1298
1436
  * Tracks per model family with daily reset.
1299
1437
  */
1300
1438
  recordRequest(accountIndex, family) {
1301
- const account = this.accounts[accountIndex];
1302
- if (!account)
1303
- return;
1304
- const today = new Date(this.now()).toISOString().slice(0, 10);
1305
- if (!account.dailyRequestCounts ||
1306
- account.dailyRequestCounts.date !== today) {
1307
- account.dailyRequestCounts = { date: today, claude: 0, gemini: 0 };
1308
- }
1309
- account.dailyRequestCounts[family]++;
1310
- account.lastUsed = this.now();
1311
- // Also track for session
1312
- this.recordSessionRequest(accountIndex, family);
1439
+ this.selection.recordRequest(accountIndex, family);
1313
1440
  }
1314
1441
  /**
1315
1442
  * Get request counts for an account for today.
1316
1443
  */
1317
1444
  getDailyRequestCounts(accountIndex) {
1318
- const account = this.accounts[accountIndex];
1319
- if (!account?.dailyRequestCounts)
1320
- return null;
1321
- const today = new Date(this.now()).toISOString().slice(0, 10);
1322
- if (account.dailyRequestCounts.date !== today)
1323
- return null;
1324
- return { ...account.dailyRequestCounts };
1445
+ return this.selection.getDailyRequestCounts(accountIndex);
1325
1446
  }
1326
1447
  /**
1327
1448
  * Get total daily request counts across all accounts for a model family.
1328
1449
  */
1329
1450
  getTotalDailyRequests(family) {
1330
- const today = new Date(this.now()).toISOString().slice(0, 10);
1331
- let total = 0;
1332
- for (const account of this.accounts) {
1333
- if (account.dailyRequestCounts?.date === today) {
1334
- total += account.dailyRequestCounts[family];
1335
- }
1336
- }
1337
- return total;
1451
+ return this.selection.getTotalDailyRequests(family);
1338
1452
  }
1339
1453
  /**
1340
1454
  * Get a summary of daily request distribution across accounts.
1341
1455
  * Returns accounts sorted by request count (descending).
1342
1456
  */
1343
1457
  getDailyRequestSummary(family) {
1344
- const today = new Date(this.now()).toISOString().slice(0, 10);
1345
- const result = [];
1346
- for (const account of this.accounts) {
1347
- const count = account.dailyRequestCounts?.date === today
1348
- ? account.dailyRequestCounts[family]
1349
- : 0;
1350
- if (count > 0) {
1351
- result.push({ index: account.index, email: account.email, count });
1352
- }
1353
- }
1354
- return result.sort((a, b) => b.count - a.count);
1458
+ return this.selection.getDailyRequestSummary(family);
1355
1459
  }
1356
1460
  /**
1357
1461
  * Record a request for the current session (in-memory only).
1358
1462
  */
1359
1463
  recordSessionRequest(accountIndex, family) {
1360
- const key = String(accountIndex);
1361
- const current = this.sessionRequestCounts.get(key) ?? {
1362
- claude: 0,
1363
- gemini: 0,
1364
- };
1365
- current[family]++;
1366
- this.sessionRequestCounts.set(key, current);
1464
+ this.selection.recordSessionRequest(accountIndex, family);
1367
1465
  }
1368
1466
  /**
1369
1467
  * Get a summary of the current session's request usage.
1370
1468
  */
1371
1469
  getSessionSummary() {
1372
- const durationMs = this.now() - this.sessionStartTime;
1373
- const durationMinutes = Math.round(durationMs / 60000);
1374
- const durationHours = durationMs / 3600000;
1375
- let totalClaude = 0;
1376
- let totalGemini = 0;
1377
- const perAccount = [];
1378
- for (const [key, counts] of this.sessionRequestCounts) {
1379
- const idx = Number(key);
1380
- const account = this.accounts[idx];
1381
- totalClaude += counts.claude;
1382
- totalGemini += counts.gemini;
1383
- if (counts.claude > 0 || counts.gemini > 0) {
1384
- perAccount.push({
1385
- index: idx,
1386
- email: account?.email,
1387
- claude: counts.claude,
1388
- gemini: counts.gemini,
1389
- });
1390
- }
1391
- }
1392
- const totalRequests = totalClaude + totalGemini;
1393
- const requestsPerHour = durationHours > 0 ? Math.round(totalRequests / durationHours) : 0;
1394
- return {
1395
- durationMinutes,
1396
- totalClaude,
1397
- totalGemini,
1398
- requestsPerHour,
1399
- accountsUsed: perAccount.length,
1400
- perAccount: perAccount.sort((a, b) => b.claude + b.gemini - (a.claude + a.gemini)),
1401
- };
1470
+ return this.selection.getSessionSummary();
1402
1471
  }
1403
1472
  isAccountOverSoftQuota(account, family, thresholdPercent, cacheTtlMs, model) {
1404
- return isOverSoftQuotaThreshold(account, family, this.getEffectiveSoftQuotaThreshold(thresholdPercent), cacheTtlMs, this.now, model);
1473
+ return this.selection.isAccountOverSoftQuota(account, family, thresholdPercent, cacheTtlMs, model);
1405
1474
  }
1475
+ /**
1476
+ * The accounts a quota check should cover. For a repository-backed
1477
+ * manager each target carries the ref its account is loaded under,
1478
+ * captured now, before any quota work starts; an account without a ref
1479
+ * has no row to attribute a reading to and is left out rather than
1480
+ * matched by position, email or token.
1481
+ */
1406
1482
  getAccountsForQuotaCheck() {
1407
- return this.accounts.map((a) => ({
1408
- email: a.email,
1409
- refreshToken: a.parts.refreshToken,
1410
- projectId: a.parts.projectId ?? a.projectId,
1411
- managedProjectId: a.parts.managedProjectId ?? a.managedProjectId,
1412
- addedAt: a.addedAt,
1413
- lastUsed: a.lastUsed,
1414
- enabled: a.enabled,
1415
- }));
1483
+ const targets = [];
1484
+ for (const a of this.accounts) {
1485
+ const target = {
1486
+ email: a.email,
1487
+ refreshToken: a.parts.refreshToken,
1488
+ projectId: a.parts.projectId ?? a.projectId,
1489
+ managedProjectId: a.parts.managedProjectId ?? a.managedProjectId,
1490
+ addedAt: a.addedAt,
1491
+ lastUsed: a.lastUsed,
1492
+ enabled: a.enabled,
1493
+ };
1494
+ if (this.repository === undefined) {
1495
+ targets.push(target);
1496
+ }
1497
+ else if (a.ref !== undefined) {
1498
+ targets.push({ ...target, rowRef: a.ref });
1499
+ }
1500
+ }
1501
+ return targets;
1416
1502
  }
1417
1503
  getOldestQuotaCacheAge() {
1418
- let oldest = null;
1419
- for (const acc of this.accounts) {
1420
- if (acc.enabled === false)
1421
- continue;
1422
- if (acc.cachedQuotaUpdatedAt == null)
1423
- return null;
1424
- const age = this.now() - acc.cachedQuotaUpdatedAt;
1425
- if (oldest === null || age > oldest)
1426
- oldest = age;
1427
- }
1428
- return oldest;
1504
+ return this.selection.getOldestQuotaCacheAge();
1429
1505
  }
1430
1506
  areAllAccountsOverSoftQuota(family, thresholdPercent, cacheTtlMs, model) {
1431
- if (thresholdPercent >= 100)
1432
- return false;
1433
- const enabled = this.accounts.filter((a) => a.enabled !== false);
1434
- if (enabled.length <= 1)
1435
- return false;
1436
- return enabled.every((a) => isOverSoftQuotaThreshold(a, family, thresholdPercent, cacheTtlMs, this.now, model));
1507
+ return this.selection.areAllAccountsOverSoftQuota(family, thresholdPercent, cacheTtlMs, model);
1437
1508
  }
1438
1509
  /**
1439
1510
  * Get minimum wait time until any account's soft quota resets.
@@ -1442,39 +1513,7 @@ export class AccountManager {
1442
1513
  * Returns null if no resetTime data is available.
1443
1514
  */
1444
1515
  getMinWaitTimeForSoftQuota(family, thresholdPercent, cacheTtlMs, model) {
1445
- if (thresholdPercent >= 100)
1446
- return 0;
1447
- const enabled = this.accounts.filter((a) => a.enabled !== false);
1448
- if (enabled.length === 0)
1449
- return null;
1450
- if (enabled.length === 1)
1451
- return 0;
1452
- // If any account is available (not over threshold), no wait needed
1453
- const available = enabled.filter((a) => !isOverSoftQuotaThreshold(a, family, thresholdPercent, cacheTtlMs, this.now, model));
1454
- if (available.length > 0)
1455
- return 0;
1456
- // All accounts are over threshold - find earliest reset time
1457
- // For gemini family, we MUST have the model to distinguish pro vs flash quotas.
1458
- // Fail-open (return null = no wait info) if model is missing to avoid blocking on wrong quota.
1459
- if (!model && family !== 'claude')
1460
- return null;
1461
- const quotaGroup = resolveQuotaGroup(family, model);
1462
- const now = this.now();
1463
- const waitTimes = [];
1464
- for (const acc of enabled) {
1465
- const groupData = acc.cachedQuota?.[quotaGroup];
1466
- if (groupData?.resetTime) {
1467
- const resetTimestamp = Date.parse(groupData.resetTime);
1468
- if (Number.isFinite(resetTimestamp)) {
1469
- waitTimes.push(Math.max(0, resetTimestamp - now));
1470
- }
1471
- }
1472
- }
1473
- if (waitTimes.length === 0)
1474
- return null;
1475
- const minWait = Math.min(...waitTimes);
1476
- // Treat 0 as stale cache (resetTime in the past) → fail-open to avoid spin loop
1477
- return minWait === 0 ? null : minWait;
1516
+ return this.selection.getMinWaitTimeForSoftQuota(family, thresholdPercent, cacheTtlMs, model);
1478
1517
  }
1479
1518
  }
1480
1519
  //# sourceMappingURL=account-manager.js.map