@rikcodes/teamclaude 1.1.20-rik.4 → 1.1.20-rik.6

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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rikcodes/teamclaude",
3
- "version": "1.1.20-rik.4",
3
+ "version": "1.1.20-rik.6",
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",
@@ -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
  /**
@@ -2798,7 +2856,7 @@ export class AccountManager {
2798
2856
 
2799
2857
  // Clear expired unified quotas
2800
2858
  if (q.unified5h != null && q.unified5hReset && now >= q.unified5hReset) {
2801
- console.log(`[TeamClaude] Account "${account.name}" session quota reset`);
2859
+ console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" session quota reset`);
2802
2860
  // Recorded on the account, not just returned. _clearExpiredQuotas is
2803
2861
  // reached from two directions — refreshExpiredQuotas on the request path,
2804
2862
  // which runs the session-reset switch rule, and _isNearQuota via
@@ -2818,7 +2876,7 @@ export class AccountManager {
2818
2876
  session = true;
2819
2877
  }
2820
2878
  if (q.unified7d != null && q.unified7dReset && now >= q.unified7dReset) {
2821
- console.log(`[TeamClaude] Account "${account.name}" weekly quota reset`);
2879
+ console.log(`[TeamClaude] Account "${safeLine(account.name, 64)}" weekly quota reset`);
2822
2880
  q.unified7d = null;
2823
2881
  q.unified7dReset = null;
2824
2882
  q.unifiedStatus = null;
@@ -2862,7 +2920,7 @@ export class AccountManager {
2862
2920
  // so a reading is never discarded before it has had a window to prove out.
2863
2921
  if (!q[seenField]) { q[seenField] = now; continue; }
2864
2922
  if (now < q[seenField] + this.familyStaleMs) continue;
2865
- 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`);
2866
2924
  q[key] = null;
2867
2925
  q[`${key}Reset`] = null;
2868
2926
  q[seenField] = null;
@@ -3297,7 +3355,7 @@ export class AccountManager {
3297
3355
  if (account.probing && account.quota.unified7dReset != null) {
3298
3356
  account.probing = false;
3299
3357
  account.requalify = true;
3300
- 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`);
3301
3359
  }
3302
3360
 
3303
3361
  this._observeBurnRate(account, observed);
@@ -3307,7 +3365,7 @@ export class AccountManager {
3307
3365
 
3308
3366
  if (this._isNearQuota(account)) {
3309
3367
  const pct = account.quota.unified7d != null ? Math.round(account.quota.unified7d * 100) : null;
3310
- 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}%)`}`);
3311
3369
  }
3312
3370
  }
3313
3371
 
@@ -3364,7 +3422,7 @@ export class AccountManager {
3364
3422
  if (account.probing && account.quota.unified7dReset != null) {
3365
3423
  account.probing = false;
3366
3424
  account.requalify = true;
3367
- 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`);
3368
3426
  }
3369
3427
 
3370
3428
  // `unified-status` is upstream's verdict on THIS response. A family-cap 429
@@ -3452,7 +3510,7 @@ export class AccountManager {
3452
3510
  : account.quota.tokensLimit
3453
3511
  ? ((1 - account.quota.tokensRemaining / account.quota.tokensLimit) * 100).toFixed(1)
3454
3512
  : '?';
3455
- 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`);
3456
3514
  }
3457
3515
  }
3458
3516
 
@@ -3545,7 +3603,7 @@ export class AccountManager {
3545
3603
  // drop the dead-token guard too — otherwise the account would come back
3546
3604
  // active but never attempt a refresh (see ensureTokenFresh).
3547
3605
  account._deadRefreshToken = null;
3548
- 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`);
3549
3607
  }
3550
3608
  }
3551
3609
 
@@ -3610,7 +3668,7 @@ export class AccountManager {
3610
3668
  }
3611
3669
  // Worth a line: the account was refusing this family and is not any more.
3612
3670
  if (wasSpent && !(q[key] != null && q[key] >= this.thresholdFor(key))) {
3613
- 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`);
3614
3672
  }
3615
3673
  }
3616
3674
  // Families beyond the two with dedicated fields. Replaced wholesale rather
@@ -3634,11 +3692,11 @@ export class AccountManager {
3634
3692
  const was = q.spend;
3635
3693
  q.spend = { ...usage.spend };
3636
3694
  if (q.spend.enabled && !was?.enabled) {
3637
- 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)`);
3638
3696
  }
3639
3697
  const spentNow = (q.spend.usedMinor || 0) > 0;
3640
3698
  if (spentNow && !((was?.usedMinor || 0) > 0)) {
3641
- 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)}`);
3642
3700
  }
3643
3701
  }
3644
3702
 
@@ -3717,7 +3775,7 @@ export class AccountManager {
3717
3775
  // after throttleProbeFloorMs from here, so a probe that 429s again pushes
3718
3776
  // the next probe out by a full floor rather than hammering upstream.
3719
3777
  account.throttledAt = Date.now();
3720
- 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`);
3721
3779
  }
3722
3780
 
3723
3781
  /**
@@ -3731,7 +3789,7 @@ export class AccountManager {
3731
3789
  account.status = 'active';
3732
3790
  account.rateLimitedUntil = null;
3733
3791
  account.throttledAt = null;
3734
- 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`);
3735
3793
  }
3736
3794
 
3737
3795
  /**
@@ -3768,7 +3826,7 @@ export class AccountManager {
3768
3826
  if (account._deadRefreshToken && account._deadRefreshToken === account.refreshToken) {
3769
3827
  if (account.status !== 'error') {
3770
3828
  account.status = 'error';
3771
- 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`);
3772
3830
  }
3773
3831
  return;
3774
3832
  }
@@ -3794,7 +3852,7 @@ export class AccountManager {
3794
3852
  if (account._refreshPromise) return account._refreshPromise;
3795
3853
 
3796
3854
  account._refreshPromise = (async () => {
3797
- console.log(`[TeamClaude] Refreshing token for account "${account.name}"...`);
3855
+ console.log(`[TeamClaude] Refreshing token for account "${safeLine(account.name, 64)}"...`);
3798
3856
  // The token we SEND, captured before the await. A config reload or
3799
3857
  // `teamclaude import` (updateAccountTokens) can install newer tokens while
3800
3858
  // the grant is in flight; reading `account.refreshToken` afterwards would
@@ -3811,7 +3869,7 @@ export class AccountManager {
3811
3869
  ? this._codexRefreshFn(sent)
3812
3870
  : this._refreshFn(sent));
3813
3871
  if (account.refreshToken !== sent) {
3814
- 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`);
3815
3873
  return;
3816
3874
  }
3817
3875
  account.credential = newTokens.accessToken;
@@ -3819,10 +3877,10 @@ export class AccountManager {
3819
3877
  account.expiresAt = newTokens.expiresAt;
3820
3878
  account._lastRefreshAt = Date.now();
3821
3879
  account._deadRefreshToken = null; // this token works; clear any stale guard
3822
- console.log(`[TeamClaude] Token refreshed for account "${account.name}"`);
3880
+ console.log(`[TeamClaude] Token refreshed for account "${safeLine(account.name, 64)}"`);
3823
3881
  this._onTokenRefresh?.(accountIndex, newTokens);
3824
3882
  } catch (err) {
3825
- 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}`);
3826
3884
  // Reserve 'error' (which drops the account from rotation until re-login)
3827
3885
  // for a GENUINE auth rejection: the refresh token itself is no longer
3828
3886
  // valid — revoked, or invalidated by an account/plan migration. A
@@ -3838,11 +3896,11 @@ export class AccountManager {
3838
3896
  // a token imported mid-refresh stays untouched and gets its own try.
3839
3897
  account._deadRefreshToken = sent;
3840
3898
  if (account.refreshToken !== sent) {
3841
- 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`);
3842
3900
  return;
3843
3901
  }
3844
3902
  account.status = 'error';
3845
- 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`);
3846
3904
  }
3847
3905
  } finally {
3848
3906
  account._refreshPromise = null;
@@ -3872,7 +3930,7 @@ export class AccountManager {
3872
3930
  if (refreshToken) account.refreshToken = refreshToken;
3873
3931
  account.expiresAt = expiresAt;
3874
3932
  if (account.status === 'error') account.status = 'active';
3875
- console.log(`[TeamClaude] Updated tokens for account "${account.name}"`);
3933
+ console.log(`[TeamClaude] Updated tokens for account "${safeLine(account.name, 64)}"`);
3876
3934
  this._onTokenRefresh?.(accountIndex, {
3877
3935
  accessToken,
3878
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/index.js CHANGED
@@ -35,7 +35,7 @@ import { TUI } from './tui.js';
35
35
  import { SessionTitles } from './session-titles.js';
36
36
  import { RemoteControl, createAttachSession } from './tui-remote.js';
37
37
  import { SxManager } from './sx.js';
38
- import { autoUpdate, checkForUpdate, currentVersion, runUpdate, installKind, PKG_NAME } from './updater.js';
38
+ import { autoUpdate, checkForUpdate, currentVersion, resolveVersionLabel, runUpdate, installKind, updateAvailableFromCache, PKG_NAME } from './updater.js';
39
39
  import { renderStatus, formatPercent } from './status-renderer.js';
40
40
  import { sanitizeText } from './safe-text.js';
41
41
  import { ClientUsageTracker, UsageDimensionTracker } from './client-usage.js';
@@ -382,6 +382,11 @@ async function serverCommand() {
382
382
  // disk while this process keeps running the old code, and status must report
383
383
  // what is running, not what is installed.
384
384
  const serverVersion = currentVersion();
385
+ // What the header names this build, and whether the last recorded check saw
386
+ // something newer. A checkout gets no update marker: autoUpdate refuses to
387
+ // npm-install over one, so offering it would advertise a declined action.
388
+ const { label: versionLabel, git: fromGit } = await resolveVersionLabel();
389
+ const updateAvailable = !fromGit && await updateAvailableFromCache({ current: serverVersion });
385
390
 
386
391
  // sx.org proxy (IP-based-429 workaround). Dormant unless an API key is set in
387
392
  // config.sx.apiKey; when set we provision a proxy and route upstream through it.
@@ -485,7 +490,7 @@ async function serverCommand() {
485
490
 
486
491
  if (useTUI) {
487
492
  tui = new TUI({
488
- accountManager, config, sx, activityLogPath, sessionTitles, version: serverVersion,
493
+ accountManager, config, sx, activityLogPath, sessionTitles, versionLabel, updateAvailable,
489
494
  saveConfig: () => atomicConfigUpdate(async diskConfig => {
490
495
  diskConfig.accounts = mergeAccountsForSave(
491
496
  config.accounts, accountManager.accounts, diskConfig.accounts, removedAccountIds(config),
@@ -585,6 +590,8 @@ async function serverCommand() {
585
590
  usageDimensions: dimensionUsage.export(),
586
591
  server: {
587
592
  version: serverVersion,
593
+ versionLabel,
594
+ updateAvailable,
588
595
  startedAt: new Date(serverStartedAt).toISOString(),
589
596
  uptimeSeconds: Math.round((Date.now() - serverStartedAt) / 1000),
590
597
  port,
package/src/mitm.js CHANGED
@@ -129,6 +129,11 @@ export function hostMode(host, config) {
129
129
  // Explicitly never intercepted, even though it sits under a provider's domain
130
130
  // — checked before anything else so no later rule can claim it.
131
131
  if (isNeverIntercepted(host)) return 'tunnel';
132
+ // In terminal-only mode the MITM must never terminate ChatGPT Desktop's
133
+ // connection. Terminal Codex uses the explicit /backend-api/codex base URL;
134
+ // this host-level bypass keeps Desktop's native auth and feature endpoints
135
+ // outside TeamClaude entirely.
136
+ if (config?.proxy?.terminalOnly === true && host === 'chatgpt.com') return 'tunnel';
132
137
  if (host === upstreamHostOf(config)) return 'rewrite';
133
138
  // A second provider's host, and only when an account actually uses that
134
139
  // provider. MITM is the mode that works without the client cooperating — a
package/src/provider.js CHANGED
@@ -57,6 +57,9 @@ export const DEFAULT_PROVIDER = 'anthropic';
57
57
  * The provider an account belongs to. Accounts written before providers
58
58
  * existed have no `provider` field and are Anthropic, so the default keeps
59
59
  * every existing config working untouched.
60
+ *
61
+ * @param {Record<string, any>|null|undefined} account
62
+ * @returns {keyof typeof PROVIDERS}
60
63
  */
