@rikcodes/teamclaude 1.1.20-rik.1 → 1.1.20-rik.11

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
@@ -1,7 +1,7 @@
1
1
  # TeamClaude
2
2
 
3
3
  > **Fork notice (rikbrown).** This fork adds two features on top of
4
- > [KarpelesLab/teamclaude](https://github.com/KarpelesLab/teamclaude), currently based on upstream 1.1.19:
4
+ > [KarpelesLab/teamclaude](https://github.com/KarpelesLab/teamclaude), currently based on upstream 1.1.20:
5
5
  >
6
6
  > - **[OpenAI models via a Codex sidecar](docs/openai.md)** (`sidecars` + `customModels`, opt-in):
7
7
  > route `gpt-*` requests through a supervised local translating proxy to a ChatGPT subscription,
@@ -150,7 +150,7 @@ At launch, `teamclaude run` — and the `claude` alias, which passes through `ru
150
150
  | Row field | Where it ends up |
151
151
  | --- | --- |
152
152
  | `model`, `label`, `description` | A `/model` picker row under the **real** model id (`--settings`), so `/model gpt-5.6-sol` works picked or typed |
153
- | `model` | A dispatchable subagent named after the model (`--agents`), so "dispatch a `gpt-5.6-terra` subagent" works from a Claude parent |
153
+ | `model` | A dispatchable subagent named after the model (`--agents`), so "dispatch a `gpt-5.6-terra` subagent" works from a Claude parent. Set `"customModelAgents": false` to skip these if you define your own agents in `~/.claude/agents/` |
154
154
  | `contextTokens` | `CLAUDE_CODE_MAX_CONTEXT_TOKENS`, set to the largest value across rows, so Claude Code compacts at the real window instead of assuming 200k |
155
155
 
156
156
  For tools that spawn `claude` themselves, `teamclaude env` can set only environment variables. It carries the window and `ANTHROPIC_CUSTOM_MODEL_OPTION` for the **first** row. For GPT subagents under `env`, create `~/.claude/agents/<name>.md` with `model: gpt-5.6-terra` in its frontmatter.
@@ -163,9 +163,67 @@ Each request is routed by the model name in its body, so one session can freely
163
163
  2. Add a `customModels` row. Codex publishes the window for each model as `context_window` in `~/.codex/models_cache.json`; copy it to `contextTokens`.
164
164
  3. Start a new `teamclaude run` session. The rows are read at launch, so you do not need to restart the server. If you upgraded the sidecar binary, restart the server — or send `SIGTERM` to the sidecar process and let the supervisor restart it with the new binary.
165
165
 
166
- Claude Code prints one `[claude-code:unrecognized_model]` line to stderr for each custom model. This is expected; suppressing it would lose the correct context window. The quota bars for the sidecar account show `unknown` unless the sidecar forwards Codex's rate-limit headers — see [Quota](docs/openai.md#quota). Keep the sidecar on loopback, and use **one** ChatGPT subscription for each person. Pooling several subscriptions is the pattern that OpenAI's fraud systems target ([terms of service](docs/openai.md#terms-of-service)).
166
+ Claude Code prints one `[claude-code:unrecognized_model]` line to stderr for each custom model. This is expected; suppressing it would lose the correct context window. The quota bars for the sidecar account show `unknown` unless the sidecar forwards Codex's rate-limit headers — see [Quota](docs/openai.md#quota). Keep the sidecar on loopback.
167
167
 
168
- Full details: [docs/openai.md](docs/openai.md).
168
+ The sidecar appears under the account table as a `⚙` line rather than a row because it holds no subscription, is the only account its route can use, and never rotates. The line also shows its supervised process state (`up pid 98018`, or `down (code 1) 3 restarts`).
169
+
170
+ #### Several ChatGPT accounts
171
+
172
+ > **Read the [terms of service](docs/openai.md#terms-of-service) before setting this up.** OpenAI's Terms of Use prohibit rotating ChatGPT subscriptions past a spent window, and account suspension is a plausible consequence. This is a sharper trade-off than pooling Claude subscriptions because the first-party client lets you switch Claude subscriptions by hand.
173
+
174
+ One sidecar holds one ChatGPT login, so GPT requests do not rotate and its quota belongs to a login that TeamClaude does not own. Point the sidecar's **back leg** at TeamClaude so native Codex accounts serve it instead:
175
+
176
+ ```
177
+ Claude Code ──▶ TC /v1/messages (gpt-*) ──▶ sidecar account ──▶ sidecar translates
178
+ ──▶ TC /backend-api/codex/responses ──▶ ChatGPT account pool ──▶ chatgpt.com
179
+ ```
180
+
181
+ Each hop is classified by its path, and the subscription partition keeps the pools apart: on the way in only the sidecar account is eligible, on the way back only the ChatGPT accounts. One route lists both.
182
+
183
+ **1. Add the accounts** — run `teamclaude login --codex` once for each one. A Codex login takes its email as its name. Your Anthropic account probably uses the same name, so the Codex name gets a prefix to keep it unambiguous:
184
+
185
+ ```
186
+ $ teamclaude login --codex
187
+ Named "codex:you@example.com" — "you@example.com" is already an account on another provider.
188
+ ```
189
+
190
+ **2. Redirect the sidecar** and stub its own login, so TeamClaude supplies the credential instead:
191
+
192
+ ```json
193
+ { "name": "codex",
194
+ "command": ["claude-code-proxy", "serve", "--no-monitor", "--port", "18765"],
195
+ "env": {
196
+ "CCP_CODEX_BASE_URL": "http://127.0.0.1:3456/backend-api/codex/responses",
197
+ "CCP_CODEX_TRANSPORT": "http"
198
+ } }
199
+ ```
200
+
201
+ ```bash
202
+ cd ~/.config/claude-code-proxy/codex
203
+ cp auth.json auth.json.bak # the real login — keep it
204
+ echo '{ "access": "delegated-to-teamclaude", "refresh": "", "expires": 4102444800000 }' > auth.json
205
+ ```
206
+
207
+ The sidecar refuses to start with an empty store but never refreshes a far-future token, and TeamClaude replaces both the bearer and the account header on the way out. Leave `accountId` unset so none of the sidecar's own identity can leak.
208
+
209
+ **3. Put them all on the `gpt-*` route**, sidecar included, and give each account a `headersTimeoutMs` — the 120s fleet default is shorter than a long reasoning turn:
210
+
211
+ ```json
212
+ { "name": "codex", "match": ["gpt-*"],
213
+ "accounts": ["codex", "codex:you@example.com", "codex:you@work.example"] }
214
+ ```
215
+
216
+ **4. Restart the server.** A `sidecars[].env` change is read once at startup, so a reload is not enough.
217
+
218
+ Three details matter:
219
+
220
+ - **Leave the sidecar account on the route.** It can look removable because it is not a subscription or an account row, but it is the routing target for the way *in*. Without it, every `gpt-*` request fails to find an account while `teamclaude status` shows two healthy ChatGPT accounts on the route.
221
+ - **`CCP_CODEX_TRANSPORT=http` is required.** A WebSocket upgrade is relayed with the caller's own headers and draws no account, so the WebSocket transport cannot be pooled.
222
+ - **Do not reuse a name across providers.** Routes address accounts by name, so a shared name admits both — including the Claude account that cannot serve `gpt-*`, which outranks the sidecar on priority and wins. TeamClaude warns at startup when it sees one.
223
+
224
+ Two things differ from the single-account setup: each turn appears **twice** in the activity list, once per hop, and tokens are booked against the sidecar account, so a ChatGPT account reads `N req · 0 tok`. Its quota bars are unaffected because they come from the `x-codex-*` headers on the second hop, where the subscription is.
225
+
226
+ Full details, including what happens to quota on each hop: [Several ChatGPT accounts behind one sidecar](docs/openai.md#several-chatgpt-accounts-behind-one-sidecar).
169
227
 
170
228
  ### Burn-rate projection
171
229
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rikcodes/teamclaude",
3
- "version": "1.1.20-rik.1",
3
+ "version": "1.1.20-rik.11",
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",
@@ -1,5 +1,5 @@
1
1
  import { refreshAccessToken, isTokenExpiringSoon, isTokenExpired, formatMoney } from './oauth.js';
2
- import { providerOf, DEFAULT_PROVIDER, isSubscriptionAccount } from './provider.js';
2
+ import { providerOf, DEFAULT_PROVIDER, isSubscriptionAccount, isLocalUpstream } from './provider.js';
3
3
  import { refreshCodexToken } from './codex-auth.js';
4
4
  import { parseCodexQuota, parseCodexPlanType } from './codex-quota.js';
5
5
  import { sameIdentity } from './identity.js';
@@ -221,6 +221,13 @@ function makeAccount(acct, index) {
221
221
  // Fields to drop from request bodies for this account (third-party upstreams
222
222
  // that reject e.g. `context_management`). See server.js stripBodyFields.
223
223
  stripRequestFields: acct.stripRequestFields || null,
224
+ // Whether this upstream keeps Anthropic message-thread state. Off for a
225
+ // third-party backend, which would otherwise be handed a bare delta. See
226
+ // server.js refusesThreadContinue.
227
+ messageThreads: acct.messageThreads === true,
228
+ // Whether the operator has already been told this upstream keeps no thread
229
+ // state, so the line is printed once rather than per refusal.
230
+ threadRefusalReported: false,
224
231
  // Per-account response-headers deadline (ms); null means the fleet default
225
232
  // in upstream-fetch.js. See normalizeHeadersTimeoutMs.
226
233
  headersTimeoutMs: normalizeHeadersTimeoutMs(acct.headersTimeoutMs),
@@ -355,6 +362,10 @@ export class AccountManager {
355
362
  // it. Each such switch arms the ramp below, so steady interleaved traffic
356
363
  // holds both accounts at the ramp floor while nothing has failed over.
357
364
  this.routeCursors = new Map();
365
+ /** Rotation for requests that carry no session id; separate from the shared
366
+ * cursor so turning it never moves a running session's account.
367
+ * @type {number} */
368
+ this._untaggedCursor = 0;
358
369
  // One cursor per provider. `currentIndex` is a single slot, and a request
359
370
  // only another provider can serve would otherwise drag it across: a Codex
360
371
  // request moved it onto a Codex account and the next Anthropic request moved
@@ -688,7 +699,7 @@ export class AccountManager {
688
699
  if (!account?.entitlementDeniedUntil) return false;
689
700
  if (now < account.entitlementDeniedUntil) return true;
690
701
  account.entitlementDeniedUntil = null;
691
- console.log(`[TeamClaude] Account "${account.name}" entitlement cooldown expired, marking available`);
702
+ console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" entitlement cooldown expired, marking available`);
692
703
  return false;
693
704
  }
