@rikcodes/teamclaude 1.1.19-rik.2 → 1.1.20-rik.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 (46) hide show
  1. package/README.md +4 -4
  2. package/package.json +7 -3
  3. package/src/account-id.js +1 -0
  4. package/src/account-manager.js +277 -61
  5. package/src/account-uuid-rewrite.js +29 -4
  6. package/src/admission-gate.js +12 -1
  7. package/src/alias.js +10 -1
  8. package/src/backend-quota.js +16 -4
  9. package/src/band-decision.js +8 -0
  10. package/src/cache-control-sanitize.js +17 -1
  11. package/src/classification-path.js +8 -0
  12. package/src/claude-env.js +26 -2
  13. package/src/codex-auth.js +3 -2
  14. package/src/codex-usage.js +87 -0
  15. package/src/crash-log.js +3 -1
  16. package/src/dashboard.js +31 -0
  17. package/src/egress-guard.js +24 -5
  18. package/src/event-loop-monitor.js +2 -1
  19. package/src/forward-target.js +2 -1
  20. package/src/identity.js +1 -0
  21. package/src/index.js +37 -2
  22. package/src/json-format-stream.js +6 -0
  23. package/src/mitm.js +13 -2
  24. package/src/oauth.js +5 -4
  25. package/src/prober.js +39 -6
  26. package/src/provider.js +20 -0
  27. package/src/quota-projection.js +3 -0
  28. package/src/request-log.js +11 -2
  29. package/src/resolve-accounts.js +2 -1
  30. package/src/rollover.js +59 -8
  31. package/src/safe-text.js +7 -1
  32. package/src/server.js +51 -8
  33. package/src/session-titles.js +26 -1
  34. package/src/session-tracker.js +63 -14
  35. package/src/sidecar.js +1 -0
  36. package/src/sx.js +1 -0
  37. package/src/sync-accounts.js +8 -4
  38. package/src/terminal-title.js +7 -0
  39. package/src/tool-pair-sanitize.js +23 -0
  40. package/src/tui-remote.js +4 -2
  41. package/src/tui.js +34 -5
  42. package/src/types.js +14 -0
  43. package/src/updater.js +24 -2
  44. package/src/upstream-fetch.js +4 -3
  45. package/src/upstream-proxy.js +2 -2
  46. package/src/warmer.js +18 -1
package/README.md CHANGED
@@ -24,9 +24,9 @@
24
24
  > Already have upstream installed globally? Run `npm uninstall -g @karpeleslab/teamclaude` first —
25
25
  > both packages provide the `teamclaude` command.
26
26
  >
27
- > Branch: `rik/soonest-weekly-pool`. Everything else matches upstream.
27
+ > Branch: `rik/main`. Everything else matches upstream.
28
28
 