61
64
  export function providerOf(account) {
62
65
  const id = account?.provider;
@@ -247,6 +250,8 @@ export function rewritesBody(account) {
247
250
  * program spells it differently. A loopback upstream says the same thing
248
251
  * directly, and says it for a hand-started process too. A remote third-party
249
252
  * backend (DeepSeek, GLM) keeps a public host and is not caught.
253
+ *
254
+ * @param {any} account
250
255
  */
251
256
  export function isLocalUpstream(account) {
252
257
  if (!account?.upstream) return false;
package/src/server.js CHANGED
@@ -12,7 +12,7 @@ import { parseRequestModel, parseAdvisorModel } from './account-manager.js';
12
12
  import { TopLevelFieldFinder, modelGlobMatches } from './model.js';
13
13
  import { BodyWriter, truncationNote } from './request-log.js';
14
14
  import { upstreamFetch, upstreamPoolStatus } from './upstream-fetch.js';
15
- import { applyAuthHeaders, upstreamFor, rewritesBody, providerForPath, providerOf, isSubscriptionAccount, holdsConnection, DEFAULT_PROVIDER } from './provider.js';
15
+ import { applyAuthHeaders, upstreamFor, rewritesBody, providerForPath, providerOf, isSubscriptionAccount, holdsConnection, DEFAULT_PROVIDER, PROVIDERS } from './provider.js';
16
16
  import { tunnelTls } from './sx.js';
17
17
  import { createEgressGuard } from './egress-guard.js';
18
18
  import { safeLine } from './safe-text.js';
@@ -1111,7 +1111,7 @@ export function createProxyRequestListener({ accountManager, upstream, logDir =
1111
1111
  // socket itself on a dead stream, so a clientGone check at this point
1112
1112
  // would reclassify the worst failure as "the user left".
1113
1113
  accountManager.endSession(sessionId,
1114
- !isCompletionPath(req.url) ? null : (ctx.delivered ? true : (ctx.abandoned ? null : false)));
1114
+ !isCompletionPath(classificationPath(req.url)) ? null : (ctx.delivered ? true : (ctx.abandoned ? null : false)));
1115
1115
  // Cleared BEFORE the hook, because the hook can throw: leaving the entry
1116
1116
  // marked open would send the outer catch to call that same throwing hook
1117
1117
  // a second time for one request.
@@ -1216,7 +1216,9 @@ function isCompletionPath(url) {
1216
1216
  // a stale streak, and one the proxy refuses to send at all (egress unpinned)
1217
1217
  // must still count as getting nothing.
1218
1218
  function recordEarlyOutcome(accountManager, sessionId, url, usable) {
1219
- if (sessionId && isCompletionPath(url)) accountManager.recordOutcome(sessionId, usable);
1219
+ // On the classification path, like every other decision here: `\v1\messages`
1220
+ // goes out as `/v1/messages` and is a completion for the streak too (#377).
1221
+ if (sessionId && isCompletionPath(classificationPath(url))) accountManager.recordOutcome(sessionId, usable);
1220
1222
  }
1221
1223
 
1222
1224
  /**
@@ -2160,6 +2162,38 @@ export async function forwardRequest(req, res, body, accountManager, upstream, r
2160
2162
  const upstreamUrl = `${upstreamFor(account, upstream)}${req.url}`;
2161
2163
  const method = req.method;
2162
2164
 
2165
+ // An upstream that keeps no thread state would receive only this turn's delta
2166
+ // and answer it as the whole conversation. Refusing makes the client resend
2167
+ // the full history (see refusesThreadContinue). Placed before admit() so the
2168
+ // early return holds no concurrency slot, and after recordSession so the
2169
+ // resend that follows lands on this same account and reuses its cache.
2170
+ if (refusesThreadContinue(body, account, req.url) && !res.headersSent && !clientGone(res)) {
2171
+ ctx.status = 400;
2172
+ ctx.delivered = true; // a 4xx IS an answer — see answeredStatus
2173
+ // Said once per account: the client stops sending threads for that model
2174
+ // after the first refusal, so a line per refusal would be a line per model,
2175
+ // not per turn — and an operator watching tokens rise needs to find this.
2176
+ // The flag lives on the account so a reload clears it with the setting it
2177
+ // reports on (see syncAccountsFromDisk).
2178
+ if (!account.threadRefusalReported) {
2179
+ account.threadRefusalReported = true;
2180
+ console.error(`[TeamClaude] ${safeLine(account.name, 64)}: refusing message-thread continues (this upstream keeps no thread state; set "messageThreads": true if it does)`);
2181
+ }
2182
+ res.writeHead(400, { 'Content-Type': 'application/json', 'x-should-retry': 'false' });
2183
+ res.end(JSON.stringify({
2184
+ type: 'error',
2185
+ error: {
2186
+ type: 'invalid_request_error',
2187
+ message: 'thread: this upstream does not keep thread state; resend the conversation',
2188
+ // Read by the client as "stop threading this model for the session"
2189
+ // rather than "retry this one turn", which is the difference between
2190
+ // one refusal and one per turn.
2191
+ details: { error_code: 'thread_unsupported_request' },
2192
+ },
2193
+ }));
2194
+ return;
2195
+ }
2196
+
2163
2197
  // Every rewrite below runs inside rewriteRequestBody (exported for tests);
2164
2198
  // Content-Length is refreshed below because the body can shrink.
2165
2199
  let sendBody = rewriteRequestBody(body, account, req.url, req.headers['content-type']);
@@ -2969,6 +3003,78 @@ export function rewriteRequestBody(body, account, url, contentType) {
2969
3003
  return sendBody;
2970
3004
  }
2971
3005
 
3006
+ // A continue must carry both of these exact JSON substrings, so a Buffer scan
3007
+ // skips the parse for any body missing either. Necessary, not sufficient: a
3008
+ // create whose message text is the word "continue" carries both as well and
3009
+ // gets parsed for nothing. The parse below is what decides.
3010
+ const THREAD_MARKER = Buffer.from('"thread"');
3011
+ const CONTINUE_MARKER = Buffer.from('"continue"');
3012
+
3013
+ /**
3014
+ * Whether an `upstream` names Anthropic itself — a region pin or a mirror rather
3015
+ * than a different backend. Those reach the real thread store, so refusing their
3016
+ * continues would be overhead the operator has to discover and opt out of by
3017
+ * hand. The HOST decides: a third-party API serving the Anthropic shape does it
3018
+ * under its own host, usually with a path prefix.
3019
+ *
3020
+ * @param {unknown} upstream
3021
+ */
3022
+ function pointsAtAnthropic(upstream) {
3023
+ try {
3024
+ return new URL(String(upstream)).hostname === new URL(PROVIDERS.anthropic.upstream).hostname;
3025
+ } catch {
3026
+ return false; // unparseable is not evidence of a thread store
3027
+ }
3028
+ }
3029
+
3030
+ /**
3031
+ * Whether a request must be refused instead of forwarded, because it continues
3032
+ * an Anthropic message thread on an upstream that keeps no thread state.
3033
+ *
3034
+ * Claude Code stores the conversation on Anthropic's side once a thread exists:
3035
+ * the first /v1/messages body carries thread:{type:"create"} with the whole
3036
+ * messages array, later ones thread:{type:"continue"} with only the new delta.
3037
+ * A third-party upstream ignores the unknown field and answers the delta alone,
3038
+ * so from the second turn on the model no longer sees the conversation — with
3039
+ * no error anywhere. Anthropic itself answers 400 when a thread cannot be
3040
+ * continued, and the client reacts by resending the whole conversation, so
3041
+ * refusing here is what puts the upstream back on a complete one.
3042
+ *
3043
+ * The body carries `details.error_code: "thread_unsupported_request"`, which the
3044
+ * client reads as "this model keeps no thread state": it resends the turn in
3045
+ * full and then drops the `thread` field entirely for the rest of the session,
3046
+ * so the refusals are counted per agent and model rather than per turn, and cost
3047
+ * no tokens. A relay that does reach Anthropic keeps working threads and opts
3048
+ * out with `messageThreads: true`.
3049
+ *
3050
+ * Exported for tests.
3051
+ *
3052
+ * @param {Buffer|null|undefined} body fully-buffered request body
3053
+ * @param {Record<string, any>|null|undefined} account the account about to serve it
3054
+ * @param {string|undefined} url req.url
3055
+ * @returns {boolean}
3056
+ */
3057
+ export function refusesThreadContinue(body, account, url) {
3058
+ if (!account?.upstream || !rewritesBody(account)) return false;
3059
+ if (account.messageThreads) return false;
3060
+ if (!Buffer.isBuffer(body) || body.length === 0) return false;
3061
+ // Only a completion continues a thread. count_tokens carries a body of the
3062
+ // same shape, and a refusal there is unrecoverable — there is no conversation
3063
+ // to resend for a token count. Classified on the folded, once-decoded path
3064
+ // like every other refusal here: `\v1\messages` is what this process itself
3065
+ // will send as `/v1/messages`, so the test has to read it the same way.
3066
+ if (!isCompletionPath(classificationPath(url))) return false;
3067
+ if (!body.includes(THREAD_MARKER) || !body.includes(CONTINUE_MARKER)) return false;
3068
+ // Last of the cheap gates because it parses two URLs: by here the request is
3069
+ // already known to be a completion whose body could carry a continue.
3070
+ if (pointsAtAnthropic(account.upstream)) return false;
3071
+ try {
3072
+ return JSON.parse(body.toString('utf8'))?.thread?.type === 'continue';
3073
+ } catch {
3074
+ return false; // not JSON we can reason about — never break it
3075
+ }
3076
+ }
3077
+
2972
3078
  // Remove top-level fields from a JSON request body (see stripRequestFields).
2973
3079
  // Returns the original buffer when nothing changed or the body isn't JSON, so
2974
3080
  // non-messages endpoints pass through untouched. Exported for tests.
@@ -1,5 +1,6 @@
1
1
  import { importCredentials } from './oauth.js';
2
2
  import { sameIdentity } from './identity.js';
3
+ import { safeLine } from './safe-text.js';
3
4
  import { ensureAccountIds } from './account-id.js';
4
5
  import { normalizeHeadersTimeoutMs } from './account-manager.js';
5
6
 
@@ -102,8 +103,20 @@ export async function syncAccountsFromDisk(diskConfig, memConfig, accountManager
102
103
  // disk edit must land here to take effect on reload. `|| null` mirrors the
103
104
  // constructor's normalization, letting a removal on disk revert the account
104
105
  // to the fleet default instead of sticking on the old value.
106
+ // Re-arm the one-shot operator line whenever either input it reports on
107
+ // changes — the setting, or the upstream it was reported for. An operator
108
+ // who takes `messageThreads` back off, or who moves the account to a
109
+ // different backend, needs to be told again that continues are refused;
110
+ // otherwise their only signal stays silent. Read before the assignments
111
+ // below, which are what it compares against.
112
+ if (mgr.upstream !== (diskAcct.upstream || null)
113
+ || mgr.messageThreads !== (diskAcct.messageThreads === true)) mgr.threadRefusalReported = false;
105
114
  mgr.upstream = diskAcct.upstream || null;
106
115
  mgr.modelMap = diskAcct.modelMap || null;
116
+ // Read per request like the two above (server.js rewriteRequestBody), and
117
+ // missing from this sync until #374: an edit waited for a restart.
118
+ mgr.stripRequestFields = diskAcct.stripRequestFields || null;
119
+ mgr.messageThreads = diskAcct.messageThreads === true;
107
120
  // Same per-request read (`account.headersTimeoutMs` in forwardRequest);
108
121
  // same normalization as the constructor, so a removed or invalid disk value
109
122
  // reverts to the fleet default.
@@ -117,6 +130,8 @@ export async function syncAccountsFromDisk(diskConfig, memConfig, accountManager
117
130
  if (cfgAcct) {
118
131
  if (diskAcct.upstream) cfgAcct.upstream = diskAcct.upstream; else delete cfgAcct.upstream;
119
132
  if (diskAcct.modelMap) cfgAcct.modelMap = diskAcct.modelMap; else delete cfgAcct.modelMap;
133
+ if (diskAcct.stripRequestFields) cfgAcct.stripRequestFields = diskAcct.stripRequestFields; else delete cfgAcct.stripRequestFields;
134
+ if (diskAcct.messageThreads === true) cfgAcct.messageThreads = true; else delete cfgAcct.messageThreads;
120
135
  if (diskAcct.maxUsage != null) cfgAcct.maxUsage = diskAcct.maxUsage; else delete cfgAcct.maxUsage;
121
136
  if (diskAcct.headersTimeoutMs != null) cfgAcct.headersTimeoutMs = diskAcct.headersTimeoutMs; else delete cfgAcct.headersTimeoutMs;
122
137
  }
@@ -132,7 +147,7 @@ export async function syncAccountsFromDisk(diskConfig, memConfig, accountManager
132
147
  const creds = await importCredentials(diskAcct.importFrom);
133
148
  freshCred = { accessToken: creds.accessToken, refreshToken: creds.refreshToken, expiresAt: creds.expiresAt };
134
149
  } catch (/** @type {any} */ err) {
135
- console.error(`[TeamClaude] Re-import failed for "${diskAcct.name}": ${err.message}`);
150
+ console.error(`[TeamClaude] Re-import failed for "${safeLine(diskAcct.name, 64)}": ${err.message}`);
136
151
  }
137
152
  } else if (diskAcct.type === 'oauth' && diskAcct.accessToken) {
138
153
  freshCred = { accessToken: diskAcct.accessToken, refreshToken: diskAcct.refreshToken, expiresAt: diskAcct.expiresAt };
@@ -151,12 +166,12 @@ export async function syncAccountsFromDisk(diskConfig, memConfig, accountManager
151
166
  freshCred.expiresAt < mgr.expiresAt;
152
167
  if (changed && !diskIsStaler) {
153
168
  accountManager.updateAccountTokens(mgr.index, freshCred);
154
- console.log(`[TeamClaude] Refreshed credentials for "${mgr.name}"`);
169
+ console.log(`[TeamClaude] Refreshed credentials for "${safeLine(mgr.name, 64)}"`);
155
170
  }
156
171
  } else if (freshCred.apiKey && mgr.credential !== freshCred.apiKey) {
157
172
  mgr.credential = freshCred.apiKey;
158
173
  if (mgr.status === 'error') mgr.status = 'active';
159
- console.log(`[TeamClaude] Updated API key for "${mgr.name}"`);
174
+ console.log(`[TeamClaude] Updated API key for "${safeLine(mgr.name, 64)}"`);
160
175
  }
161
176
  }
162
177
  return added;
package/src/tui-remote.js CHANGED
@@ -25,6 +25,7 @@ const text = (value, max, fallback = '') => {
25
25
  return safeLine(value, max) || fallback;
26
26
  };
27
27
  const NAME_MAX = 64;
28
+ const LABEL_MAX = 32;
28
29
 
29
30
  // Addresses that reach this machine. A server bound to one of these exempts
30
31
  // loopback clients from the proxy-key gate, which changes what a 401 can mean.
@@ -198,6 +199,10 @@ export class RemoteAccountManager {
198
199
  this.connected = false; // false ⇒ the view is a stale snapshot
199
200
  this.lastError = null;
200
201
  this.status = null;
202
+ // Empty until the first poll, so the header shows no version rather than
203
+ // this process's own — in attach mode that would name the wrong machine.
204
+ this.versionLabel = '';
205
+ this.updateAvailable = false;
201
206
  }
202
207
 
203
208
  /** Per-bucket threshold lookup, mirroring AccountManager.thresholdFor so the
@@ -271,6 +276,10 @@ export class RemoteAccountManager {
271
276
  eligible: !!a?.eligible,
272
277
  })),
273
278
  }));
279
+ // A server too old to send versionLabel still sends version; one older than
280
+ // both leaves the label empty and the header simply omits it.
281
+ this.versionLabel = text(status?.server?.versionLabel ?? status?.server?.version, LABEL_MAX);
282
+ this.updateAvailable = !!status?.server?.updateAvailable;
274
283
  // Conduit lines read this. Clamped like every other remote field: the
275
284
  // payload is a server's word, not ours, and it reaches a rendered line.
276
285
  this.sidecars = (Array.isArray(status?.sidecars) ? status.sidecars : []).map(sc => ({
package/src/tui.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  oauthIdentityFields,
10
10
  } from './identity.js';
11
11
  import { configIndexFor, managerAccountFor, markAccountRemoved } from './account-pairing.js';
12
+ import { PROVIDERS, providerOf } from './provider.js';
12
13
  import { mintAccountId } from './account-id.js';
13
14
  import { formatPercent } from './status-renderer.js';
14
15
  import { resolveMaxUsage } from './model.js';
@@ -212,6 +213,11 @@ const BAR_MAX = 20;
212
213
  // terminal lays the table out exactly as it did before the column could grow.
213
214
  const NAME_MIN = 12;
214
215
 
216
+ // Clear space the centred version label needs on each side before it is drawn
217
+ // at all. Below that it reads as a collision with the title or the port block,
218
+ // so the whole label is dropped rather than squeezed.
219
+ const HEAD_GAP = 2;
220
+
215
221
  // Which pair of bars a row draws: the subscription buckets (Ses/Wk, plus the
216
222
  // S7/F7 family bars) when any unified reading exists, else the metered Tok/Req
217
223
  // pair an API-key account reports. The account row budget is drawn per
@@ -418,10 +424,10 @@ export class TUI {
418
424
  // Names the activity column against the session id the client sent. Absent
419
425
  // or disabled leaves every row showing the short id.
420
426
  sessionTitles = null,
421
- // Shown faint beside the title. Null in attach mode, where the dashboard is
422
- // built before the first poll and the server's version is not yet known,
423
- // and in tests — both render the title alone rather than a stray `null`.
424
- version = null }) {
427
+ // How the header names this build, and whether a newer release is known.
428
+ // In attach mode the account manager carries the server's own answer and
429
+ // these are unused; the empty defaults keep the label hidden until it does.
430
+ versionLabel = '', updateAvailable = false }) {
425
431
  this.am = accountManager;
426
432
  this.remote = remote;
427
433
  this.applySwitch = applySwitch;
@@ -438,7 +444,8 @@ export class TUI {
438
444
  this._readProfile = readProfile;
439
445
  this._activityStream = null;
440
446
  this.sessionTitles = sessionTitles;
441
- /** @type {string|null} */ this.version = version;
447
+ this.versionLabel = versionLabel;
448
+ this.updateAvailable = updateAvailable;
442
449
 
443
450
  this.log = []; // completed activity entries
444
451
  this.active = new Map(); // in-flight requests
@@ -494,6 +501,18 @@ export class TUI {
494
501
  start() {
495
502
  this.running = true;
496
503
  this._openActivityLog();
504
+ // Node puts a TTY stdout in BLOCKING mode, so every paint is a synchronous
505
+ // write(2) that returns only when the terminal has drained the pty. That
506
+ // makes the proxy's event loop hostage to its own display: when the
507
+ // terminal emulator pauses — an Electron pane busy elsewhere, a window
508
+ // occluded, the machine dozing — the write sits in the kernel and nothing
509
+ // else runs: no upstream bytes relayed, no request completed, no log line.
510
+ // Measured live: stalls of 5-29s, the main thread in write() under
511
+ // StreamBase::WriteString, with sessions "waiting for API response" and
512
+ // nothing to see anywhere because the thing that would show it is the
513
+ // thing blocked. Non-blocking here, and the paint below drops a frame
514
+ // when the terminal is behind instead of waiting for it.
515
+ this._setStdoutBlocking(false);
497
516
  process.stdout.write(`${ESC}?1049h${ESC}?25l`);
498
517
  process.stdin.setRawMode(true);
499
518
  process.stdin.resume();
@@ -550,6 +569,12 @@ export class TUI {
550
569
  if (this._activityStream) { this._activityStream.end(); this._activityStream = null; }
551
570
  process.stdin.removeListener('data', this._dataHandler);
552
571
  process.stdout.removeListener('resize', this._resizeHandler);
572
+ if (this._drainHandler) { process.stdout.removeListener('drain', this._drainHandler); this._drainHandler = null; }
573
+ // Blocking again for the exit sequence: a non-blocking write can still be
574
+ // queued when the process exits, and a terminal left on the alternate
575
+ // screen with no cursor is the one state an operator cannot recover
576
+ // without knowing the escape by heart.
577
+ this._setStdoutBlocking(true);
553
578
  process.stdout.write(`${ESC}?25h${ESC}?1049l`);
554
579
  try { process.stdin.setRawMode(false); } catch {}
555
580
  process.stdin.pause();
@@ -1361,11 +1386,36 @@ export class TUI {
1361
1386
  _paint(buf, force) {
1362
1387
  const stale = Date.now() - (this._lastPaintAt || 0) >= FORCE_REPAINT_MS;
1363
1388
  if (!force && !stale && buf === this._lastFrame) return;
1389
+ // The terminal has not taken the previous frame yet. Painting anyway would
1390
+ // only queue another full screen behind it — the operator sees the newest
1391
+ // frame either way, so the one in between is worth nothing. Drop it, and
1392
+ // paint what is current once the terminal catches up.
1393
+ if (process.stdout.writableNeedDrain) {
1394
+ this._pendingPaint = true;
1395
+ if (!this._drainHandler) {
1396
+ this._drainHandler = () => {
1397
+ this._drainHandler = null;
1398
+ if (this._pendingPaint && this.running) { this._pendingPaint = false; this.render({ force: true }); }
1399
+ };
1400
+ process.stdout.once('drain', this._drainHandler);
1401
+ }
1402
+ return;
1403
+ }
1404
+ this._pendingPaint = false;
1364
1405
  this._lastFrame = buf;
1365
1406
  this._lastPaintAt = Date.now();
1366
1407
  process.stdout.write(buf);
1367
1408
  }
1368
1409
 
1410
+ /** Flip stdout between blocking and non-blocking. A handle without the
1411
+ * method (a pipe in tests, a file) needs neither, and a failure to flip is
1412
+ * worth no more than the old behaviour it leaves in place.
1413
+ * @param {boolean} blocking */
1414
+ _setStdoutBlocking(blocking) {
1415
+ // `_handle` is Node-internal and untyped; the optional chain is the guard.
1416
+ try { /** @type {any} */ (process.stdout)._handle?.setBlocking?.(blocking); } catch {}
1417
+ }
1418
+
1369
1419
  _render(force = false) {
1370
1420
  // Reset the display the instant a quota window (e.g. 5-hour session) expires,
1371
1421
  // instead of waiting for the next request to clear it.
@@ -1381,7 +1431,7 @@ export class TUI {
1381
1431
  const lines = [];
1382
1432
 
1383
1433
  // ── Header
1384
- const left = bold(' RikClaude Harness') + (this.version ? dim(` ${this.version}`) : '');
1434
+ const left = bold(' RikClaude Harness');
1385
1435
  const port = this.config.proxy?.port || 3456;
1386
1436
  const sess = this.am.sessionStats();
1387
1437
  const sessStr = (sess.active || sess.known)
@@ -1393,7 +1443,26 @@ export class TUI {
1393
1443
  // mode): what is on screen is the last snapshot, not the current state.
1394
1444
  const live = this.am.connected === false ? red('▼') : green('▲');
1395
1445
  const right = `${sessStr}Port ${port} ${live} `;
1396
- lines.push(left + ' '.repeat(Math.max(1, W - vw(left) - vw(right))) + right);
1446
+ // In attach mode the dashboard names the server's build, not this process's,
1447
+ // so the account manager's answer wins. It arrives sanitized (applyStatus)
1448
+ // and starts empty, which keeps the label hidden until the first poll rather
1449
+ // than briefly showing the local checkout's version as if it were the
1450
+ // server's. A local AccountManager has neither property.
1451
+ const label = this.am.versionLabel ?? this.versionLabel;
1452
+ const upd = this.am.updateAvailable ?? this.updateAvailable;
1453
+ const mid = label ? dim(label) + (upd ? ` ${green('▲')}` : '') : '';
1454
+ const lw = vw(left), rw = vw(right), mw = vw(mid);
1455
+ // Centred on the line, not in the gap between the two blocks, so the label
1456
+ // holds still as the session segment comes and goes.
1457
+ const start = Math.floor((W - mw) / 2);
1458
+ // Load-bearing, not cosmetic: both padding runs below would be negative
1459
+ // without it, and ' '.repeat(-1) throws. Satisfying it also means the mid
1460
+ // branch can never produce the over-wide line the other branch can, so the
1461
+ // two are not interchangeable.
1462
+ const midFits = mw > 0 && start - lw >= HEAD_GAP && (W - rw) - (start + mw) >= HEAD_GAP;
1463
+ lines.push(midFits
1464
+ ? left + ' '.repeat(start - lw) + mid + ' '.repeat(W - rw - start - mw) + right
1465
+ : left + ' '.repeat(Math.max(1, W - lw - rw)) + right);
1397
1466
  lines.push(' ' + dim('─'.repeat(W - 2)));
1398
1467
 
1399
1468
  const footerH = 2;
@@ -1592,13 +1661,13 @@ export class TUI {
1592
1661
  */
1593
1662
  _displayOrder() {
1594
1663
  return this.am.accounts
1595
- .map((_, i) => i)
1664
+ .map((/** @type {any} */ _, /** @type {number} */ i) => i)
1596
1665
  .filter(i => !isLocalUpstream(this.am.accounts[i]));
1597
1666
  }
1598
1667
 
1599
1668
  /** Manager indices of the local backends, in config order. */
1600
1669
  _conduitOrder() {
1601
- return this.am.accounts.map((_, i) => i).filter(i => isLocalUpstream(this.am.accounts[i]));
1670
+ return this.am.accounts.map((/** @type {any} */ _, /** @type {number} */ i) => i).filter(i => isLocalUpstream(this.am.accounts[i]));
1602
1671
  }
1603
1672
 
1604
1673
  /** One line per local backend: what it is, where it sends, and whether it can
@@ -1687,8 +1756,18 @@ export class TUI {
1687
1756
  const rawName = rpad(truncate(a.name, nameW), nameW);
1688
1757
  const name = isSel ? bold(rawName) : rawName;
1689
1758
 
1690
- // Type
1691
- const type = gray(a.type.padEnd(7));
1759
+ // Type — or the provider, once the pool serves more than one.
1760
+ //
1761
+ // One person's ChatGPT and Claude subscriptions are usually the same email, so a
1762
+ // mixed pool lists that address twice and the name column cannot tell the two rows
1763
+ // apart. `oauth` repeated down every row is what the column says instead, which the
1764
+ // operator already knew. Width follows the labels actually present, so nothing is
1765
+ // truncated and a single-provider pool keeps the column it has today.
1766
+ /** @type {Set<keyof typeof PROVIDERS>} */
1767
+ const pooled = new Set(this.am.accounts.map(providerOf));
1768
+ const mixed = pooled.size > 1;
1769
+ const typeW = mixed ? Math.max(...[...pooled].map(id => PROVIDERS[id].label.length)) : 7;
1770
+ const type = gray((mixed ? PROVIDERS[providerOf(a)].label : a.type).padEnd(typeW));
1692
1771
 
1693
1772
  // Status — a disabled account is shown as such regardless of its quota state.
1694
1773
  let status;
package/src/updater.js CHANGED
@@ -15,11 +15,18 @@ import { existsSync, readFileSync } from 'node:fs';
15
15
  import { readFile, writeFile } from 'node:fs/promises';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { dirname, join, resolve } from 'node:path';
18
+ import { promisify } from 'node:util';
18
19
  import { getConfigPath } from './config.js';
20
+ import { safeLine } from './safe-text.js';
19
21
 
20
22
  export const PKG_NAME = '@rikcodes/teamclaude'; // fork: self-updates track this scope, never upstream's
21
23
  const REGISTRY = 'https://registry.npmjs.org';
22
24
  const DAY_MS = 24 * 60 * 60 * 1000;
25
+ // Wide enough for `v1.2.3-rc.1+build`, narrow enough that a tag cannot be the
26
+ // reason a fixed-width caller has no room left.
27
+ const LABEL_MAX = 32;
28
+
29
+ const pexec = promisify(execFile);
23
30
 
24
31
  /** Package root = one directory above this file's src/ directory. */
25
32
  function packageRoot() {
@@ -35,6 +42,37 @@ export function currentVersion(root = packageRoot()) {
35
42
  }
36
43
  }
37
44
 
45
+ /**
46
+ * How the running copy identifies itself, for display: the exact tag when the
47
+ * checkout sits on one, else the short sha, else the shipped package.json
48
+ * version, else the literal `local`. `git` reports a checkout — npm cannot
49
+ * update one, so nothing should offer to.
50
+ *
51
+ * The git calls are pinned to the package root. `teamclaude server` is started
52
+ * from the operator's own project directory, and resolving against the process
53
+ * cwd would report that repository's sha as this package's version.
54
+ *
55
+ * @param {Object} [opts]
56
+ * @param {string} [opts.root]
57
+ * @param {(file: string, args: string[], options: { cwd: string, encoding: 'utf8', timeout: number }) => Promise<{ stdout: string }>} [opts.exec]
58
+ * @returns {Promise<{ label: string, git: boolean }>}
59
+ */
60
+ export async function resolveVersionLabel({ root = packageRoot(), exec = pexec } = {}) {
61
+ const git = existsSync(join(root, '.git'));
62
+ if (git) {
63
+ /** @type {{ cwd: string, encoding: 'utf8', timeout: number }} */
64
+ const opts = { cwd: root, encoding: 'utf8', timeout: 2000 };
65
+ const probes = [['describe', '--tags', '--exact-match', 'HEAD'], ['rev-parse', '--short', 'HEAD']];
66
+ for (const args of probes) {
67
+ try {
68
+ const label = safeLine((await exec('git', args, opts)).stdout, LABEL_MAX);
69
+ if (label) return { label, git };
70
+ } catch { /* not on a tag, a shallow or broken checkout, or no git binary */ }
71
+ }
72
+ }
73
+ return { label: safeLine(currentVersion(root) || 'local', LABEL_MAX), git };
74
+ }
75
+
38
76
  /** Numeric compare of x.y.z, then the pre-release tail. >0 if a is newer.
39
77
  * The tail matters here: this fork versions releases as X.Y.Z-rik.N on the
40
78
  * same upstream base, so ignoring it would make every -rik.N publish compare
@@ -157,6 +195,22 @@ export async function checkForUpdate({
157
195
  return { current, latest, updateAvailable: compareVersions(latest, current) > 0 };
158
196
  }
159
197
 
198
+ /**
199
+ * Whether the last recorded check saw a newer release. Cache only — never the
200
+ * registry — so a caller on a render or status path costs nothing. Like
201
+ * `checkForUpdate`, the cached `latest` is used regardless of its age.
202
+ *
203
+ * @param {Object} [opts]
204
+ * @param {string|null} [opts.current]
205
+ * @param {string} [opts.cachePath]
206
+ * @returns {Promise<boolean>}
207
+ */
208
+ export async function updateAvailableFromCache({ current = currentVersion(), cachePath = defaultCacheFile() } = {}) {
209
+ if (!current) return false;
210
+ const { latest } = await readCache(cachePath);
211
+ return !!latest && compareVersions(latest, current) > 0;
212
+ }
213
+
160
214
  /**
161
215
  * Whether `v` is a plain release version, the only shape we ever pass to npm.
162
216
  *