694
705
 
@@ -851,6 +862,12 @@ export class AccountManager {
851
862
  if (acc) return acc;
852
863
  }
853
864
  }
865
+ // An untagged request has no session to pin, so the walk below rests every
866
+ // one of them on the current account. Spread them too, on their own cursor.
867
+ if (!sessionId && this.distributeSessions && !this._pinnedAccountForModel(model, advisorModel)) {
868
+ const acc = this._selectUntagged(exclude, model, advisorModel);
869
+ if (acc) return acc;
870
+ }
854
871
  if (advisorModel) {
855
872
  const account = this._select(exclude, model, advisorModel, false);
856
873
  if (account) return account;
@@ -944,6 +961,41 @@ export class AccountManager {
944
961
  * eligible account. Returns null if nothing is eligible, so the caller falls
945
962
  * back to the normal quota-driven walk. Does NOT record the pin — that happens
946
963
  * on the actual route (recordSession), so retries/failover re-pin naturally. */
964
+ /**
965
+ * Round-robin for requests that carry no session id.
966
+ *
967
+ * The Codex CLI tags `POST /responses` with `session-id` but not its catalog
968
+ * fetch, and Claude Code tags `/v1/messages` but not its telemetry. Those
969
+ * untagged requests have nothing to pin, so the walk below rests all of them
970
+ * on the current account: measured against a five-account Codex pool with
971
+ * distribution on, every session's `GET /models` landed on one account, 16
972
+ * requests against 1 apiece for its siblings.
973
+ *
974
+ * `_pickLeastLoaded` does not spread them. An untagged request never reaches
975
+ * `recordSession`, so it adds no session count, and a serial caller's
976
+ * in-flight is back to zero by the time the next one arrives — the tiebreak
977
+ * chain is level every time and answers with the same account. Hence a cursor
978
+ * of its own.
979
+ *
980
+ * That cursor is its own on purpose: `_setCurrent` is what sessions riding
981
+ * the shared one follow, and moving it from a catalog fetch would hand a
982
+ * running conversation to another account mid-flight and throw away the
983
+ * prompt cache it built there. An untagged request picks where it goes and
984
+ * changes nothing for anyone else.
985
+ *
986
+ * @param {Set<number>|null} exclude
987
+ * @param {string|null} model
988
+ * @param {string|null} advisorModel
989
+ * @returns {Record<string, any>|null}
990
+ */
991
+ _selectUntagged(exclude, model, advisorModel) {
992
+ const candidates = this._bandedCandidates(exclude, model, advisorModel);
993
+ if (candidates.length === 0) return null;
994
+ const cursor = this._untaggedCursor;
995
+ this._untaggedCursor = cursor + 1;
996
+ return candidates[cursor % candidates.length];
997
+ }
998
+
947
999
  _selectForSession(sessionId, exclude, model, advisorModel) {
948
1000
  // The pin is per governing bucket, and this request is bound by the
949
1001
  // EXECUTOR's: one request goes to one account, so the executor's affinity is
@@ -1680,7 +1732,7 @@ export class AccountManager {
1680
1732
  account.status = 'active';
1681
1733
  account.rateLimitedUntil = null;
1682
1734
  account.throttledAt = null;
1683
- console.log(`[TeamClaude] Account "${account.name}" rate limit expired, marking active`);
1735
+ console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" rate limit expired, marking active`);
1684
1736
  }
1685
1737
 
1686
1738
  if (account.status === 'exhausted') return 'exhausted';
@@ -2177,6 +2229,12 @@ export class AccountManager {
2177
2229
  * `_isAvailable` excludes accounts at or above the switch threshold. Shared by
2178
2230
  * both selection loops so they cannot disagree on the candidate set.
2179
2231
  */
2232
+ /**
2233
+ * @param {Set<number>|null} [exclude]
2234
+ * @param {string|null} [model]
2235
+ * @param {string|null} [advisorModel]
2236
+ * @returns {Array<Record<string, any>>}
2237
+ */
2180
2238
  _bandedCandidates(exclude = null, model = null, advisorModel = null) {
2181
2239
  return this._topPressureBand(
2182
2240
  this.accounts.filter(a => !exclude?.has(a.index) && this._isAvailable(a, model, advisorModel)),
@@ -2547,7 +2605,7 @@ export class AccountManager {
2547
2605
  const now = Date.now();
2548
2606
  if (now < (this._rolloverHeldLogAt.get(key) || 0)) return;
2549
2607
  this._rolloverHeldLogAt.set(key, now + 60_000);
2550
- console.log(`[TeamClaude] Account "${account.name}" rolled over its ${window} window ${this._heldRolloverReason(reason)}`);
2608
+ console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" rolled over its ${window} window ${this._heldRolloverReason(reason)}`);
2551
2609
  }
2552
2610
 
2553
2611
  /**
@@ -2699,14 +2757,27 @@ export class AccountManager {
2699
2757
  }
2700
2758
 
2701
2759
  /** Accounts a configured route can use (all accounts when it lists none), each
2702
- * with a live eligibility flag for a representative model of the route. */
2760
+ * with a live eligibility flag for a representative model of the route and the
2761
+ * provider that will serve it.
2762
+ *
2763
+ * A route that NAMES its accounts is shown in full, whatever provider each one
2764
+ * belongs to. One route legitimately spans two: a translating sidecar reached
2765
+ * on the Anthropic wire, plus the subscription pool its own back leg re-enters
2766
+ * on the provider's path. Both hops are that route's traffic. Filtering the
2767
+ * view by a single provider hid the second set completely — the route looked
2768
+ * like it listed one account, and a newly added subscription that nobody had
2769
+ * added to the list was invisible rather than merely idle, which is the exact
2770
+ * shape of the diagnosis this view exists to prevent.
2771
+ *
2772
+ * A route that lists NOBODY is different: it constrains models, not accounts,
2773
+ * so only the asking provider's own pool can serve it and the partition still
2774
+ * applies. */
2703
2775
  _routeAccountsView(route, provider = DEFAULT_PROVIDER) {
2704
2776
  const sample = sampleModelFor(route);
2705
- const excluded = this._excludeOtherProviders(null, provider);
2706
- const inRoute = a => !route.accounts.length
2707
- || route.accounts.includes(a.name) || route.accounts.includes(String(a.index));
2708
- return this.accounts.filter(a => inRoute(a) && !excluded?.has(a.index))
2709
- .map(a => ({ name: a.name, eligible: this._isAvailable(a, sample) }));
2777
+ const listed = route.accounts.length
2778
+ ? this.accounts.filter(a => route.accounts.includes(a.name) || route.accounts.includes(String(a.index)))
2779
+ : this.accounts.filter(a => !this._excludeOtherProviders(null, provider)?.has(a.index));
2780
+ return listed.map(a => ({ name: a.name, provider: providerOf(a), eligible: this._isAvailable(a, sample) }));
2710
2781
  }
2711
2782
 
2712
2783
  /** A representative model id for a route name (configured or auto fable/sonnet),
@@ -2785,7 +2856,7 @@ export class AccountManager {
2785
2856
 
2786
2857
  // Clear expired unified quotas
2787
2858
  if (q.unified5h != null && q.unified5hReset && now >= q.unified5hReset) {
2788
- console.log(`[TeamClaude] Account "${account.name}" session quota reset`);
2859
+ console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" session quota reset`);
2789
2860
  // Recorded on the account, not just returned. _clearExpiredQuotas is
2790
2861
  // reached from two directions — refreshExpiredQuotas on the request path,
2791
2862
  // which runs the session-reset switch rule, and _isNearQuota via
@@ -2805,7 +2876,7 @@ export class AccountManager {
2805
2876
  session = true;
2806
2877
  }
2807
2878
  if (q.unified7d != null && q.unified7dReset && now >= q.unified7dReset) {
2808
- console.log(`[TeamClaude] Account "${account.name}" weekly quota reset`);
2879
+ console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" weekly quota reset`);
2809
2880
  q.unified7d = null;
2810
2881
  q.unified7dReset = null;
2811
2882
  q.unifiedStatus = null;
@@ -2849,7 +2920,7 @@ export class AccountManager {
2849
2920
  // so a reading is never discarded before it has had a window to prove out.
2850
2921
  if (!q[seenField]) { q[seenField] = now; continue; }
2851
2922
  if (now < q[seenField] + this.familyStaleMs) continue;
2852
- console.log(`[TeamClaude] Account "${account.name}" ${label} weekly reading is stale — revalidating on the next ${label} request`);
2923
+ console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" ${label} weekly reading is stale — revalidating on the next ${label} request`);
2853
2924
  q[key] = null;
2854
2925
  q[`${key}Reset`] = null;
2855
2926
  q[seenField] = null;
@@ -3284,7 +3355,7 @@ export class AccountManager {
3284
3355
  if (account.probing && account.quota.unified7dReset != null) {
3285
3356
  account.probing = false;
3286
3357
  account.requalify = true;
3287
- console.log(`[TeamClaude] Learned weekly quota for "${account.name}", re-evaluating selection`);
3358
+ console.log(`[TeamClaude] Learned weekly quota for "${safeLine(account.name, 64)}", re-evaluating selection`);
3288
3359
  }
3289
3360
 
3290
3361
  this._observeBurnRate(account, observed);
@@ -3294,7 +3365,7 @@ export class AccountManager {
3294
3365
 
3295
3366
  if (this._isNearQuota(account)) {
3296
3367
  const pct = account.quota.unified7d != null ? Math.round(account.quota.unified7d * 100) : null;
3297
- console.log(`[TeamClaude] "${account.name}" near weekly quota${pct == null ? '' : ` (${pct}%)`}`);
3368
+ console.log(`[TeamClaude] "${safeLine(account.name, 64)}" near weekly quota${pct == null ? '' : ` (${pct}%)`}`);
3298
3369
  }
3299
3370
  }
3300
3371
 
@@ -3351,7 +3422,7 @@ export class AccountManager {
3351
3422
  if (account.probing && account.quota.unified7dReset != null) {
3352
3423
  account.probing = false;
3353
3424
  account.requalify = true;
3354
- console.log(`[TeamClaude] Learned weekly quota for "${account.name}", re-evaluating selection`);
3425
+ console.log(`[TeamClaude] Learned weekly quota for "${safeLine(account.name, 64)}", re-evaluating selection`);
3355
3426
  }
3356
3427
 
3357
3428
  // `unified-status` is upstream's verdict on THIS response. A family-cap 429
@@ -3384,14 +3455,26 @@ export class AccountManager {
3384
3455
  // display, projection and switch-threshold logic apply unchanged. A window
3385
3456
  // with no length is a bucket the plan does not have, not one at 0% used.
3386
3457
  // used-percent is 0-100 (not the 0-1 fraction Anthropic reports).
3387
- for (const window of ['primary', 'secondary']) {
3388
- const used = parseFloat(headers[`x-codex-${window}-used-percent`]);
3389
- const minutes = parseInt(headers[`x-codex-${window}-window-minutes`], 10);
3390
- if (isNaN(used) || !(minutes > 0)) continue;
3391
- const reset = parseResetAt(headers[`x-codex-${window}-reset-at`]);
3392
- const weekly = minutes > CODEX_WEEKLY_MIN_MINUTES;
3393
- account.quota[weekly ? 'unified7d' : 'unified5h'] = used / 100;
3394
- if (reset != null) account.quota[weekly ? 'unified7dReset' : 'unified5hReset'] = reset;
3458
+ //
3459
+ // Unless this account is a CONDUIT: a local proxy whose own back leg draws
3460
+ // on the Codex accounts in this same fleet. Then the numbers it forwards
3461
+ // belong to whichever of them served, and filing them here makes the
3462
+ // conduit's bars a copy of the last one to answer. That is not merely a
3463
+ // wrong readout — the conduit is the only account its route can use on the
3464
+ // way in, so borrowing a spent account's number takes it below the switch
3465
+ // threshold and every request fails, while a sibling sits at 0%.
3466
+ // A standalone sidecar (no Codex accounts here) is NOT a conduit: it holds
3467
+ // its own login, the forwarded numbers are its own, and they still apply.
3468
+ if (!(isLocalUpstream(account) && this.accounts.some(a => providerOf(a) === 'codex'))) {
3469
+ for (const window of ['primary', 'secondary']) {
3470
+ const used = parseFloat(headers[`x-codex-${window}-used-percent`]);
3471
+ const minutes = parseInt(headers[`x-codex-${window}-window-minutes`], 10);
3472
+ if (isNaN(used) || !(minutes > 0)) continue;
3473
+ const reset = parseResetAt(headers[`x-codex-${window}-reset-at`]);
3474
+ const weekly = minutes > CODEX_WEEKLY_MIN_MINUTES;
3475
+ account.quota[weekly ? 'unified7d' : 'unified5h'] = used / 100;
3476
+ if (reset != null) account.quota[weekly ? 'unified7dReset' : 'unified5hReset'] = reset;
3477
+ }
3395
3478
  }
3396
3479
 
3397
3480
  // Standard rate limits (API key accounts)
@@ -3427,7 +3510,7 @@ export class AccountManager {
3427
3510
  : account.quota.tokensLimit
3428
3511
  ? ((1 - account.quota.tokensRemaining / account.quota.tokensLimit) * 100).toFixed(1)
3429
3512
  : '?';
3430
- console.log(`[TeamClaude] Account "${account.name}" at ${pct}% usage — will switch on next request`);
3513
+ console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" at ${pct}% usage — will switch on next request`);
3431
3514
  }
3432
3515
  }
3433
3516
 
@@ -3520,7 +3603,7 @@ export class AccountManager {
3520
3603
  // drop the dead-token guard too — otherwise the account would come back
3521
3604
  // active but never attempt a refresh (see ensureTokenFresh).
3522
3605
  account._deadRefreshToken = null;
3523
- console.log(`[TeamClaude] Account "${account.name}" re-enabled — clearing error state`);
3606
+ console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" re-enabled — clearing error state`);
3524
3607
  }
3525
3608
  }
3526
3609
 
@@ -3585,7 +3668,7 @@ export class AccountManager {
3585
3668
  }
3586
3669
  // Worth a line: the account was refusing this family and is not any more.
3587
3670
  if (wasSpent && !(q[key] != null && q[key] >= this.thresholdFor(key))) {
3588
- console.log(`[TeamClaude] Account "${account.name}" ${label} weekly quota confirmed available by probe`);
3671
+ console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" ${label} weekly quota confirmed available by probe`);
3589
3672
  }
3590
3673
  }
3591
3674
  // Families beyond the two with dedicated fields. Replaced wholesale rather
@@ -3609,11 +3692,11 @@ export class AccountManager {
3609
3692
  const was = q.spend;
3610
3693
  q.spend = { ...usage.spend };
3611
3694
  if (q.spend.enabled && !was?.enabled) {
3612
- console.log(`[TeamClaude] Account "${account.name}" can bill real money past its plan limits (extra usage is enabled upstream)`);
3695
+ console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" can bill real money past its plan limits (extra usage is enabled upstream)`);
3613
3696
  }
3614
3697
  const spentNow = (q.spend.usedMinor || 0) > 0;
3615
3698
  if (spentNow && !((was?.usedMinor || 0) > 0)) {
3616
- console.log(`[TeamClaude] Account "${account.name}" has started spending real money: ${formatMoney(q.spend)}`);
3699
+ console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" has started spending real money: ${formatMoney(q.spend)}`);
3617
3700
  }
3618
3701
  }
3619
3702
 
@@ -3692,7 +3775,7 @@ export class AccountManager {
3692
3775
  // after throttleProbeFloorMs from here, so a probe that 429s again pushes
3693
3776
  // the next probe out by a full floor rather than hammering upstream.
3694
3777
  account.throttledAt = Date.now();
3695
- console.log(`[TeamClaude] Account "${account.name}" rate limited for ${retryAfterSeconds}s`);
3778
+ console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" rate limited for ${retryAfterSeconds}s`);
3696
3779
  }
3697
3780
 
3698
3781
  /**
@@ -3706,7 +3789,7 @@ export class AccountManager {
3706
3789
  account.status = 'active';
3707
3790
  account.rateLimitedUntil = null;
3708
3791
  account.throttledAt = null;
3709
- console.log(`[TeamClaude] Account "${account.name}" revalidated — rate limit no longer applies, back in rotation`);
3792
+ console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" revalidated — rate limit no longer applies, back in rotation`);
3710
3793
  }
3711
3794
 
3712
3795
  /**
@@ -3743,7 +3826,7 @@ export class AccountManager {
3743
3826
  if (account._deadRefreshToken && account._deadRefreshToken === account.refreshToken) {
3744
3827
  if (account.status !== 'error') {
3745
3828
  account.status = 'error';
3746
- console.error(`[TeamClaude] Account "${account.name}" still holds a rejected refresh token — run: teamclaude login`);
3829
+ console.error(`[TeamClaude] Account "${safeLine(account.name, 64)}" still holds a rejected refresh token — run: teamclaude login`);
3747
3830
  }
3748
3831
  return;
3749
3832
  }
@@ -3769,7 +3852,7 @@ export class AccountManager {
3769
3852
  if (account._refreshPromise) return account._refreshPromise;
3770
3853
 
3771
3854
  account._refreshPromise = (async () => {
3772
- console.log(`[TeamClaude] Refreshing token for account "${account.name}"...`);
3855
+ console.log(`[TeamClaude] Refreshing token for account "${safeLine(account.name, 64)}"...`);
3773
3856
  // The token we SEND, captured before the await. A config reload or
3774
3857
  // `teamclaude import` (updateAccountTokens) can install newer tokens while
3775
3858
  // the grant is in flight; reading `account.refreshToken` afterwards would
@@ -3786,7 +3869,7 @@ export class AccountManager {
3786
3869
  ? this._codexRefreshFn(sent)
3787
3870
  : this._refreshFn(sent));
3788
3871
  if (account.refreshToken !== sent) {
3789
- console.log(`[TeamClaude] Discarding refresh result for account "${account.name}" — its tokens were replaced while the refresh was in flight`);
3872
+ console.log(`[TeamClaude] Discarding refresh result for account "${safeLine(account.name, 64)}" — its tokens were replaced while the refresh was in flight`);
3790
3873
  return;
3791
3874
  }
3792
3875
  account.credential = newTokens.accessToken;
@@ -3794,10 +3877,10 @@ export class AccountManager {
3794
3877
  account.expiresAt = newTokens.expiresAt;
3795
3878
  account._lastRefreshAt = Date.now();
3796
3879
  account._deadRefreshToken = null; // this token works; clear any stale guard
3797
- console.log(`[TeamClaude] Token refreshed for account "${account.name}"`);
3880
+ console.log(`[TeamClaude] Token refreshed for account "${safeLine(account.name, 64)}"`);
3798
3881
  this._onTokenRefresh?.(accountIndex, newTokens);
3799
3882
  } catch (err) {
3800
- console.error(`[TeamClaude] Token refresh failed for "${account.name}": ${err.message}`);
3883
+ console.error(`[TeamClaude] Token refresh failed for "${safeLine(account.name, 64)}": ${err.message}`);
3801
3884
  // Reserve 'error' (which drops the account from rotation until re-login)
3802
3885
  // for a GENUINE auth rejection: the refresh token itself is no longer
3803
3886
  // valid — revoked, or invalidated by an account/plan migration. A
@@ -3813,11 +3896,11 @@ export class AccountManager {
3813
3896
  // a token imported mid-refresh stays untouched and gets its own try.
3814
3897
  account._deadRefreshToken = sent;
3815
3898
  if (account.refreshToken !== sent) {
3816
- console.log(`[TeamClaude] Account "${account.name}" received new tokens while its old refresh token was being rejected — keeping the new ones`);
3899
+ console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" received new tokens while its old refresh token was being rejected — keeping the new ones`);
3817
3900
  return;
3818
3901
  }
3819
3902
  account.status = 'error';
3820
- console.error(`[TeamClaude] Account "${account.name}" needs re-login (refresh token rejected) — run: teamclaude login`);
3903
+ console.error(`[TeamClaude] Account "${safeLine(account.name, 64)}" needs re-login (refresh token rejected) — run: teamclaude login`);
3821
3904
  }
3822
3905
  } finally {
3823
3906
  account._refreshPromise = null;
@@ -3847,7 +3930,7 @@ export class AccountManager {
3847
3930
  if (refreshToken) account.refreshToken = refreshToken;
3848
3931
  account.expiresAt = expiresAt;
3849
3932
  if (account.status === 'error') account.status = 'active';
3850
- console.log(`[TeamClaude] Updated tokens for account "${account.name}"`);
3933
+ console.log(`[TeamClaude] Updated tokens for account "${safeLine(account.name, 64)}"`);
3851
3934
  this._onTokenRefresh?.(accountIndex, {
3852
3935
  accessToken,
3853
3936
  refreshToken: account.refreshToken,
@@ -67,6 +67,12 @@ function collectFamilies(headers) {
67
67
  return families;
68
68
  }
69
69
 
70
+ /**
71
+ * One window reading: utilization as a 0-1 fraction, reset as ms epoch or null.
72
+ *
73
+ * @typedef {{utilization: number, resetAt: number|null}} QuotaWindow
74
+ */
75
+
70
76
  /**
71
77
  * Turn one family's windows into `{ fiveHour, weekly }` readings, keyed by the
72
78
  * window's own duration rather than its primary/secondary position.
@@ -74,8 +80,12 @@ function collectFamilies(headers) {
74
80
  * A window with no `window-minutes`, a zero duration, or an unparseable
75
81
  * utilization is dropped: a zeroed window is how this API says "not
76
82
  * applicable", and treating that as 0% used would look like full headroom.
83
+ *
84
+ * @param {Record<string, {usedPercent?: number, windowMinutes?: number, resetAt?: number}>} windows
85
+ * @returns {{fiveHour?: QuotaWindow, weekly?: QuotaWindow}}
77
86
  */
78
87
  function classify(windows) {
88
+ /** @type {{fiveHour?: QuotaWindow, weekly?: QuotaWindow}} */
79
89
  const out = {};
80
90
  for (const w of Object.values(windows)) {
81
91
  const minutes = Number(w.windowMinutes);
@@ -122,12 +132,19 @@ export function parseCodexQuota(headers) {
122
132
  if (account.weekly.resetAt) quota.unified7dReset = account.weekly.resetAt;
123
133
  }
124
134
 
125
- // Model-scoped families. Their 5-hour window is not modelled separately —
126
- // the manager scopes eligibility by weekly family buckets — so only the
127
- // weekly reading is carried, alongside the name upstream gave it.
135
+ // Model-scoped families. Their weekly reading is the family bucket, carried
136
+ // alongside the name upstream gave it. Their 5-hour one is picked up below:
137
+ // on a subscription it is the only 5h this API ever states.
138
+ /** @type {QuotaWindow|null} */
139
+ let scopedFiveHour = null;
128
140
  for (const fam of families.values()) {
129
141
  if (!fam.slug) continue;
130
142
  const scoped = classify(fam.windows);
143
+ // Tightest wins, so the reading is taken before the weekly guard below
144
+ // drops a family that states a 5h window and no weekly one.
145
+ if (scoped.fiveHour && (!scopedFiveHour || scoped.fiveHour.utilization > scopedFiveHour.utilization)) {
146
+ scopedFiveHour = scoped.fiveHour;
147
+ }
131
148
  if (!scoped.weekly) continue;
132
149
  (quota.modelBuckets ??= []).push({
133
150
  slug: fam.slug,
@@ -137,6 +154,21 @@ export function parseCodexQuota(headers) {
137
154
  });
138
155
  }
139
156
 
157
+ // A subscription's account-wide family states no 5-hour window at all: it
158
+ // puts the 7-day one in `primary` and zeroes `secondary`, which classify()
159
+ // drops, correctly, because a zero-length window is how this API says "not
160
+ // applicable". The only 5h it states sits in a named family, and upstream
161
+ // returns the same one whatever model was asked for — it is the account's
162
+ // session window wearing a model's name. So fill the shared bucket from it
163
+ // when the account-wide family left it empty, and never overwrite a reading
164
+ // the account-wide family did give: a family bucket barring models it does
165
+ // not meter would be the one-way ratchet the weekly buckets take such care
166
+ // to avoid.
167
+ if (quota.unified5h == null && scopedFiveHour) {
168
+ quota.unified5h = scopedFiveHour.utilization;
169
+ if (scopedFiveHour.resetAt) quota.unified5hReset = scopedFiveHour.resetAt;
170
+ }
171
+
140
172
  return quota;
141
173
  }
142
174
 
@@ -34,6 +34,40 @@ function classify(rateLimit) {
34
34
  return { fiveHour, sevenDay };
35
35
  }
36
36
 
37
+ /**
38
+ * Name each extra limit from the entry itself.
39
+ *
40
+ * A live subscription sends `additional_rate_limits` as a LIST whose entries
41
+ * name themselves (`metered_feature`, `limit_name`); older readings used an
42
+ * object keyed by the feature. `Object.entries` over a list hands back array
43
+ * indices, so every bucket was filed as "0" and "1" — two accounts' Spark
44
+ * limits collided under one meaningless key, and the header path's name for
45
+ * the same bucket stacked beside it rather than replacing it.
46
+ *
47
+ * `metered_feature` is that header name with a `codex_` prefix (`codex_bengalfox`
48
+ * here is `x-codex-bengalfox-*` there), so stripping it makes the two paths
49
+ * agree on one key per bucket.
50
+ *
51
+ * @param {any} additional
52
+ * @returns {Array<{slug: string, name: string, rateLimit: any}>}
53
+ */
54
+ function additionalLimits(additional) {
55
+ if (Array.isArray(additional)) {
56
+ const out = [];
57
+ for (const entry of additional) {
58
+ if (!entry || typeof entry !== 'object') continue;
59
+ const feature = typeof entry.metered_feature === 'string' ? entry.metered_feature.replace(/^codex_/, '') : '';
60
+ const label = typeof entry.limit_name === 'string' ? entry.limit_name : '';
61
+ const slug = feature || label;
62
+ if (!slug) continue;
63
+ out.push({ slug, name: label || slug, rateLimit: entry.rate_limit || entry });
64
+ }
65
+ return out;
66
+ }
67
+ return Object.entries(additional || {})
68
+ .map(([key, value]) => ({ slug: key, name: key, rateLimit: value?.rate_limit || value }));
69
+ }
70
+
37
71
  /**
38
72
  * Convert the private `/wham/usage` response into TeamClaude quota fields.
39
73
  *
@@ -43,19 +77,36 @@ export function normalizeCodexUsagePayload(data) {
43
77
  const rateLimit = data?.rate_limit || data?.rate_limits;
44
78
  const shared = classify(rateLimit);
45
79
  const modelBuckets = [];
46
- for (const [name, value] of Object.entries(data?.additional_rate_limits || {})) {
47
- const reading = classify(value?.rate_limit || value);
80
+ /** @type {{utilization: number, resetAt: number|null, seconds: number}|null} */
81
+ let extraFiveHour = null;
82
+ for (const { slug, name, rateLimit: extra } of additionalLimits(data?.additional_rate_limits)) {
83
+ const reading = classify(extra);
84
+ // Taken before the weekly guard below, so an extra limit stating a 5-hour
85
+ // window and no weekly one contributes its reading instead of being
86
+ // dropped whole. Tightest wins when several state one.
87
+ if (reading.fiveHour && (!extraFiveHour || reading.fiveHour.utilization > extraFiveHour.utilization)) {
88
+ extraFiveHour = reading.fiveHour;
89
+ }
48
90
  if (reading.sevenDay) {
49
91
  modelBuckets.push({
50
- slug: name,
92
+ slug,
51
93
  name,
52
94
  utilization: reading.sevenDay.utilization,
53
95
  resetAt: reading.sevenDay.resetAt,
54
96
  });
55
97
  }
56
98
  }
99
+
100
+ // The shared `rate_limit` on a subscription states a 7-day window and a null
101
+ // secondary, so it yields no 5-hour reading; the only one the payload states
102
+ // sits in an extra limit. Fall back to that so the probe learns a session
103
+ // window at all, and never let it replace a shared reading: an extra limit
104
+ // meters the models it names, and one barring the rest would be the one-way
105
+ // ratchet the weekly buckets are written to avoid.
106
+ const fiveHour = shared.fiveHour || extraFiveHour;
107
+
57
108
  return {
58
- fiveHour: shared.fiveHour && { utilization: shared.fiveHour.utilization, resetAt: shared.fiveHour.resetAt },
109
+ fiveHour: fiveHour && { utilization: fiveHour.utilization, resetAt: fiveHour.resetAt },
59
110
  sevenDay: shared.sevenDay && { utilization: shared.sevenDay.utilization, resetAt: shared.sevenDay.resetAt },
60
111
  modelBuckets,
61
112
  planType: data?.plan_type || null,
package/src/config.js CHANGED
@@ -101,6 +101,7 @@ export function createDefaultConfig() {
101
101
  sessionTitles: { enabled: false, width: 18 },
102
102
  projection: { enabled: true, windowMinutes: 90, wasteFloor: 0.1 },
103
103
  eventLogging: 'hide',
104
+ activityLog: null,
104
105
  blockedModels: [],
105
106
  accounts: [],
106
107
  };