@karpeleslab/teamclaude 1.0.6 → 1.0.8

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.
@@ -1,4 +1,14 @@
1
1
  import { refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
2
+ import { sameIdentity } from './identity.js';
3
+
4
+ // Quota fields that survive a restart: utilization levels and their reset
5
+ // windows, learned passively from upstream responses. Transient/derived state
6
+ // (probing, requalify, rateLimitedUntil) is intentionally excluded.
7
+ const PERSISTED_QUOTA_FIELDS = [
8
+ 'unified5h', 'unified7d', 'unified7dSonnet',
9
+ 'unified5hReset', 'unified7dReset', 'unified7dSonnetReset', 'unifiedStatus',
10
+ 'tokensLimit', 'tokensRemaining', 'requestsLimit', 'requestsRemaining', 'resetsAt',
11
+ ];
2
12
 
3
13
  function emptyQuota() {
4
14
  return {
@@ -8,26 +18,38 @@ function emptyQuota() {
8
18
  requestsLimit: null,
9
19
  requestsRemaining: null,
10
20
  // Unified rate limits (Claude Max accounts)
11
- unified5h: null, // utilization 0-1
12
- unified7d: null, // utilization 0-1
13
- unified5hReset: null, // ms timestamp
14
- unified7dReset: null, // ms timestamp
15
- unifiedStatus: null, // allowed | allowed_warning | rejected
21
+ unified5h: null, // utilization 0-1
22
+ unified7d: null, // utilization 0-1
23
+ unified7dSonnet: null, // utilization 0-1 (Sonnet-specific weekly bucket)
24
+ unified5hReset: null, // ms timestamp
25
+ unified7dReset: null, // ms timestamp
26
+ unified7dSonnetReset: null, // ms timestamp
27
+ unifiedStatus: null, // allowed | allowed_warning | rejected
16
28
  resetsAt: null,
17
29
  };
18
30
  }
19
31
 
20
32
  export class AccountManager {
21
- constructor(accounts, switchThreshold = 0.98) {
33
+ constructor(accounts, switchThreshold = 0.98, { refreshFn = refreshAccessToken } = {}) {
34
+ // Injectable for tests (mirrors Prober's probeFn); defaults to the real
35
+ // OAuth token refresh.
36
+ this._refreshFn = refreshFn;
22
37
  this.accounts = accounts.map((acct, index) => ({
23
38
  index,
24
39
  name: acct.name,
25
40
  type: acct.type,
26
41
  accountUuid: acct.accountUuid || null,
42
+ orgUuid: acct.orgUuid || null,
43
+ orgName: acct.orgName || null,
44
+ priority: acct.priority || 0,
45
+ disabled: acct.disabled || false,
27
46
  credential: acct.accessToken || acct.apiKey,
28
47
  refreshToken: acct.refreshToken || null,
29
48
  expiresAt: acct.expiresAt || null,
30
49
  status: 'active',
50
+ // No quota is known at startup, so start probing: the first response for
51
+ // an account reveals its weekly limit and triggers re-evaluation.
52
+ probing: true,
31
53
  quota: emptyQuota(),
32
54
  usage: {
33
55
  totalInputTokens: 0,
@@ -39,23 +61,122 @@ export class AccountManager {
39
61
  }));
40
62
  this.currentIndex = 0;
41
63
  this.switchThreshold = switchThreshold;
64
+ // When every account reads as over-quota we would otherwise refuse locally
65
+ // forever (a stale cached utilization is never re-validated because no
66
+ // request is ever sent). Instead, allow one real upstream probe at most this
67
+ // often to refresh the cached quota. See _selectProbe.
68
+ this.probeIntervalMs = 60_000;
69
+ this._nextProbeAt = 0;
42
70
  }
43
71
 
44
72
  /**
45
73
  * Get the best available account, rotating if the current one is near quota.
46
74
  * Returns null if all accounts are exhausted.
47
75
  */
48
- getActiveAccount() {
76
+ getActiveAccount(exclude = null) {
77
+ // Clear expired quotas across all accounts and switch proactively if a
78
+ // session reset made a sooner-expiring account the better choice. This runs
79
+ // on every request so the behaviour holds without the TUI render loop.
80
+ this.refreshExpiredQuotas();
49
81
  const current = this.accounts[this.currentIndex];
50
- if (this._isAvailable(current)) {
51
- return current;
82
+ // `exclude` is a per-request set of indices already tried this request (e.g.
83
+ // an account that just threw a transport error). It is never a persistent
84
+ // status change — the account stays healthy for the next request.
85
+ // We just learned a probed account's weekly quota — re-evaluate which
86
+ // account is best now that its limit is known.
87
+ if (current && current.requalify) {
88
+ current.requalify = false;
89
+ const next = this._selectNext(exclude);
90
+ if (next) return next;
91
+ }
92
+ if (this._isAvailable(current) && !exclude?.has(current.index)) {
93
+ // A strictly higher-priority (lower value) available account preempts a
94
+ // healthy current one. Within the same priority tier we stay put, so the
95
+ // common case (all accounts at the default priority 0) is unchanged and
96
+ // never thrashes — preemption only triggers when priorities differ.
97
+ const betterExists = this.accounts.some(a =>
98
+ this._isAvailable(a) && !exclude?.has(a.index) && (a.priority || 0) < (current.priority || 0));
99
+ return betterExists ? this._selectNext(exclude) : current;
100
+ }
101
+ const next = this._selectNext(exclude);
102
+ if (next) return next;
103
+ // No account is under the switch threshold. Before refusing locally, allow a
104
+ // throttled probe so a stale/poisoned cached quota can't pin us in a
105
+ // permanent "all exhausted" state — the probe's real response refreshes the
106
+ // quota (or upstream's own 429 converts soft exhaustion into a hard
107
+ // rate-limit hold). null here means the caller emits the synthetic 429.
108
+ return this._selectProbe(exclude);
109
+ }
110
+
111
+ _isProbeable(account) {
112
+ if (!account) return false;
113
+ // Never probe an account the operator has taken out of rotation, one whose
114
+ // token is broken, or one upstream has explicitly rate-limited — those are
115
+ // hard states, not stale soft-quota guesses.
116
+ if (account.disabled) return false;
117
+ if (account.status === 'error' || account.status === 'exhausted') return false;
118
+ if (account.status === 'throttled' && account.rateLimitedUntil
119
+ && Date.now() < account.rateLimitedUntil) return false;
120
+ return true;
121
+ }
122
+
123
+ /** Highest utilization across all known quota dimensions (0-1), used to pick
124
+ * the least-exhausted probe target. Mirrors the ratios in _isNearQuota. */
125
+ _maxUtilization(account) {
126
+ const q = account.quota;
127
+ let max = 0;
128
+ if (q.unified5h != null) max = Math.max(max, q.unified5h);
129
+ if (q.unified7d != null) max = Math.max(max, q.unified7d);
130
+ if (q.tokensLimit != null && q.tokensRemaining != null) {
131
+ max = Math.max(max, 1 - q.tokensRemaining / q.tokensLimit);
52
132
  }
53
- return this._selectNext();
133
+ if (q.requestsLimit != null && q.requestsRemaining != null) {
134
+ max = Math.max(max, 1 - q.requestsRemaining / q.requestsLimit);
135
+ }
136
+ return max;
137
+ }
138
+
139
+ /**
140
+ * Pick an account to send a single revalidation probe upstream when every
141
+ * account reads as over the switch threshold. Throttled to one probe per
142
+ * probeIntervalMs so a genuinely-exhausted fleet isn't hammered — between
143
+ * probes this returns null and the caller falls back to the synthetic 429.
144
+ * The chosen account is the least-utilized probeable one (most likely to have
145
+ * stale headroom), so the refreshed quota corrects the cache fastest.
146
+ */
147
+ _selectProbe(exclude = null) {
148
+ const now = Date.now();
149
+ if (now < this._nextProbeAt) return null;
150
+
151
+ let best = null;
152
+ let bestPriority = Infinity;
153
+ let bestUsage = Infinity;
154
+ for (const account of this.accounts) {
155
+ if (exclude?.has(account.index)) continue;
156
+ if (!this._isProbeable(account)) continue;
157
+ const priority = account.priority || 0;
158
+ const usage = this._maxUtilization(account);
159
+ if (priority < bestPriority ||
160
+ (priority === bestPriority && usage < bestUsage)) {
161
+ bestPriority = priority;
162
+ bestUsage = usage;
163
+ best = account;
164
+ }
165
+ }
166
+ if (!best) return null;
167
+
168
+ this._nextProbeAt = now + this.probeIntervalMs;
169
+ this.currentIndex = best.index;
170
+ console.log(`[TeamClaude] All accounts over threshold — probing "${best.name}" to refresh quota`);
171
+ return best;
54
172
  }
55
173
 
56
174
  _isAvailable(account) {
57
175
  if (!account) return false;
58
176
 
177
+ // Manually disabled accounts are skipped entirely until re-enabled.
178
+ if (account.disabled) return false;
179
+
59
180
  // Check rate limit expiry
60
181
  if (account.status === 'throttled' && account.rateLimitedUntil) {
61
182
  if (Date.now() < account.rateLimitedUntil) return false;
@@ -70,21 +191,38 @@ export class AccountManager {
70
191
  return true;
71
192
  }
72
193
 
73
- _isNearQuota(account) {
194
+ /**
195
+ * Clear any quota counters whose reset time has passed. Cheap and safe to
196
+ * call frequently (e.g. from the TUI render loop) — once a counter is cleared
197
+ * it stays null until the next upstream response repopulates it, so the
198
+ * "reset" log fires at most once per window.
199
+ * @returns {{changed: boolean, session: boolean}} what was cleared.
200
+ */
201
+ _clearExpiredQuotas(account) {
74
202
  const q = account.quota;
75
203
  const now = Date.now();
204
+ let changed = false;
205
+ let session = false;
76
206
 
77
207
  // Clear expired unified quotas
78
208
  if (q.unified5h != null && q.unified5hReset && now >= q.unified5hReset) {
79
209
  console.log(`[TeamClaude] Account "${account.name}" session quota reset`);
80
210
  q.unified5h = null;
81
211
  q.unified5hReset = null;
212
+ changed = true;
213
+ session = true;
82
214
  }
83
215
  if (q.unified7d != null && q.unified7dReset && now >= q.unified7dReset) {
84
216
  console.log(`[TeamClaude] Account "${account.name}" weekly quota reset`);
85
217
  q.unified7d = null;
86
218
  q.unified7dReset = null;
87
219
  q.unifiedStatus = null;
220
+ changed = true;
221
+ }
222
+ if (q.unified7dSonnet != null && q.unified7dSonnetReset && now >= q.unified7dSonnetReset) {
223
+ q.unified7dSonnet = null;
224
+ q.unified7dSonnetReset = null;
225
+ changed = true;
88
226
  }
89
227
 
90
228
  // Clear expired standard quotas
@@ -94,8 +232,70 @@ export class AccountManager {
94
232
  q.requestsRemaining = null;
95
233
  q.requestsLimit = null;
96
234
  q.resetsAt = null;
235
+ changed = true;
97
236
  }
98
237
 
238
+ return { changed, session };
239
+ }
240
+
241
+ /**
242
+ * Clear expired quotas across all accounts. Called from the display loop and
243
+ * the request path so a window expiry (e.g. the 5-hour session quota) resets
244
+ * the view instantly rather than waiting for the next request.
245
+ *
246
+ * When an account's session quota resets, it may have become the better
247
+ * choice — switch to it if its weekly limit expires sooner than the current
248
+ * account's (and it still has weekly quota), so we spend the quota closest to
249
+ * refreshing first.
250
+ */
251
+ refreshExpiredQuotas() {
252
+ let changed = false;
253
+ const sessionReset = [];
254
+ for (const account of this.accounts) {
255
+ const r = this._clearExpiredQuotas(account);
256
+ if (r.changed) changed = true;
257
+ if (r.session) sessionReset.push(account);
258
+ }
259
+ if (sessionReset.length) this._switchOnSessionReset(sessionReset);
260
+ return changed;
261
+ }
262
+
263
+ /**
264
+ * Given accounts whose session quota just reset, switch to the one whose
265
+ * weekly limit expires soonest — but only if that is sooner than the current
266
+ * account's weekly limit and the account still has weekly quota to spend.
267
+ */
268
+ _switchOnSessionReset(candidates) {
269
+ const current = this.accounts[this.currentIndex];
270
+ // Need a known weekly reset on the current account to compare against;
271
+ // if it is unknown we are still probing it, so leave it alone.
272
+ if (!current || current.quota.unified7dReset == null) return;
273
+
274
+ let best = null;
275
+ let bestWeekly = current.quota.unified7dReset;
276
+ for (const acc of candidates) {
277
+ if (acc.index === this.currentIndex) continue;
278
+ if (!this._isAvailable(acc)) continue; // enough session & weekly quota left
279
+ // Don't demote to a lower-priority (higher value) account on a reset.
280
+ if ((acc.priority || 0) > (current.priority || 0)) continue;
281
+ const weekly = acc.quota.unified7dReset;
282
+ if (weekly == null) continue; // need a known weekly to compare
283
+ if (weekly < bestWeekly) {
284
+ bestWeekly = weekly;
285
+ best = acc;
286
+ }
287
+ }
288
+
289
+ if (best) {
290
+ this.currentIndex = best.index;
291
+ console.log(`[TeamClaude] Account "${best.name}" session quota reset and weekly expires sooner — switching to it`);
292
+ }
293
+ }
294
+
295
+ _isNearQuota(account) {
296
+ const q = account.quota;
297
+ this._clearExpiredQuotas(account);
298
+
99
299
  // Unified quotas (Claude Max) — utilization is already 0-1
100
300
  if (q.unified5h != null && q.unified5h >= this.switchThreshold) return true;
101
301
  if (q.unified7d != null && q.unified7d >= this.switchThreshold) return true;
@@ -114,18 +314,75 @@ export class AccountManager {
114
314
  return false;
115
315
  }
116
316
 
117
- _selectNext() {
118
- const startIndex = this.currentIndex;
317
+ /**
318
+ * Pick the best available account by selection order, WITHOUT mutating state:
319
+ * 1. lowest `priority` value (operator-controlled; default 0, lower = preferred)
320
+ * 2. then the account with no known weekly limit — using it lets us
321
+ * discover its quota
322
+ * 3. then the account whose weekly limit expires soonest: that quota is
323
+ * closest to refreshing, so spending it first preserves accounts whose
324
+ * weekly window resets further out.
325
+ * With all priorities at the default 0, this reduces to the weekly-reset
326
+ * heuristic. Returns the account or null if none are available.
327
+ */
328
+ _pickBestAvailable(exclude = null) {
329
+ let best = null;
330
+ let bestPriority = Infinity;
331
+ let bestReset = Infinity;
332
+
333
+ for (let i = 0; i < this.accounts.length; i++) {
334
+ const account = this.accounts[i];
335
+ if (exclude?.has(account.index)) continue;
336
+ // _isAvailable filters out accounts at/above the switch threshold, so the
337
+ // soonest-expiring pick only ever lands on an account whose 5-hour quota
338
+ // is still below 98%.
339
+ if (!this._isAvailable(account)) continue;
340
+
341
+ const priority = account.priority || 0;
342
+ // Unknown weekly reset sorts first so we fill it in.
343
+ const weeklyReset = account.quota.unified7dReset || -Infinity;
344
+ if (priority < bestPriority ||
345
+ (priority === bestPriority && weeklyReset < bestReset)) {
346
+ bestPriority = priority;
347
+ bestReset = weeklyReset;
348
+ best = account;
349
+ }
350
+ }
351
+ return best;
352
+ }
119
353
 
120
- for (let i = 1; i <= this.accounts.length; i++) {
121
- const idx = (startIndex + i) % this.accounts.length;
122
- const account = this.accounts[idx];
354
+ /**
355
+ * Select the active account up front (e.g. on daemon launch, once persisted
356
+ * quota has been restored) so we start on the highest-priority / soonest-
357
+ * resetting account instead of blindly on index 0. Mirrors rotation order.
358
+ * Returns the chosen account, or the existing current one if none are
359
+ * available (the server still starts; requests 429 until a window resets).
360
+ */
361
+ selectActiveAccount() {
362
+ this.refreshExpiredQuotas(); // drop any restored windows that already expired
363
+ const best = this._pickBestAvailable();
364
+ if (!best) return this.accounts[this.currentIndex] || null;
365
+ this.currentIndex = best.index;
366
+ best.probing = best.quota.unified7dReset == null;
367
+ const wk = best.quota.unified7d != null
368
+ ? `${(best.quota.unified7d * 100).toFixed(1)}% weekly used`
369
+ : 'weekly quota unknown';
370
+ console.log(`[TeamClaude] Starting on account "${best.name}" (priority ${best.priority || 0}, ${wk})`);
371
+ return best;
372
+ }
123
373
 
124
- if (this._isAvailable(account)) {
125
- this.currentIndex = idx;
126
- console.log(`[TeamClaude] Switched to account "${account.name}"`);
127
- return account;
374
+ _selectNext(exclude = null) {
375
+ const best = this._pickBestAvailable(exclude);
376
+ if (best) {
377
+ const switched = best.index !== this.currentIndex;
378
+ this.currentIndex = best.index;
379
+ // If we switched to an account whose weekly quota is still unknown, flag
380
+ // it so we re-evaluate once that quota is learned (see updateQuota).
381
+ best.probing = best.quota.unified7dReset == null;
382
+ if (switched) {
383
+ console.log(`[TeamClaude] Switched to account "${best.name}"`);
128
384
  }
385
+ return best;
129
386
  }
130
387
 
131
388
  // All accounts unavailable — find the one that resets soonest
@@ -133,6 +390,7 @@ export class AccountManager {
133
390
  let soonestTime = Infinity;
134
391
 
135
392
  for (const account of this.accounts) {
393
+ if (exclude?.has(account.index)) continue;
136
394
  const resetTime = account.rateLimitedUntil
137
395
  || account.quota.unified5hReset
138
396
  || account.quota.unified7dReset
@@ -173,6 +431,14 @@ export class AccountManager {
173
431
  if (r5h) account.quota.unified5hReset = parseInt(r5h, 10) * 1000;
174
432
  if (r7d) account.quota.unified7dReset = parseInt(r7d, 10) * 1000;
175
433
 
434
+ // We switched to this account to discover its weekly quota; now that we
435
+ // know it, flag for re-evaluation so selection can pick the best account.
436
+ if (account.probing && account.quota.unified7dReset != null) {
437
+ account.probing = false;
438
+ account.requalify = true;
439
+ console.log(`[TeamClaude] Learned weekly quota for "${account.name}", re-evaluating selection`);
440
+ }
441
+
176
442
  const uStatus = headers['anthropic-ratelimit-unified-status'];
177
443
  if (uStatus) account.quota.unifiedStatus = uStatus;
178
444
 
@@ -216,6 +482,53 @@ export class AccountManager {
216
482
  if (outputTokens) account.usage.totalOutputTokens += outputTokens;
217
483
  }
218
484
 
485
+ /**
486
+ * Enable or disable an account. A disabled account is skipped by rotation
487
+ * until re-enabled. Re-enabling also clears a stuck 'error' state (and any
488
+ * lingering rate-limit hold) so the account is retried immediately.
489
+ */
490
+ setDisabled(accountIndex, disabled) {
491
+ const account = this.accounts[accountIndex];
492
+ if (!account) return;
493
+ account.disabled = disabled;
494
+ if (!disabled && account.status === 'error') {
495
+ account.status = 'active';
496
+ account.rateLimitedUntil = null;
497
+ console.log(`[TeamClaude] Account "${account.name}" re-enabled — clearing error state`);
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Apply quota learned from the OAuth usage endpoint (the background probe).
503
+ * Updates utilization/reset for the 5h, 7d, and Sonnet-7d buckets WITHOUT
504
+ * touching usage counters — a probe is not real client traffic.
505
+ */
506
+ applyUsageData(accountIndex, usage) {
507
+ const account = this.accounts[accountIndex];
508
+ if (!account || !usage) return;
509
+ const q = account.quota;
510
+
511
+ if (usage.fiveHour) {
512
+ if (usage.fiveHour.utilization != null) q.unified5h = usage.fiveHour.utilization;
513
+ if (usage.fiveHour.resetAt != null) q.unified5hReset = usage.fiveHour.resetAt;
514
+ }
515
+ if (usage.sevenDay) {
516
+ if (usage.sevenDay.utilization != null) q.unified7d = usage.sevenDay.utilization;
517
+ if (usage.sevenDay.resetAt != null) q.unified7dReset = usage.sevenDay.resetAt;
518
+ }
519
+ if (usage.sevenDaySonnet) {
520
+ if (usage.sevenDaySonnet.utilization != null) q.unified7dSonnet = usage.sevenDaySonnet.utilization;
521
+ if (usage.sevenDaySonnet.resetAt != null) q.unified7dSonnetReset = usage.sevenDaySonnet.resetAt;
522
+ }
523
+
524
+ // If we just learned this account's weekly window while probing, re-evaluate
525
+ // selection (same path as learning it from a live response).
526
+ if (account.probing && q.unified7dReset != null) {
527
+ account.probing = false;
528
+ account.requalify = true;
529
+ }
530
+ }
531
+
219
532
  /**
220
533
  * Mark an account as rate-limited for a given duration.
221
534
  */
@@ -244,7 +557,7 @@ export class AccountManager {
244
557
  account._refreshPromise = (async () => {
245
558
  console.log(`[TeamClaude] Refreshing token for account "${account.name}"...`);
246
559
  try {
247
- const newTokens = await refreshAccessToken(account.refreshToken);
560
+ const newTokens = await this._refreshFn(account.refreshToken);
248
561
  account.credential = newTokens.accessToken;
249
562
  account.refreshToken = newTokens.refreshToken;
250
563
  account.expiresAt = newTokens.expiresAt;
@@ -252,10 +565,16 @@ export class AccountManager {
252
565
  this._onTokenRefresh?.(accountIndex, newTokens);
253
566
  } catch (err) {
254
567
  console.error(`[TeamClaude] Token refresh failed for "${account.name}": ${err.message}`);
255
- // Only mark as error if the access token is actually expired;
256
- // a failed proactive refresh shouldn't kill a still-valid token
257
- if (!account.expiresAt || Date.now() >= account.expiresAt) {
568
+ // Reserve 'error' (which drops the account from rotation until re-login)
569
+ // for a GENUINE auth rejection: the refresh token itself is no longer
570
+ // valid revoked, or invalidated by an account/plan migration. A
571
+ // transient failure (network, 5xx, timeout) must NOT sideline a healthy
572
+ // account: keep its current token and retry on the next request. This is
573
+ // what kept accounts wrongly "errored" after a momentary refresh blip.
574
+ const isAuthRejection = err.status === 400 || err.status === 401 || err.status === 403;
575
+ if (isAuthRejection) {
258
576
  account.status = 'error';
577
+ console.error(`[TeamClaude] Account "${account.name}" needs re-login (refresh token rejected) — run: teamclaude login`);
259
578
  }
260
579
  } finally {
261
580
  account._refreshPromise = null;
@@ -301,10 +620,16 @@ export class AccountManager {
301
620
  name: acctData.name,
302
621
  type: acctData.type,
303
622
  accountUuid: acctData.accountUuid || null,
623
+ orgUuid: acctData.orgUuid || null,
624
+ orgName: acctData.orgName || null,
625
+ priority: acctData.priority || 0,
626
+ disabled: acctData.disabled || false,
304
627
  credential: acctData.accessToken || acctData.apiKey,
305
628
  refreshToken: acctData.refreshToken || null,
306
629
  expiresAt: acctData.expiresAt || null,
307
630
  status: 'active',
631
+ // Unknown quota until the first response — probe it like startup accounts.
632
+ probing: true,
308
633
  quota: emptyQuota(),
309
634
  usage: { totalInputTokens: 0, totalOutputTokens: 0, totalRequests: 0, lastUsed: null },
310
635
  rateLimitedUntil: null,
@@ -326,6 +651,36 @@ export class AccountManager {
326
651
  }
327
652
  }
328
653
 
654
+ /**
655
+ * Serialize persistable quota state for all accounts (no credentials), keyed
656
+ * by account identity so it can be matched back after a restart.
657
+ */
658
+ exportQuotaState() {
659
+ return this.accounts.map(a => {
660
+ const quota = {};
661
+ for (const f of PERSISTED_QUOTA_FIELDS) quota[f] = a.quota[f];
662
+ return { accountUuid: a.accountUuid, orgUuid: a.orgUuid, orgName: a.orgName, name: a.name, quota };
663
+ });
664
+ }
665
+
666
+ /**
667
+ * Restore quota learned in a previous run. Matches saved entries to accounts
668
+ * by identity. Stale windows are not special-cased here — _clearExpiredQuotas
669
+ * wipes any restored window whose reset time has already passed on first use.
670
+ */
671
+ restoreQuotaState(saved) {
672
+ if (!Array.isArray(saved)) return;
673
+ for (const account of this.accounts) {
674
+ const match = saved.find(s => sameIdentity(s, account));
675
+ if (!match || !match.quota) continue;
676
+ for (const f of PERSISTED_QUOTA_FIELDS) {
677
+ if (match.quota[f] != null) account.quota[f] = match.quota[f];
678
+ }
679
+ // We already know this account's weekly window, so it isn't "probing".
680
+ if (account.quota.unified7dReset != null) account.probing = false;
681
+ }
682
+ }
683
+
329
684
  /**
330
685
  * Return a status summary of all accounts (safe to expose, no credentials).
331
686
  */
@@ -336,6 +691,9 @@ export class AccountManager {
336
691
  accounts: this.accounts.map(a => ({
337
692
  name: a.name,
338
693
  type: a.type,
694
+ orgName: a.orgName || null,
695
+ priority: a.priority || 0,
696
+ disabled: a.disabled || false,
339
697
  status: a.status,
340
698
  quota: { ...a.quota },
341
699
  usage: { ...a.usage },
@@ -0,0 +1,115 @@
1
+ // Rewrite the request body's account_uuid to match the account whose token we
2
+ // inject. Claude Code puts the logged-in account's UUID inside `metadata.user_id`
3
+ // (a stringified JSON) of /v1/messages; under rotation that would disagree with
4
+ // the injected token.
5
+ //
6
+ // This is a STREAMING, byte-exact JSON state machine — no regex, no whole-body
7
+ // buffering — so it handles arbitrarily large bodies fed in chunks. It tracks
8
+ // JSON structure (container stack, current key, in-string/escape) to find the
9
+ // `metadata.user_id` string value, and only inside that value does it look for
10
+ // the `account_uuid` field and overwrite its 36-char value with the new UUID
11
+ // (same length → no content-length/flow-control changes). A stray `account_uuid`
12
+ // elsewhere in the body (user content, tool results) is never touched.
13
+
14
+ // Byte sequence of `account_uuid":"` as it appears INSIDE the (escaped) user_id
15
+ // string: account_uuid \ " : \ "
16
+ const PREFIX = Buffer.from('account_uuid\\":\\"', 'latin1');
17
+
18
+ export class AccountUuidPatcher {
19
+ constructor(newUuid) {
20
+ this.newUuid = (typeof newUuid === 'string' && newUuid.length === 36) ? Buffer.from(newUuid, 'latin1') : null;
21
+ this.frames = []; // container stack: { container:'obj'|'arr', name, key, awaitingKey }
22
+ this.inStr = false;
23
+ this.esc = false;
24
+ this.readingKey = false;
25
+ this.keyBuf = [];
26
+ this.target = false; // inside the metadata.user_id string value
27
+ this.matchPos = 0; // PREFIX match progress (within target)
28
+ this.uuidRemaining = 0; // value bytes left to overwrite
29
+ this.done = false; // patched the one account_uuid already
30
+ this.changed = false;
31
+ }
32
+
33
+ /** Feed a chunk; returns a same-length chunk (patched in place). */
34
+ push(chunk) {
35
+ if (!this.newUuid || this.done) return chunk;
36
+ const out = Buffer.from(chunk);
37
+ for (let i = 0; i < out.length; i++) {
38
+ out[i] = this.#byte(out[i]);
39
+ if (this.done) break; // rest passes through unchanged
40
+ }
41
+ return out;
42
+ }
43
+
44
+ #top() { return this.frames[this.frames.length - 1]; }
45
+
46
+ #byte(b) {
47
+ if (this.target) return this.#targetByte(b);
48
+
49
+ if (this.inStr) {
50
+ if (this.esc) { this.esc = false; if (this.readingKey) this.keyBuf.push(b); return b; }
51
+ if (b === 0x5c) { this.esc = true; return b; } // backslash
52
+ if (b === 0x22) { // end of string
53
+ this.inStr = false;
54
+ if (this.readingKey) { this.#top().key = Buffer.from(this.keyBuf).toString('latin1'); this.keyBuf = []; this.readingKey = false; }
55
+ return b;
56
+ }
57
+ if (this.readingKey) this.keyBuf.push(b);
58
+ return b;
59
+ }
60
+
61
+ const top = this.#top();
62
+ switch (b) {
63
+ case 0x7b: this.frames.push({ container: 'obj', name: top ? top.key : null, key: null, awaitingKey: true }); break; // {
64
+ case 0x5b: this.frames.push({ container: 'arr', name: top ? top.key : null, key: null, awaitingKey: false }); break; // [
65
+ case 0x7d: case 0x5d: this.frames.pop(); break; // } ]
66
+ case 0x3a: if (top) top.awaitingKey = false; break; // : (key → value)
67
+ case 0x2c: if (top && top.container === 'obj') top.awaitingKey = true; break; // ,
68
+ case 0x22: // string start
69
+ if (top && top.container === 'obj' && top.awaitingKey) {
70
+ this.readingKey = true; this.keyBuf = []; this.inStr = true; this.esc = false;
71
+ } else {
72
+ this.inStr = true; this.esc = false; this.readingKey = false;
73
+ if (top && top.container === 'obj' && top.name === 'metadata' && top.key === 'user_id' && this.frames.length === 2) {
74
+ this.target = true; this.matchPos = 0; this.uuidRemaining = 0;
75
+ }
76
+ }
77
+ break;
78
+ default: break; // scalars / whitespace
79
+ }
80
+ return b;
81
+ }
82
+
83
+ // Inside the metadata.user_id string value: stream-match the account_uuid key
84
+ // and overwrite its 36-byte value. Detect the (unescaped) closing quote to exit.
85
+ #targetByte(b) {
86
+ if (this.uuidRemaining > 0) {
87
+ const outByte = this.newUuid[this.newUuid.length - this.uuidRemaining];
88
+ this.uuidRemaining--;
89
+ if (outByte !== b) this.changed = true;
90
+ if (this.uuidRemaining === 0) this.done = true; // only one account_uuid per body
91
+ return outByte;
92
+ }
93
+ if (this.esc) { this.esc = false; this.#match(b); return b; }
94
+ if (b === 0x5c) { this.esc = true; this.#match(b); return b; }
95
+ if (b === 0x22) { this.target = false; this.matchPos = 0; return b; } // end of user_id value
96
+ this.#match(b);
97
+ return b;
98
+ }
99
+
100
+ #match(b) {
101
+ if (b === PREFIX[this.matchPos]) {
102
+ this.matchPos++;
103
+ if (this.matchPos === PREFIX.length) { this.uuidRemaining = 36; this.matchPos = 0; }
104
+ } else {
105
+ this.matchPos = (b === PREFIX[0]) ? 1 : 0; // PREFIX has no internal repeat of its first byte
106
+ }
107
+ }
108
+ }
109
+
110
+ /** One-shot convenience (whole-buffer); returns the same instance if unchanged. */
111
+ export function patchAccountUuid(buf, newUuid) {
112
+ const p = new AccountUuidPatcher(newUuid);
113
+ const out = p.push(buf);
114
+ return p.changed ? out : buf;
115
+ }