29
- [![CI](https://github.com/rikbrown/teamclaude/actions/workflows/ci.yml/badge.svg?branch=rik/soonest-weekly-pool)](https://github.com/rikbrown/teamclaude/actions/workflows/ci.yml)
29
+ [![CI](https://github.com/rikbrown/teamclaude/actions/workflows/ci.yml/badge.svg?branch=rik/main)](https://github.com/rikbrown/teamclaude/actions/workflows/ci.yml)
30
30
  [![npm version](https://img.shields.io/npm/v/@rikcodes/teamclaude.svg)](https://www.npmjs.com/package/@rikcodes/teamclaude)
31
31
  [![node](https://img.shields.io/node/v/@rikcodes/teamclaude.svg)](https://nodejs.org)
32
32
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
@@ -190,12 +190,12 @@ Versions are `<upstream base>-rik.<n>`, e.g. `1.1.19-rik.1`. The self-updater or
190
190
 
191
191
  1. Rebase onto the upstream release you want as the base, if any.
192
192
  2. Bump `version` in `package.json` and commit.
193
- 3. Push to `rik/soonest-weekly-pool` — the Publish workflow runs the tests, publishes to npm, and cuts a GitHub release.
193
+ 3. Push to `rik/main` — the Publish workflow runs the tests, publishes to npm, and cuts a GitHub release.
194
194
 
195
195
  The workflow authenticates with npm Trusted Publishing (OIDC), which needs a one-time setup on npmjs.com: `@rikcodes/teamclaude` → Settings → Trusted Publisher → GitHub Actions, owner `rikbrown`, repo `teamclaude`, workflow `publish.yml`. Until that exists, publish by hand:
196
196
 
197
197
  ```bash
198
- pnpm publish --publish-branch rik/soonest-weekly-pool --tag latest --otp=<code>
198
+ pnpm publish --publish-branch rik/main --tag latest --otp=<code>
199
199
  ```
200
200
 
201
201
  A prerelease version always needs an explicit `--tag`, and `latest` is the tag the self-updater reads.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rikcodes/teamclaude",
3
- "version": "1.1.19-rik.2",
3
+ "version": "1.1.20-rik.1",
4
4
  "description": "Multi-account proxy for Claude Code and Codex: pools Claude Max, ChatGPT/Codex, API-key and third-party backend accounts, and rotates on quota",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -13,7 +13,9 @@
13
13
  "scripts": {
14
14
  "start": "node src/index.js",
15
15
  "test": "node --test --test-timeout=120000",
16
- "lint": "eslint ."
16
+ "lint": "eslint .",
17
+ "typecheck": "tsc -p tsconfig.json",
18
+ "typecheck:strict": "node scripts/typecheck-strict.mjs"
17
19
  },
18
20
  "keywords": [
19
21
  "claude",
@@ -43,7 +45,9 @@
43
45
  "node": ">=20.0.0"
44
46
  },
45
47
  "devDependencies": {
46
- "eslint": "^9.0.0"
48
+ "@types/node": "20.19.43",
49
+ "eslint": "^9.0.0",
50
+ "typescript": "5.9.2"
47
51
  },
48
52
  "publishConfig": {
49
53
  "access": "public"
package/src/account-id.js CHANGED
@@ -40,6 +40,7 @@ export function mintAccountId() {
40
40
  * copies its id along with it, and two entries answering to one id collapse
41
41
  * onto whichever comes first — the later one would be handed the earlier one's
42
42
  * credential, which is the crossing this field exists to prevent.
43
+ * @param {Array<Record<string, any>>} accounts
43
44
  */
44
45
  export function ensureAccountIds(accounts) {
45
46
  const seen = new Set();
@@ -7,10 +7,11 @@ import { weeklyBucketForModel, modelGlobMatches, modelFamily, gatingUtilization,
7
7
  import { SessionTracker } from './session-tracker.js';
8
8
  import { buildQuotaSummary, quotaTier } from './quota-summary.js';
9
9
  import { QuotaProjection, PROJECTED_BUCKETS } from './quota-projection.js';
10
- import { ROLLOVER_MIN_JUMP_MS, remapHeld } from './rollover.js';
10
+ import { ROLLOVER_MIN_JUMP_MS, remapHeld, findHeld, dropHeld, newObservation } from './rollover.js';
11
11
  import { decideBand, pressureOf, pressureRank, assertNever } from './band-decision.js';
12
12
  import { BurnRateLearner, ConcurrencyLearner, scoreCandidate } from './adaptive-distribution.js';
13
13
  import { safeLine } from './safe-text.js';
14
+ /** @typedef {import('./session-tracker.js').Observation} Observation */
14
15
 
15
16
  // Re-exported for callers that import these model helpers from here.
16
17
  export { isFableModel, parseRequestModel, parseAdvisorModel } from './model.js';
@@ -292,6 +293,24 @@ function sampleModelFor(route) {
292
293
  }
293
294
 
294
295
  export class AccountManager {
296
+ /**
297
+ * @param {Array<Object>} accounts config entries, credentials resolved
298
+ * @param {number|Object<string, number>} [switchThreshold] one number, or per bucket with a `default`
299
+ * @param {Object} [opts]
300
+ * @param {Function} [opts.refreshFn]
301
+ * @param {Function} [opts.codexRefreshFn]
302
+ * @param {number} [opts.throttleProbeFloorMs]
303
+ * @param {number} [opts.familyStaleMs]
304
+ * @param {number} [opts.statusStaleMs]
305
+ * @param {number} [opts.forcedRefreshFloorMs]
306
+ * @param {Array<Object>} [opts.routes]
307
+ * @param {Object} [opts.ramp]
308
+ * @param {boolean|string} [opts.distributeSessions]
309
+ * @param {Object} [opts.adaptive]
310
+ * @param {Object} [opts.projection]
311
+ * @param {Object} [opts.sessionTracker]
312
+ * @param {Object} [opts.expiryRouting]
313
+ */
295
314
  constructor(accounts, switchThreshold = 0.98, { refreshFn = refreshAccessToken, codexRefreshFn = refreshCodexToken, throttleProbeFloorMs, familyStaleMs, statusStaleMs, forcedRefreshFloorMs = FORCED_REFRESH_FLOOR_MS, routes, ramp, distributeSessions = false, adaptive, projection, sessionTracker, expiryRouting } = {}) {
296
315
  // How long a just-minted token is trusted against a forced refresh.
297
316
  this._forcedRefreshFloorMs = forcedRefreshFloorMs;
@@ -356,6 +375,7 @@ export class AccountManager {
356
375
  // observation, { idx, windows: name → reset }, of the account traffic was
357
376
  // resting on when a request last arrived to find it there. Null while the
358
377
  // knob is off: nothing writes one then and none survives the transition.
378
+ /** @type {Observation|null} */
359
379
  this._currentObs = null;
360
380
  // Throttle for the held-rollover line, keyed by (account index, WINDOW,
361
381
  // reason). Keying by the request bucket would let two windows that share one
@@ -534,9 +554,14 @@ export class AccountManager {
534
554
  */
535
555
  _setCurrent(account) {
536
556
  this.currentIndex = account.index;
537
- this.providerCursors.set(providerOf(account), account.index);
557
+ // A move made by an operator or a poll names the provider cursor of the
558
+ // account it lands on. A move inside a selection walk does not: the walk
559
+ // records where it left the slot once it is done (see getActiveAccount),
560
+ // and a borrowed walk that picks a shared API key — which reads as the
561
+ // default provider — would otherwise overwrite the OWNER's cursor here.
562
+ if (this._selectingProvider == null) this.providerCursors.set(providerOf(account), account.index);
538
563
  if (!this.expiryRouting.enabled || !this.expiryRouting.preempt) return;
539
- this._firstSightOn(this._currentObs ??= { idx: null, windows: new Map(), unescaped: null, gen: 0 }, account);
564
+ this._firstSightOn(this._currentObs ??= newObservation(), account);
540
565
  }
541
566
 
542
567
  /**
@@ -689,8 +714,10 @@ export class AccountManager {
689
714
  // one provider that is a single slot for several fleets, so a request whose
690
715
  // provider does not own it borrows the slot for the walk and hands it back.
691
716
  //
692
- // With one provider — every config that predates #246 — `borrowed` is always
693
- // false and this is the same code it was.
717
+ // With one provider `borrowed` is always false, so the `providerCursors`
718
+ // entry this method writes has no reader at all. Its one reader is the
719
+ // `providerCursors.get(provider)` inside `if (borrowed)`, which only a
720
+ // borrowed walk reaches.
694
721
  const owner = providerOf(this.accounts[this.currentIndex]);
695
722
  const borrowed = !!this.accounts.length && owner !== provider;
696
723
  const saved = this.currentIndex;
@@ -700,6 +727,7 @@ export class AccountManager {
700
727
  }
701
728
 
702
729
  let account;
730
+ let walked;
703
731
  // Scoped rather than threaded through _select/_selectNext/_divertedFor: the
704
732
  // whole walk is synchronous, so nothing can interleave and observe it, and
705
733
  // the alternative is a provider argument on six private methods that exist
@@ -716,6 +744,7 @@ export class AccountManager {
716
744
  } finally {
717
745
  this._selectingProvider = null;
718
746
  this._selectionDecision = null;
747
+ walked = this.currentIndex;
719
748
  // Hand the slot back before anything can observe it moved. Only the
720
749
  // provider that owns currentIndex gets to change it.
721
750
  if (borrowed) this.currentIndex = saved;
@@ -727,7 +756,16 @@ export class AccountManager {
727
756
  // the next real failover unpaced.
728
757
  if (account) {
729
758
  this.routeCursors.set(this._cursorKey(model, advisorModel, provider), account.index);
730
- this.providerCursors.set(provider, account.index);
759
+ // `walked` names where this walk left the shared slot, captured in the
760
+ // `finally` while it still held it. When it names one of this provider's
761
+ // own accounts — including a borrow that re-seeded and then held its
762
+ // slot — the cursor takes it. Which one it names turns on where the walk
763
+ // left the slot, not on what it picked. Either way the fallback is the
764
+ // account that served — and when that account is another provider's, as
765
+ // a shared key is, this provider records nothing rather than a cursor
766
+ // its own re-seed would refuse.
767
+ const rest = providerOf(this.accounts[walked]) === provider ? walked : account.index;
768
+ if (providerOf(this.accounts[rest]) === provider) this.providerCursors.set(provider, rest);
731
769
  }
732
770
  return account;
733
771
  }
@@ -795,7 +833,7 @@ export class AccountManager {
795
833
  // on every request so the behaviour holds without the TUI render loop.
796
834
  // The empty set marks this call as a request's, since a poll hands none.
797
835
  // Allocated fresh: a set handed out once is one a later reader could add to.
798
- this.refreshExpiredQuotas(model, this.expiryRouting.enabled ? (exclude ?? new Set()) : exclude);
836
+ this.refreshExpiredQuotas(model, this.expiryRouting.enabled ? (exclude ?? new Set()) : exclude, advisorModel);
799
837
  // Session-affinity distribution (opt-in): keep a session on its pinned
800
838
  // account for cache reuse, and route a new session to the least-loaded
801
839
  // account. Only when enabled, only for a real session, and only outside a
@@ -1994,7 +2032,7 @@ export class AccountManager {
1994
2032
  // the transition seeds" and "an aim never overwrites".
1995
2033
  if (wasWatching) return;
1996
2034
  const current = this.accounts[this.currentIndex];
1997
- if (current) this._firstSightOn(this._currentObs ??= { idx: null, windows: new Map(), unescaped: null, gen: 0 }, current);
2035
+ if (current) this._firstSightOn(this._currentObs ??= newObservation(), current);
1998
2036
  for (const { sessionId, bucket, idx } of this.sessionTracker.livePins()) {
1999
2037
  this._firstSightOn(this.sessionTracker.refsFor(sessionId, bucket, true), this.accounts[idx]);
2000
2038
  }
@@ -2199,6 +2237,8 @@ export class AccountManager {
2199
2237
  * writes only where nothing is lost, since the aimed request may never arrive.
2200
2238
  * Nothing here releases a held roll: arriving is not being served, and only a
2201
2239
  * served attempt carrying this observation's stamp releases one.
2240
+ *
2241
+ * @param {Observation} obs
2202
2242
  */
2203
2243
  _restOn(obs, account, model) {
2204
2244
  if (obs.idx !== account.index) {
@@ -2207,13 +2247,39 @@ export class AccountManager {
2207
2247
  // `_firstSightOn` hands that back, and every cursor or pin move goes
2208
2248
  // through `_setCurrent` or `recordSession`, so a fail-back has been
2209
2249
  // offered it before any request reaches here.
2210
- const leaving = obs.idx == null ? null : this.accounts[obs.idx];
2211
- if (leaving && this._anyJumped(obs.windows, leaving)) {
2212
- obs.unescaped = { idx: obs.idx, windows: obs.windows };
2250
+ // A reading that names NO account was offered nothing on the way here: the
2251
+ // rebuild a removal leaves behind names nobody, and no cursor moved for
2252
+ // `_firstSightOn` to run on. So this rest is the arrival, and a roll the
2253
+ // chain still owes the account it arrives at is handed back rather than
2254
+ // first-sighted away with the week that account has already gained.
2255
+ if (obs.idx == null) {
2256
+ const back = findHeld(obs.unescaped, h => h.idx === account.index);
2257
+ if (back) {
2258
+ this._moveObs(obs, account.index);
2259
+ obs.windows = back.windows;
2260
+ obs.handedBack = new Map(Object.entries(this._accountWindows(account)));
2261
+ obs.unescaped = dropHeld(obs.unescaped, account.index);
2262
+ return;
2263
+ }
2213
2264
  }
2265
+ const leaving = obs.idx == null ? null : this.accounts[obs.idx];
2266
+ // The hold belongs to the fleet whose READING it preserves, the one that
2267
+ // last moved this observation, rather than to the fleet that writes it.
2268
+ // A borrower resting on the owner's cursor displaces the owner's reading.
2269
+ // A reading no walk has moved names no fleet, so the fleet that observes
2270
+ // the roll takes it, and a single-provider fleet can still settle it.
2271
+ const owed = leaving && this._newRoll(obs, leaving)
2272
+ ? { idx: obs.idx, windows: obs.windows, provider: obs.provider ?? this._selectingProvider }
2273
+ : null;
2274
+ this._moveObs(obs, account.index);
2275
+ // Every account the fleet is still away from keeps its OWN roll, so a
2276
+ // second escape is chained onto the first instead of forgetting it. The
2277
+ // push takes the stamp of the move that escaped the roll, and only a stay
2278
+ // under that stamp releases it. At most one roll per account, the newest:
2279
+ // a later escape was read after the earlier one's window had rolled.
2280
+ if (owed) obs.unescaped = { ...owed, gen: obs.gen, prev: dropHeld(obs.unescaped, owed.idx) };
2214
2281
  // Established whole, from every window the account presents, so a window
2215
2282
  // that comes back is a first sight rather than a stale value read as a jump.
2216
- this._moveObs(obs, account.index);
2217
2283
  obs.windows = new Map(Object.entries(this._accountWindows(account)));
2218
2284
  return;
2219
2285
  }
@@ -2247,18 +2313,48 @@ export class AccountManager {
2247
2313
  * design turns on — a reading whose account HAS rolled while its traffic has
2248
2314
  * not come to rest elsewhere, the fail-back's only protection. The test is
2249
2315
  * whether ANY window rolled, since an aim discards the whole reading at once.
2316
+ *
2317
+ * @param {Observation} obs
2250
2318
  */
2251
2319
  _firstSightOn(obs, account) {
2252
2320
  if (!obs || !account) return;
2253
- if (obs.idx != null && obs.idx !== account.index) {
2321
+ if (obs.idx !== account.index) {
2254
2322
  // A fail-back reaches its origin through a cursor move rather than a rest:
2255
2323
  // the pass returning the traffic finds the cursor still on the account
2256
2324
  // that refused it, so _restOn never sees the arrival. The held roll is
2257
2325
  // given back here, to the account that still owes it.
2258
- if (obs.unescaped?.idx === account.index) {
2326
+ // Matched on index alone, whatever fleet holds it and whatever move
2327
+ // escaped it: handing a roll back to the account that owes it is a
2328
+ // restoration and not a settlement by anyone. Only that account's roll is
2329
+ // given back, so every other escape still outstanding stands. A reading
2330
+ // that names no account arrives here too, and one holding nothing for this
2331
+ // account takes the same fresh reading it always did: there is no account
2332
+ // at a null index for _anyJumped to have rolled.
2333
+ const owed = findHeld(obs.unescaped, h => h.idx === account.index);
2334
+ if (owed) {
2335
+ // The account the hand-back LEAVES may have rolled under the traffic that
2336
+ // rested on it, and the restore below replaces its reading. That roll is
2337
+ // an escape like any other, so it is chained rather than discarded.
2338
+ // A reading no walk established names no fleet, so the roll it loses is
2339
+ // held for the fleet the chain belongs to: the hold being handed back is
2340
+ // that fleet's, and a hold naming nobody can be settled by nobody.
2341
+ const leaving = obs.idx == null ? null : this.accounts[obs.idx];
2342
+ const displaced = leaving && this._newRoll(obs, leaving)
2343
+ ? { idx: obs.idx, windows: obs.windows, provider: obs.provider ?? owed.provider ?? this._selectingProvider }
2344
+ : null;
2259
2345
  this._moveObs(obs, account.index);
2260
- obs.windows = obs.unescaped.windows;
2261
- obs.unescaped = null;
2346
+ obs.windows = owed.windows;
2347
+ obs.handedBack = new Map(Object.entries(this._accountWindows(account)));
2348
+ // A restore outside a selection walk reads no fleet from one. The reading
2349
+ // handed back is the one the hold's fleet established, so it keeps that
2350
+ // fleet, and the next hand-back has one to stamp the roll it leaves with.
2351
+ obs.provider ??= owed.provider;
2352
+ obs.unescaped = dropHeld(obs.unescaped, account.index);
2353
+ // No stamp: the move that leaves this roll is a move BACK, and a stay
2354
+ // served at the account it returns to is no evidence about the one it
2355
+ // left. Only a stay served on that account releases it, or a return that
2356
+ // restores it, which is matched on index and needs no stamp.
2357
+ if (displaced) obs.unescaped = { ...displaced, gen: null, prev: dropHeld(obs.unescaped, displaced.idx) };
2262
2358
  return;
2263
2359
  }
2264
2360
  if (this._anyJumped(obs.windows, this.accounts[obs.idx])) return;
@@ -2272,10 +2368,19 @@ export class AccountManager {
2272
2368
  /**
2273
2369
  * The stamp scopes a confirmation: a request that selected before this move,
2274
2370
  * or after a later one, carries a different one and is no evidence here.
2371
+ *
2372
+ * @param {Observation} obs
2373
+ * @param {number} index
2275
2374
  */
2276
2375
  _moveObs(obs, index) {
2277
2376
  obs.idx = index;
2278
2377
  obs.gen = ++this._obsGen;
2378
+ obs.handedBack = null;
2379
+ // A move inside a selection walk makes the reading that fleet's, so its own
2380
+ // stay is what settles a roll pushed off it. The same-index stay in `_restOn`
2381
+ // does not come through here: a borrowed walk advances a reading the resting
2382
+ // fleet never established, and stamping there hands it the owner's roll.
2383
+ obs.provider = this._selectingProvider;
2279
2384
  }
2280
2385
 
2281
2386
  /** Has ANY window this reading holds rolled over on the account it was taken
@@ -2290,6 +2395,24 @@ export class AccountManager {
2290
2395
  return false;
2291
2396
  }
2292
2397
 
2398
+ /** Has a window rolled on this account that the reading was NOT handed back
2399
+ * for? A hand-back restores the reading from before a roll and records the
2400
+ * account's windows as they stood then, so that roll is a jump against the
2401
+ * reading but not against the record. A window that has moved since the
2402
+ * hand-back, or one the record never saw, is a roll of its own, and the
2403
+ * departure that finds it holds it. Per window, so a rest governed by one
2404
+ * window says nothing about another's roll, and a second fleet's window
2405
+ * rolling on the same reading is still seen. */
2406
+ _newRoll(obs, account) {
2407
+ if (!obs.handedBack) return this._anyJumped(obs.windows, account);
2408
+ for (const [window, resetAt] of Object.entries(this._accountWindows(account))) {
2409
+ const win = { window, resetAt };
2410
+ if (!this._jumped(obs.windows, win)) continue;
2411
+ if (!obs.handedBack.has(window) || this._jumped(obs.handedBack, win)) return true;
2412
+ }
2413
+ return false;
2414
+ }
2415
+
2293
2416
  /**
2294
2417
  * The generation stamps a request selects under. Read BEFORE the walk: a move
2295
2418
  * the walk makes is the request arriving, not finding traffic already at rest,
@@ -2310,29 +2433,47 @@ export class AccountManager {
2310
2433
  confirmStay(account, carried, sessionId = null, provider = null) {
2311
2434
  if (!this.expiryRouting.enabled || !this.expiryRouting.preempt) return;
2312
2435
  if (!account || !carried) return;
2313
- // One observation slot is shared by every provider, so the roll it holds and
2314
- // the success offered for it can belong to different fleets. The REQUEST's
2315
- // provider settles it, and a confirmation naming no fleet settles nothing.
2316
- const held = this._currentObs?.unescaped;
2317
- if (held && provider && providerOf(this.accounts[held.idx]) === provider) {
2318
- this._releaseHeld(this._currentObs, account, carried.current);
2319
- }
2436
+ this._releaseHeld(this._currentObs, account, carried.current, provider);
2320
2437
  if (sessionId) {
2321
2438
  // The bucket comes from the stamp, since a route edit reaches the running
2322
- // table before the response does. Ungated, because a session's own
2323
- // observation cannot name an account it never selected.
2324
- this._releaseHeld(this.sessionTracker.refsFor(sessionId, carried.bucket), account, carried.pin);
2439
+ // table before the response does.
2440
+ this._releaseHeld(this.sessionTracker.refsFor(sessionId, carried.bucket), account, carried.pin, provider);
2325
2441
  }
2326
2442
  }
2327
2443
 
2328
2444
  /**
2329
- * Clear one observation's held roll, only where the request confirms the stay
2330
- * it selected under. Another account, or any move since, is a different stay.
2445
+ * Settle the roll the confirmed move escaped, on the evidence that the
2446
+ * destination SERVED a request selecting under that move. Another account, or
2447
+ * any move since, is a different stay. A confirmation naming no fleet settles
2448
+ * nothing. Every other roll on the chain waits for its own fail-back or for
2449
+ * the stay of its own move.
2450
+ *
2451
+ * @param {Observation} obs
2452
+ * @param {string|null} provider
2331
2453
  */
2332
- _releaseHeld(obs, account, carried) {
2454
+ _releaseHeld(obs, account, carried, provider) {
2333
2455
  if (!obs || carried == null) return;
2334
2456
  if (obs.idx !== account.index || obs.gen !== carried) return;
2335
- obs.unescaped = null;
2457
+ const owed = findHeld(obs.unescaped, h => h.gen === carried);
2458
+ // A serve settles a roll held against the very account it was served at,
2459
+ // whatever move stamped it: a borrowed walk can leave the reading resting on
2460
+ // an account the chain still owes without any move of ours, and being served
2461
+ // there is the arrival the hold was waiting for. Its own fleet only, so the
2462
+ // shared-key rule is untouched, and only that account's roll, so every
2463
+ // other escape on the chain still stands. One confirmation can qualify that
2464
+ // roll and the one this move escaped, and each is settled on its own evidence.
2465
+ const own = findHeld(obs.unescaped, h => h.idx === account.index && h.provider === provider);
2466
+ if (!provider) return;
2467
+ if (own) obs.unescaped = dropHeld(obs.unescaped, own.idx);
2468
+ if (!owed) return;
2469
+ // A hold has a reading to itself only where the destination is its own
2470
+ // fleet's subscription, which no other fleet is ever served at. Anywhere
2471
+ // else the destination has one reading for whoever it serves, so a success
2472
+ // by the fleet served there settles the roll held against it.
2473
+ const settledByAnyServed = owed.provider != null
2474
+ && !(isSubscriptionAccount(account) && providerOf(account) === owed.provider);
2475
+ if (owed.provider !== provider && !settledByAnyServed) return;
2476
+ obs.unescaped = dropHeld(obs.unescaped, owed.idx);
2336
2477
  }
2337
2478
 
2338
2479
  /** The reading for the sticky CURRENT account, taken at the top of a selection
@@ -2341,7 +2482,7 @@ export class AccountManager {
2341
2482
  if (!this.expiryRouting.enabled || !this.expiryRouting.preempt) return;
2342
2483
  const resting = this.accounts[this.currentIndex];
2343
2484
  if (!resting || exclude?.has(resting.index)) return;
2344
- this._currentObs ??= { idx: null, windows: new Map(), unescaped: null, gen: 0 };
2485
+ this._currentObs ??= newObservation();
2345
2486
  this._restOn(this._currentObs, resting, model);
2346
2487
  }
2347
2488
 
@@ -2409,7 +2550,10 @@ export class AccountManager {
2409
2550
  console.log(`[TeamClaude] Account "${account.name}" rolled over its ${window} window ${this._heldRolloverReason(reason)}`);
2410
2551
  }
2411
2552
 
2412
- /** The half of the held-rollover line that says which of the two cases it is. */
2553
+ /**
2554
+ * The half of the held-rollover line that says which of the two cases it is.
2555
+ * @param {'none-eligible'|'ranks-best'} reason
2556
+ */
2413
2557
  _heldRolloverReason(reason) {
2414
2558
  switch (reason) {
2415
2559
  case 'none-eligible': return 'but no eligible account can take that traffic — still routing there';
@@ -2445,8 +2589,11 @@ export class AccountManager {
2445
2589
  * are recorded whether or not they currently bind, because one that starts
2446
2590
  * binding later would otherwise be first-sighted on the very request that
2447
2591
  * should have caught it rolling.
2592
+ *
2593
+ * @returns {Object<string, number>}
2448
2594
  */
2449
2595
  _accountWindows(account) {
2596
+ /** @type {Object<string, number>} */
2450
2597
  const out = {};
2451
2598
  for (const bucket of this._windowKeys()) {
2452
2599
  const window = this._windowForBucket(account, bucket);
@@ -2768,16 +2915,25 @@ export class AccountManager {
2768
2915
  for (const account of this.accounts) this._clearExpiredQuotas(account);
2769
2916
  }
2770
2917
 
2771
- refreshExpiredQuotas(model = null, exclude = null) {
2918
+ refreshExpiredQuotas(model = null, exclude = null, advisorModel = null) {
2772
2919
  let changed = false;
2773
2920
  // Gated here rather than at the switch call, because the pending flag is read
2774
2921
  // against it too: with the feature off every reset is consumed on sight.
2775
2922
  const scope = this.expiryRouting.enabled ? exclude : null;
2923
+ // Selection admits an advisor request on BOTH its models, so the switch is
2924
+ // drawn on both too. Never more strictly than the pass that decides the
2925
+ // request, though: where no reachable account can serve the advisor model,
2926
+ // selection routes on the main model alone and the event is spent on that
2927
+ // basis. Ordered so that this scan reads no account with the knob off or
2928
+ // with no advisor model in hand: reading one clears its expired windows.
2929
+ const adv = (scope != null && advisorModel
2930
+ && this.accounts.some(a => !scope.has(a.index) && this._isAvailable(a, model, advisorModel)))
2931
+ ? advisorModel : null;
2776
2932
  // THE EXCLUSION SET IS WHAT MARKS A CALL AS A REQUEST'S: the request path
2777
2933
  // hands one on every call, empty included, and the TUI loop, getQuotaSummary
2778
2934
  // and selectActiveAccount hand none. The tests below are the switch's own.
2779
2935
  const canRouteTo = account => account != null
2780
- && !scope.has(account.index) && this._isAvailable(account, model);
2936
+ && !scope.has(account.index) && this._isAvailable(account, model, adv);
2781
2937
  // The cursor's account is one end of every comparison the switch makes, so a
2782
2938
  // request that cannot be sent there settles nothing and leaves the event too.
2783
2939
  const spends = !this.expiryRouting.enabled
@@ -2800,12 +2956,12 @@ export class AccountManager {
2800
2956
  sessionReset.push(account);
2801
2957
  }
2802
2958
  }
2803
- // The model reaches the switch only while the feature is on. Handed no
2804
- // model, the switch runs the same `_isAvailable(acc)` it does with the knob
2805
- // off: threading one in would make the disabled path's candidate filter
2806
- // model-scoped, a live routing change on the path that promises none.
2959
+ // Neither model reaches the switch unless the feature is on. Handed none,
2960
+ // the switch runs the same `_isAvailable(acc)` the knob-off path runs; a
2961
+ // model-scoped filter there is a live routing change on the path that
2962
+ // promises none.
2807
2963
  if (sessionReset.length) {
2808
- this._switchOnSessionReset(sessionReset, this.expiryRouting.enabled ? model : null, scope);
2964
+ this._switchOnSessionReset(sessionReset, this.expiryRouting.enabled ? model : null, scope, adv);
2809
2965
  }
2810
2966
  return changed;
2811
2967
  }
@@ -2815,7 +2971,7 @@ export class AccountManager {
2815
2971
  * weekly limit expires soonest — but only if that is sooner than the current
2816
2972
  * account's weekly limit and the account still has weekly quota to spend.
2817
2973
  */
2818
- _switchOnSessionReset(candidates, model = null, exclude = null) {
2974
+ _switchOnSessionReset(candidates, model = null, exclude = null, advisorModel = null) {
2819
2975
  const current = this.accounts[this.currentIndex];
2820
2976
  // Need a known weekly reset on the current account to compare against;
2821
2977
  // if it is unknown we are still probing it, so leave it alone. Read through
@@ -2835,12 +2991,13 @@ export class AccountManager {
2835
2991
  // goes. Kept here as well as in refreshExpiredQuotas, so a caller that does
2836
2992
  // not filter first gets the same answer.
2837
2993
  if (exclude?.has(acc.index)) continue;
2838
- // Model-scoped, because the request being routed has one: an account whose
2839
- // Fable weekly is spent is still fully usable for Opus, and a switch that
2840
- // ignores the model can install one the model's own picker would refuse.
2841
- // The caller pre-filters on this only with the feature on. With it off,
2842
- // this line alone keeps an account whose weekly is spent out of the switch.
2843
- if (!this._isAvailable(acc, model)) continue; // enough session & weekly quota left
2994
+ // Scoped to the models this switch is handed: the caller drops the advisor
2995
+ // model when no reachable account serves it, so the switch is never
2996
+ // stricter than the pass that decides the request. An account whose Fable
2997
+ // weekly is spent is fully usable for Opus, and a switch that ignores
2998
+ // either model installs one the request's own picker refuses. With the
2999
+ // feature off this line alone keeps an account whose weekly is spent out.
3000
+ if (!this._isAvailable(acc, model, advisorModel)) continue; // enough session & weekly quota left
2844
3001
  // Don't demote to a lower-priority (higher value) account on a reset.
2845
3002
  if ((acc.priority || 0) > (current.priority || 0)) continue;
2846
3003
  const weekly = this._rankingReset(acc, model);
@@ -2868,12 +3025,27 @@ export class AccountManager {
2868
3025
  || (mine === theirs && this._rankedReset(acc, model) < this._rankedReset(best, model))) best = acc;
2869
3026
  }
2870
3027
 
2871
- // TWO guards, different properties, neither implying the other. Band
2872
- // membership says the account is worth spending at all. The rank comparison
2873
- // says this switch leaves no strictly better account behind, which
2874
- // membership does not claim once a lower tier passes through unbanded. Both
2875
- // are drawn over what this request can be sent to.
2876
- if (this.expiryRouting.enabled && !this._bandedCandidates(exclude, model).includes(best)) return;
3028
+ // TWO guards, different properties, neither implying the other, and NOT
3029
+ // drawn over the same fleet. The rank comparison weighs `best` against the
3030
+ // account the cursor is on, both of which THIS REQUEST reached, so it is
3031
+ // measured on what this request can be sent to; and it says this switch
3032
+ // leaves no strictly better account behind, which membership does not claim
3033
+ // once a lower tier passes through unbanded. Band membership says the
3034
+ // account is worth spending at all, and the only thing the answer does here
3035
+ // is move the CURSOR, which serves every request behind this one. So it is
3036
+ // drawn over the fleet that cursor serves: the request's provider partition,
3037
+ // narrowed by the model filter _bandedCandidates already applies, and never
3038
+ // by the accounts this one attempt has tried. An account a 429 pushed this
3039
+ // request off holds whatever band it holds for everybody else.
3040
+ // Both models, because that filter is per-account and reads the
3041
+ // already-degraded argument: the band re-evaluates no degradation, so the
3042
+ // advisor term narrows the set and re-imposes nothing the caller dropped.
3043
+ // The provider comes from the walk in progress, as it does in _cursorKey; a
3044
+ // direct caller has none and means the default fleet. Kept inside the `&&`
3045
+ // rather than hoisted, so the knob-off path evaluates none of it.
3046
+ if (this.expiryRouting.enabled && !this._bandedCandidates(
3047
+ this._excludeOtherProviders(null, this._selectingProvider || DEFAULT_PROVIDER),
3048
+ model, advisorModel).includes(best)) return;
2877
3049
  // Strictly worse than what we are on: stay. Equal keeps the reset tiebreak
2878
3050
  // that got us here, and with expiry routing off every rank is absent and
2879
3051
  // equal, so this cannot fire at all.
@@ -3455,6 +3627,36 @@ export class AccountManager {
3455
3627
  }
3456
3628
  }
3457
3629
 
3630
+ /** Apply a read-only Codex `/wham/usage` reading without counting traffic. */
3631
+ applyCodexUsageData(accountIndex, usage) {
3632
+ const account = this.accounts[accountIndex];
3633
+ if (!account || !usage || usage.error) return;
3634
+ const q = account.quota;
3635
+ if (usage.fiveHour) {
3636
+ q.unified5h = usage.fiveHour.utilization;
3637
+ q.unified5hReset = usage.fiveHour.resetAt ?? null;
3638
+ }
3639
+ if (usage.sevenDay) {
3640
+ q.unified7d = usage.sevenDay.utilization;
3641
+ q.unified7dReset = usage.sevenDay.resetAt ?? null;
3642
+ }
3643
+ if (usage.planType) q.planType = safeLine(usage.planType, 64);
3644
+ if (Array.isArray(usage.modelBuckets)) {
3645
+ q.codexModelBuckets = Object.fromEntries(usage.modelBuckets.slice(0, MAX_CODEX_MODEL_BUCKETS)
3646
+ .filter(bucket => bucket?.slug)
3647
+ .map(bucket => [safeLine(bucket.slug, 64), {
3648
+ name: safeLine(bucket.name || bucket.slug, 64),
3649
+ utilization: bucket.utilization,
3650
+ resetAt: bucket.resetAt ?? null,
3651
+ seenAt: Date.now(),
3652
+ }]));
3653
+ }
3654
+ if (account.probing && q.unified7dReset != null) {
3655
+ account.probing = false;
3656
+ account.requalify = true;
3657
+ }
3658
+ }
3659
+
3458
3660
  /** Apply subscription metadata learned from the OAuth profile endpoint. */
3459
3661
  applyProfileData(accountIndex, profile) {
3460
3662
  const account = this.accounts[accountIndex];
@@ -3634,6 +3836,8 @@ export class AccountManager {
3634
3836
 
3635
3837
  /**
3636
3838
  * Update a specific account's OAuth tokens (e.g. after intercepting a token refresh).
3839
+ * @param {number} accountIndex
3840
+ * @param {{ accessToken?: string, refreshToken?: string, expiresAt?: number }} tokens
3637
3841
  */
3638
3842
  updateAccountTokens(accountIndex, { accessToken, refreshToken, expiresAt }) {
3639
3843
  const account = this.accounts[accountIndex];
@@ -3665,6 +3869,7 @@ export class AccountManager {
3665
3869
  */
3666
3870
  removeAccount(index) {
3667
3871
  if (index < 0 || index >= this.accounts.length) return;
3872
+ const before = this.accounts[this.currentIndex] ?? null;
3668
3873
  this.accounts.splice(index, 1);
3669
3874
  this.accounts.forEach((a, i) => a.index = i);
3670
3875
  if (this.currentIndex >= this.accounts.length) {
@@ -3695,15 +3900,26 @@ export class AccountManager {
3695
3900
  // against whichever account inherited the slot; its held roll the same.
3696
3901
  const moved = this._currentObs?.idx == null ? null : remap(this._currentObs.idx);
3697
3902
  if (this._currentObs) {
3698
- this._currentObs = moved == null ? null
3699
- : {
3700
- idx: moved,
3701
- windows: this._currentObs.windows,
3702
- unescaped: remapHeld(this._currentObs.unescaped, remap),
3703
- // Renumbering names the same account by a new index, so a request
3704
- // already in flight against it still confirms the stay it selected on.
3705
- gen: this._currentObs.gen,
3706
- };
3903
+ // Renumbering is nobody's success, and it names the same account by a new
3904
+ // index, so everything but the two indices survives the shift: the stamp
3905
+ // saying whose reading this is, and the gen a request already in flight
3906
+ // against that account still confirms its stay on. The account that went
3907
+ // away is the only one whose roll the removal settles, so what it was
3908
+ // holding for the others moves onto a fresh reading. That reading names no
3909
+ // account and has no windows, which is evidence about nobody, while every
3910
+ // hold under it keeps its own stamp and gen.
3911
+ const held = remapHeld(this._currentObs.unescaped, remap);
3912
+ this._currentObs = moved != null
3913
+ ? { ...this._currentObs, idx: moved, unescaped: held }
3914
+ : held ? { ...newObservation(), unescaped: held } : null;
3915
+ }
3916
+ // A removal that takes the account the cursor rests on leaves the cursor at
3917
+ // a neighbour, with a reading the rebuild left nameless. A reading the
3918
+ // removal merely renumbered keeps its own account, so it is offered
3919
+ // nothing.
3920
+ const landed = this.accounts[this.currentIndex] ?? null;
3921
+ if (landed && landed !== before && this._currentObs && this._currentObs.idx == null) {
3922
+ this._firstSightOn(this._currentObs, landed);
3707
3923
  }
3708
3924
  // A throttle key names an account by index, so the shift would point a live
3709
3925
  // entry at a different account. Not worth renumbering: the entries expire in