@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 +1 -1
- package/package.json +1 -1
- package/src/account-manager.js +82 -24
- package/src/codex-quota.js +35 -3
- package/src/codex-usage.js +55 -4
- package/src/index.js +9 -2
- package/src/mitm.js +5 -0
- package/src/provider.js +5 -0
- package/src/server.js +109 -3
- package/src/sync-accounts.js +18 -3
- package/src/tui-remote.js +9 -0
- package/src/tui.js +90 -11
- package/src/updater.js +54 -0
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.
|
|
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.
|
|
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",
|
package/src/account-manager.js
CHANGED
|
@@ -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,
|
package/src/codex-quota.js
CHANGED
|
@@ -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
|
|
126
|
-
// the
|
|
127
|
-
//
|
|
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
|
|
package/src/codex-usage.js
CHANGED
|
@@ -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
|
-
|
|
47
|
-
|
|
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
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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.
|
package/src/sync-accounts.js
CHANGED
|
@@ -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
|
-
//
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
|
|
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
|
-
|
|
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')
|
|
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
|
-
|
|
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
|
-
|
|
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
|
*
|