@karpeleslab/teamclaude 1.0.7 → 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.
package/README.md CHANGED
@@ -15,13 +15,17 @@ Sits transparently between Claude Code and the Anthropic API, managing multiple
15
15
 
16
16
  - **Automatic account rotation** — switches to the next account when session (5h) or weekly (7d) quota reaches the configured threshold (default 98%)
17
17
  - **Auto-retry on 429** — waits the `retry-after` duration and retries the same account; switches to the next on persistent errors
18
- - **Interactive TUI** — real-time dashboard with color-coded quota bars, reset countdowns, activity log, and keyboard controls
18
+ - **Interactive TUI** — real-time dashboard with color-coded quota bars, reset countdowns, activity log, and keyboard controls; a settings screen (`g`) edits the rotation threshold, quota-probe interval, and sx.org proxy live
19
19
  - **OAuth token management** — automatically refreshes tokens nearing expiry and persists them to config; client token refreshes pass through untouched
20
- - **Hot-reload accounts** — add accounts via `import` or `login` while the server is running, press **R** to pick them up
20
+ - **Hot-reload accounts** — add or change accounts while the server is running; press **R** in the TUI, or run headless and CLI changes auto-reload via a local control endpoint
21
+ - **Headless mode** — run the proxy without the TUI (`--headless`) for backgrounding/services
21
22
  - **Org-aware accounts** — one email can hold multiple accounts across different organizations (e.g. corp + personal); dedup is keyed on account + org, and names disambiguate as `email (Org)`
22
23
  - **Rotation priority** — pin a preferred account order with `teamclaude priority`
23
24
  - **Enable/disable accounts** — temporarily pause an account without removing it (`teamclaude disable`/`enable`, or `d` in the TUI); re-enabling also clears a stuck error state
24
25
  - **Quota persistence** — observed quota survives restarts (saved to a sibling state file), so rotation state isn't lost on restart; stale windows are discarded automatically
26
+ - **Optional quota probe** — off by default; when enabled, periodically refreshes idle accounts' quota from the usage endpoint (no message spend), and surfaces the Sonnet weekly bucket
27
+ - **Optional MITM proxy mode** — `teamclaude run --mitm` routes claude via an HTTPS forward proxy with a local CA so even hardcoded `api.anthropic.com` endpoints (e.g. the Claude Design MCP) get the real token injected
28
+ - **Optional sx.org proxy mode** — off by default; set an [sx.org](https://sx.org) API key in the TUI settings screen (`g`) and TeamClaude auto-provisions a residential proxy to change the egress IP and work around IP-based `429`s. Three modes (`m` to cycle): **always** (route all upstream traffic), **on 429 only** (stay direct, fail over to the proxy after a 429), or **off** (keep the key but don't use it). TLS stays end-to-end with Anthropic (the proxy only relays ciphertext)
25
29
  - **Request logging** — optional full request/response logging for debugging
26
30
  - **Zero dependencies** — uses only Node.js built-in modules
27
31
 
@@ -103,7 +107,15 @@ When running from a TTY, shows an interactive TUI with:
103
107
  - Real-time activity log with request tracking
104
108
  - Keyboard shortcuts (see below)
105
109
 
106
- Falls back to plain log output when not a TTY (e.g. running as a service).
110
+ Falls back to plain log output when not a TTY (e.g. running as a service). Pass `--headless` (or `--no-tui`) to force the plain-log mode even from a terminal — useful for backgrounding the proxy.
111
+
112
+ When running headless, you can re-sync accounts from the config without a restart by POSTing to the local control endpoint (the equivalent of pressing **R** in the TUI):
113
+
114
+ ```bash
115
+ curl -X POST http://localhost:3456/teamclaude/reload
116
+ ```
117
+
118
+ You usually don't need to call it directly: `teamclaude login`, `import`, `enable`, `disable`, and `priority` automatically notify a running server to reload. (New accounts and credential/priority/enable-disable changes are picked up live; account *removals* still require a restart.)
107
119
 
108
120
  #### TUI Keyboard Shortcuts
109
121
 
@@ -154,6 +166,7 @@ teamclaude remove <name> # Remove an account (by name or email)
154
166
  teamclaude disable <name> # Temporarily exclude an account from rotation
155
167
  teamclaude enable <name> # Re-enable it (also clears a stuck error state)
156
168
  teamclaude priority <name> 1 # Set rotation priority (lower = preferred)
169
+ teamclaude probe 300 # Enable background quota refresh (off by default)
157
170
  teamclaude alias # Print/install a `claude` alias that routes via the proxy
158
171
  teamclaude api <path> # Call an API endpoint with account credentials
159
172
  teamclaude help # Show all commands
@@ -195,6 +208,7 @@ TEAMCLAUDE_CONFIG=./my-config.json teamclaude server
195
208
  },
196
209
  "upstream": "https://api.anthropic.com",
197
210
  "switchThreshold": 0.98,
211
+ "sx": { "apiKey": "your-sx-org-api-key", "mode": "always" },
198
212
  "accounts": [
199
213
  {
200
214
  "name": "user@example.com (Acme)",
@@ -216,12 +230,79 @@ TEAMCLAUDE_CONFIG=./my-config.json teamclaude server
216
230
  | `proxy.port` | Local port the proxy listens on |
217
231
  | `proxy.apiKey` | API key clients use to authenticate with the proxy |
218
232
  | `upstream` | Upstream API base URL |
219
- | `switchThreshold` | Quota utilization (0–1) at which to switch accounts |
233
+ | `switchThreshold` | Quota utilization (0–1) at which to switch accounts (TUI: `g` → `t`) |
234
+ | `quotaProbeSeconds` | Background quota-probe interval in seconds (`0` = off, the default; CLI `probe` or TUI `g` → `p`) |
235
+ | `sx.apiKey` | [sx.org](https://sx.org) API key. When set, TeamClaude auto-provisions a residential proxy (egress-IP 429 workaround). Absent/empty = off |
236
+ | `sx.mode` | `always` (route all upstream traffic), `429` (direct, fail over to the proxy after a 429), or `off` (keep the key but don't use it). Defaults to `always` when a key is set |
220
237
  | `accounts[].accountUuid` | Anthropic account (person) id; set automatically from the OAuth profile |
221
238
  | `accounts[].orgUuid` / `orgName` | Organization the account is scoped to — lets one email hold multiple org accounts |
222
239
  | `accounts[].priority` | Rotation preference, lower = preferred (default 0) |
223
240
  | `accounts[].disabled` | If `true`, the account is excluded from rotation until re-enabled |
224
241
 
242
+ ### Quota probe (optional, off by default)
243
+
244
+ By default TeamClaude is **passive** — it learns each account's quota only from the responses that flow through it, so an account that hasn't been used yet shows unknown quota until it's first rotated to.
245
+
246
+ If you'd rather keep idle accounts' quota fresh, enable the background probe:
247
+
248
+ ```bash
249
+ teamclaude probe 300 # refresh every 300s
250
+ teamclaude probe off # back to passive (default)
251
+ teamclaude probe # show current setting
252
+ ```
253
+
254
+ You can also set the interval live from the TUI settings screen (`g` → `p`), alongside the rotation threshold (`t`).
255
+
256
+ It reads each OAuth account's utilization from Anthropic's usage endpoint (`/api/oauth/usage`), which reports quota **without consuming any message quota**. Minimum interval is 30s. Changing it takes effect on a running server immediately (no restart). When enabled, it also surfaces the **Sonnet 7-day** bucket as an extra bar in the TUI / `status` (when your plan exposes it).
257
+
258
+ ### MITM proxy mode (optional, off by default)
259
+
260
+ The normal reverse-proxy only intercepts what `ANTHROPIC_BASE_URL` covers. Some Claude Code features (e.g. the **Claude Design MCP**) use a **hardcoded** `https://api.anthropic.com` URL that ignores that variable, so they bypass the proxy. MITM proxy mode captures those too.
261
+
262
+ Run claude with the `--mitm` flag:
263
+
264
+ ```bash
265
+ teamclaude run --mitm -- <claude args...>
266
+ ```
267
+
268
+ That launches claude pointed at teamclaude as an **HTTPS forward proxy** (`HTTPS_PROXY`) and trusts a locally-generated CA (`NODE_EXTRA_CA_CERTS`). For an intercepted host, teamclaude **dials the real upstream first, mirrors its negotiated ALPN** (HTTP/2 or HTTP/1.1), then terminates TLS toward claude with the same protocol and relays the traffic **as transparently as possible** — rewriting only what it must:
269
+
270
+ - the **`authorization`** header → the active account's real token (dropping any client `x-api-key`);
271
+ - the **`account_uuid`** inside `metadata.user_id` → the active account's UUID (so the body agrees with the injected token);
272
+ - and it reads `anthropic-ratelimit-*` from responses for quota.
273
+
274
+ Everything else is copied byte-for-byte (HTTP/2 is handled with a built-in HPACK codec so the only header changed is the auth one). Any host other than the upstream is blind-tunnelled. The server accepts *both* base-URL and proxy clients at once, so instances launched with and without `--mitm` can share one server.
275
+
276
+ Trust model:
277
+ - The CA is generated locally, stored in the config dir, and trusted **only** by the claude process you launch via `teamclaude run` (through `NODE_EXTRA_CA_CERTS`) — it is **never** added to your system trust store. The leaf private key is `0600`; the CA private key is never written to disk.
278
+ - teamclaude still verifies the **real** Anthropic certificate on the upstream leg.
279
+
280
+ Verify the proxy + CA without any credentials — the proxy always answers a built-in test host:
281
+
282
+ ```bash
283
+ # (with the server running and certs generated, e.g. after one `teamclaude run`)
284
+ curl --proxy http://localhost:3456 --cacert ~/.config/teamclaude-ca.pem https://www.example.org/
285
+ # → {"teamclaude":"mitm-proxy-ok","host":"www.example.org",...}
286
+ ```
287
+
288
+ ### sx.org proxy mode (optional, off by default)
289
+
290
+ Some transient `429`s key on the proxy's **outbound IP**, not the account — so rotating accounts doesn't help. To work around them, TeamClaude can route upstream requests through a residential proxy from [sx.org](https://sx.org), giving a different egress IP.
291
+
292
+ Open the TUI and press **`g`** for the settings screen, then **`k`** to paste your sx.org API key (stored in `config.sx.apiKey`). TeamClaude reuses an existing active proxy port on your sx.org account, or auto-creates a residential US one, and dials the upstream through it via HTTP `CONNECT` on **both** the reverse-proxy and `--mitm` paths.
293
+
294
+ Press **`m`** to cycle the **mode**:
295
+
296
+ | Mode | Behavior |
297
+ |------|----------|
298
+ | **always** | Tunnel **every** upstream request through sx.org. |
299
+ | **on 429 only** | Connect directly; on a `429` (which is IP-based), immediately retry that request through sx.org's fresh egress IP — no wait. On the `--mitm` path, a recent `429` routes new tunnels through sx.org for a short window. |
300
+ | **off** | Never use sx.org, but **keep the API key** so you can re-enable it instantly. |
301
+
302
+ TLS is established **end-to-end with `api.anthropic.com` over the tunnel**, so the sx.org proxy only ever relays ciphertext and the real Anthropic certificate is still verified. Mode and key changes apply live (no restart). Press **`x`** to forget the key entirely.
303
+
304
+ > **Cost:** in **always** mode *all* Claude traffic flows through the residential proxy, which sx.org meters by bandwidth — expect real per-GB cost. **on 429 only** uses the proxy just when you're actually being throttled, so it's the cheaper way to ride out rate limits.
305
+
225
306
  ## How It Works
226
307
 
227
308
  1. Claude Code connects to the local proxy instead of `api.anthropic.com`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karpeleslab/teamclaude",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "Multi-account Claude proxy with automatic quota-based rotation",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -5,7 +5,8 @@ import { sameIdentity } from './identity.js';
5
5
  // windows, learned passively from upstream responses. Transient/derived state
6
6
  // (probing, requalify, rateLimitedUntil) is intentionally excluded.
7
7
  const PERSISTED_QUOTA_FIELDS = [
8
- 'unified5h', 'unified7d', 'unified5hReset', 'unified7dReset', 'unifiedStatus',
8
+ 'unified5h', 'unified7d', 'unified7dSonnet',
9
+ 'unified5hReset', 'unified7dReset', 'unified7dSonnetReset', 'unifiedStatus',
9
10
  'tokensLimit', 'tokensRemaining', 'requestsLimit', 'requestsRemaining', 'resetsAt',
10
11
  ];
11
12
 
@@ -17,17 +18,22 @@ function emptyQuota() {
17
18
  requestsLimit: null,
18
19
  requestsRemaining: null,
19
20
  // Unified rate limits (Claude Max accounts)
20
- unified5h: null, // utilization 0-1
21
- unified7d: null, // utilization 0-1
22
- unified5hReset: null, // ms timestamp
23
- unified7dReset: null, // ms timestamp
24
- 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
25
28
  resetsAt: null,
26
29
  };
27
30
  }
28
31
 
29
32
  export class AccountManager {
30
- 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;
31
37
  this.accounts = accounts.map((acct, index) => ({
32
38
  index,
33
39
  name: acct.name,
@@ -55,35 +61,114 @@ export class AccountManager {
55
61
  }));
56
62
  this.currentIndex = 0;
57
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;
58
70
  }
59
71
 
60
72
  /**
61
73
  * Get the best available account, rotating if the current one is near quota.
62
74
  * Returns null if all accounts are exhausted.
63
75
  */
64
- getActiveAccount() {
76
+ getActiveAccount(exclude = null) {
65
77
  // Clear expired quotas across all accounts and switch proactively if a
66
78
  // session reset made a sooner-expiring account the better choice. This runs
67
79
  // on every request so the behaviour holds without the TUI render loop.
68
80
  this.refreshExpiredQuotas();
69
81
  const current = this.accounts[this.currentIndex];
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.
70
85
  // We just learned a probed account's weekly quota — re-evaluate which
71
86
  // account is best now that its limit is known.
72
87
  if (current && current.requalify) {
73
88
  current.requalify = false;
74
- const next = this._selectNext();
89
+ const next = this._selectNext(exclude);
75
90
  if (next) return next;
76
91
  }
77
- if (this._isAvailable(current)) {
92
+ if (this._isAvailable(current) && !exclude?.has(current.index)) {
78
93
  // A strictly higher-priority (lower value) available account preempts a
79
94
  // healthy current one. Within the same priority tier we stay put, so the
80
95
  // common case (all accounts at the default priority 0) is unchanged and
81
96
  // never thrashes — preemption only triggers when priorities differ.
82
97
  const betterExists = this.accounts.some(a =>
83
- this._isAvailable(a) && (a.priority || 0) < (current.priority || 0));
84
- return betterExists ? this._selectNext() : current;
98
+ this._isAvailable(a) && !exclude?.has(a.index) && (a.priority || 0) < (current.priority || 0));
99
+ return betterExists ? this._selectNext(exclude) : current;
85
100
  }
86
- return this._selectNext();
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);
132
+ }
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;
87
172
  }
88
173
 
89
174
  _isAvailable(account) {
@@ -134,6 +219,11 @@ export class AccountManager {
134
219
  q.unifiedStatus = null;
135
220
  changed = true;
136
221
  }
222
+ if (q.unified7dSonnet != null && q.unified7dSonnetReset && now >= q.unified7dSonnetReset) {
223
+ q.unified7dSonnet = null;
224
+ q.unified7dSonnetReset = null;
225
+ changed = true;
226
+ }
137
227
 
138
228
  // Clear expired standard quotas
139
229
  if (q.resetsAt && now >= new Date(q.resetsAt).getTime()) {
@@ -224,22 +314,25 @@ export class AccountManager {
224
314
  return false;
225
315
  }
226
316
 
227
- _selectNext() {
228
- // Selection order among available accounts:
229
- // 1. lowest `priority` value (operator-controlled; default 0, lower = preferred)
230
- // 2. then the account with no known weekly limit — using it lets us
231
- // discover its quota
232
- // 3. then the account whose weekly limit expires soonest: that quota is
233
- // closest to refreshing, so spending it first preserves accounts whose
234
- // weekly window resets further out.
235
- // With all priorities at the default 0, this reduces to the original
236
- // weekly-reset heuristic.
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) {
237
329
  let best = null;
238
330
  let bestPriority = Infinity;
239
331
  let bestReset = Infinity;
240
332
 
241
333
  for (let i = 0; i < this.accounts.length; i++) {
242
334
  const account = this.accounts[i];
335
+ if (exclude?.has(account.index)) continue;
243
336
  // _isAvailable filters out accounts at/above the switch threshold, so the
244
337
  // soonest-expiring pick only ever lands on an account whose 5-hour quota
245
338
  // is still below 98%.
@@ -255,7 +348,31 @@ export class AccountManager {
255
348
  best = account;
256
349
  }
257
350
  }
351
+ return best;
352
+ }
258
353
 
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
+ }
373
+
374
+ _selectNext(exclude = null) {
375
+ const best = this._pickBestAvailable(exclude);
259
376
  if (best) {
260
377
  const switched = best.index !== this.currentIndex;
261
378
  this.currentIndex = best.index;
@@ -273,6 +390,7 @@ export class AccountManager {
273
390
  let soonestTime = Infinity;
274
391
 
275
392
  for (const account of this.accounts) {
393
+ if (exclude?.has(account.index)) continue;
276
394
  const resetTime = account.rateLimitedUntil
277
395
  || account.quota.unified5hReset
278
396
  || account.quota.unified7dReset
@@ -380,6 +498,37 @@ export class AccountManager {
380
498
  }
381
499
  }
382
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
+
383
532
  /**
384
533
  * Mark an account as rate-limited for a given duration.
385
534
  */
@@ -408,7 +557,7 @@ export class AccountManager {
408
557
  account._refreshPromise = (async () => {
409
558
  console.log(`[TeamClaude] Refreshing token for account "${account.name}"...`);
410
559
  try {
411
- const newTokens = await refreshAccessToken(account.refreshToken);
560
+ const newTokens = await this._refreshFn(account.refreshToken);
412
561
  account.credential = newTokens.accessToken;
413
562
  account.refreshToken = newTokens.refreshToken;
414
563
  account.expiresAt = newTokens.expiresAt;
@@ -416,10 +565,16 @@ export class AccountManager {
416
565
  this._onTokenRefresh?.(accountIndex, newTokens);
417
566
  } catch (err) {
418
567
  console.error(`[TeamClaude] Token refresh failed for "${account.name}": ${err.message}`);
419
- // Only mark as error if the access token is actually expired;
420
- // a failed proactive refresh shouldn't kill a still-valid token
421
- 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) {
422
576
  account.status = 'error';
577
+ console.error(`[TeamClaude] Account "${account.name}" needs re-login (refresh token rejected) — run: teamclaude login`);
423
578
  }
424
579
  } finally {
425
580
  account._refreshPromise = null;
@@ -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
+ }
@@ -0,0 +1,83 @@
1
+ // Minimal HTTP/2 framing (RFC 7540 §4) — just what the MITM relay needs:
2
+ // split a byte stream into frames, re-serialize frames, and (de)construct
3
+ // HEADERS/CONTINUATION header blocks so we can rewrite one header and re-emit.
4
+ // All other frame types are forwarded verbatim by the relay.
5
+
6
+ export const FRAME = {
7
+ DATA: 0x0, HEADERS: 0x1, PRIORITY: 0x2, RST_STREAM: 0x3, SETTINGS: 0x4,
8
+ PUSH_PROMISE: 0x5, PING: 0x6, GOAWAY: 0x7, WINDOW_UPDATE: 0x8, CONTINUATION: 0x9,
9
+ };
10
+ export const FLAG = { END_STREAM: 0x1, END_HEADERS: 0x4, PADDED: 0x8, PRIORITY: 0x20 };
11
+
12
+ // Client connection preface: "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" (RFC 7540 §3.5).
13
+ export const PREFACE = Buffer.from('505249202a20485454502f322e300d0a0d0a534d0d0a0d0a', 'hex');
14
+
15
+ /**
16
+ * Split as many complete frames as possible out of `buf`.
17
+ * Returns { frames, rest } where rest is the unconsumed tail (incomplete frame).
18
+ * Each frame: { type, flags, streamId, payload, raw } (payload/raw are subarrays).
19
+ */
20
+ export function readFrames(buf) {
21
+ const frames = [];
22
+ let off = 0;
23
+ while (buf.length - off >= 9) {
24
+ const length = buf.readUIntBE(off, 3);
25
+ if (buf.length - off < 9 + length) break;
26
+ frames.push({
27
+ type: buf[off + 3],
28
+ flags: buf[off + 4],
29
+ streamId: buf.readUInt32BE(off + 5) & 0x7fffffff,
30
+ payload: buf.subarray(off + 9, off + 9 + length),
31
+ raw: buf.subarray(off, off + 9 + length),
32
+ });
33
+ off += 9 + length;
34
+ }
35
+ return { frames, rest: buf.subarray(off) };
36
+ }
37
+
38
+ /** Serialize one frame. */
39
+ export function buildFrame({ type, flags = 0, streamId, payload = Buffer.alloc(0) }) {
40
+ const h = Buffer.alloc(9);
41
+ h.writeUIntBE(payload.length, 0, 3);
42
+ h[3] = type;
43
+ h[4] = flags;
44
+ h.writeUInt32BE(streamId & 0x7fffffff, 5);
45
+ return Buffer.concat([h, payload]);
46
+ }
47
+
48
+ /**
49
+ * Pull the header-block fragment out of a HEADERS payload, stripping any
50
+ * PADDED / PRIORITY prefixes. Returns { block, priority } where priority is the
51
+ * 5-byte priority field (or null). Padding is discarded.
52
+ */
53
+ export function stripHeadersPayload(payload, flags) {
54
+ let off = 0;
55
+ let padLen = 0;
56
+ if (flags & FLAG.PADDED) { padLen = payload[0]; off = 1; }
57
+ let priority = null;
58
+ if (flags & FLAG.PRIORITY) { priority = Buffer.from(payload.subarray(off, off + 5)); off += 5; }
59
+ const block = Buffer.from(payload.subarray(off, payload.length - padLen));
60
+ return { block, priority };
61
+ }
62
+
63
+ /**
64
+ * Build a HEADERS frame (+ CONTINUATION frames if the block exceeds
65
+ * maxFrameSize) for a re-encoded header block. Padding is not re-added.
66
+ */
67
+ export function buildHeaderBlock(streamId, block, { endStream = false, priority = null, maxFrameSize = 16384 } = {}) {
68
+ const prio = priority || Buffer.alloc(0);
69
+ const firstCap = Math.max(0, maxFrameSize - prio.length);
70
+ const firstChunk = block.subarray(0, firstCap);
71
+ let rest = block.subarray(firstChunk.length);
72
+
73
+ let hFlags = (endStream ? FLAG.END_STREAM : 0) | (priority ? FLAG.PRIORITY : 0);
74
+ if (rest.length === 0) hFlags |= FLAG.END_HEADERS;
75
+
76
+ const out = [buildFrame({ type: FRAME.HEADERS, flags: hFlags, streamId, payload: Buffer.concat([prio, firstChunk]) })];
77
+ while (rest.length) {
78
+ const chunk = rest.subarray(0, maxFrameSize);
79
+ rest = rest.subarray(chunk.length);
80
+ out.push(buildFrame({ type: FRAME.CONTINUATION, flags: rest.length === 0 ? FLAG.END_HEADERS : 0, streamId, payload: chunk }));
81
+ }
82
+ return Buffer.concat(out);
83
+ }