@rikcodes/teamclaude 1.1.13-rik.1 → 1.1.13-rik.4
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 +43 -15
- package/package.json +7 -7
- package/src/account-manager.js +88 -1
- package/src/claude-env.js +65 -1
- package/src/config.js +2 -0
- package/src/index.js +41 -4
- package/src/quota-projection.js +166 -0
- package/src/server.js +24 -7
- package/src/session-titles.js +184 -0
- package/src/sidecar.js +133 -0
- package/src/status-renderer.js +5 -2
- package/src/tui-remote.js +5 -0
- package/src/tui.js +74 -15
package/README.md
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
# TeamClaude
|
|
2
2
|
|
|
3
|
-
> **Fork notice (rikbrown).** This fork adds
|
|
3
|
+
> **Fork notice (rikbrown).** This fork adds four features and two reload fixes on top of
|
|
4
4
|
> [KarpelesLab/teamclaude](https://github.com/KarpelesLab/teamclaude):
|
|
5
5
|
>
|
|
6
|
+
> - **[OpenAI models via a Codex sidecar](docs/openai.md)** (`sidecars` + `customModels`, opt-in):
|
|
7
|
+
> route `gpt-*` requests through a supervised local translating proxy to a ChatGPT subscription,
|
|
8
|
+
> under real model names — `/model gpt-5.6-sol` in the picker and typed, correct 272k context
|
|
9
|
+
> sizing, and dispatchable GPT subagents — while Claude traffic stays on the Claude accounts.
|
|
6
10
|
> - **[Soonest-weekly rotation](docs/routing.md#soonest-weekly-rotation)** (`soonestWeekly`, opt-in): rank
|
|
7
11
|
> equal-priority accounts by the weekly window that governs the requested model, continuously — preempt the
|
|
8
12
|
> current account when another resets more than `poolHours` sooner, and balance `distributeSessions` within
|
|
9
13
|
> that pool instead of across all accounts. Spends the quota closest to refreshing first, so a window no
|
|
10
14
|
> longer rolls over with quota unspent.
|
|
15
|
+
> - **[Burn-rate projection](docs/quota.md#burn-rate-projection)** (`projection`, on by default): sample each
|
|
16
|
+
> bucket's consumption over a rolling window and tag every account row with whichever window binds
|
|
17
|
+
> first — `Ses TTL 38m` when it runs out before it resets, `Wk 22% unspent` when the reset arrives
|
|
18
|
+
> first and that much expires. A readout only: no selection code reads it.
|
|
19
|
+
> - **[Session titles](docs/usage.md#session-titles-in-the-activity-log)** (`sessionTitles`, on by default):
|
|
20
|
+
> name each activity row after the Claude Code session that sent the request, reading the title
|
|
21
|
+
> `/rename` writes and the one Claude Code generates. A session with neither keeps its short id.
|
|
11
22
|
> - `soonestWeekly` and `distributeSessions` changes now apply on config reload; upstream applies
|
|
12
23
|
> `distributeSessions` only at startup.
|
|
13
24
|
>
|
|
@@ -18,11 +29,14 @@
|
|
|
18
29
|
> npm install -g @rikcodes/teamclaude
|
|
19
30
|
> ```
|
|
20
31
|
>
|
|
32
|
+
> Already have upstream installed globally? Run `npm uninstall -g @karpeleslab/teamclaude` first —
|
|
33
|
+
> both packages provide the `teamclaude` command.
|
|
34
|
+
>
|
|
21
35
|
> Branch: `rik/soonest-weekly-pool`. Everything else matches upstream.
|
|
22
36
|
|
|
23
|
-
[](https://github.com/rikbrown/teamclaude/actions/workflows/ci.yml)
|
|
38
|
+
[](https://www.npmjs.com/package/@rikcodes/teamclaude)
|
|
39
|
+
[](https://nodejs.org)
|
|
26
40
|
[](LICENSE)
|
|
27
41
|
|
|
28
42
|
Multi-account Claude proxy with automatic quota-based rotation for [Claude Code](https://claude.ai/claude-code).
|
|
@@ -48,6 +62,8 @@ Already logged into Claude Code? `teamclaude import` takes its credentials inste
|
|
|
48
62
|
## What it does
|
|
49
63
|
|
|
50
64
|
- Rotates to the next account when the 5h session or 7d weekly bucket reaches the threshold (98% by default), preferring the account whose weekly quota resets soonest.
|
|
65
|
+
- Optionally spends the account whose weekly window resets soonest **first**, preempting the current account when another resets more than `poolHours` sooner, so a window stops rolling over with quota unspent (`soonestWeekly`, this fork).
|
|
66
|
+
- Projects each quota window's burn rate against its reset, so a row says `Ses TTL 38m · Wk 22% unspent` instead of leaving you to read it off a bar (`projection`, this fork).
|
|
51
67
|
- Tracks the per-model weekly cap separately, so an account out of Fable quota is skipped for Fable requests and still serves Opus and Sonnet.
|
|
52
68
|
- Tells a spent quota bucket apart from a per-minute rate limit and only rotates on the first one. Rotating on a rate limit would just move the burst to the next account and drop the warm cache, so it paces the same account instead.
|
|
53
69
|
- Paces requests onto a freshly switched account, so a herd of agents failing over at the same instant doesn't throttle it and cascade down the fleet.
|
|
@@ -56,6 +72,7 @@ Already logged into Claude Code? `teamclaude import` takes its credentials inste
|
|
|
56
72
|
- Holds the request open until quota resets instead of returning 429 when every account is spent, so an unattended run finishes on its own (`holdSeconds`, off by default).
|
|
57
73
|
- Refreshes OAuth tokens before they expire and writes them back to config. Client refreshes pass through untouched.
|
|
58
74
|
- Takes any Anthropic-compatible API (DeepSeek, GLM) as a low-priority fallback for when the Claude accounts are done.
|
|
75
|
+
- Serves OpenAI models next to Claude ones — a supervised local sidecar translates `gpt-*` requests onto a ChatGPT subscription, with real model names in `/model` and GPT subagents dispatchable from a Claude parent (`sidecars` + `customModels`, this fork).
|
|
59
76
|
- No dependencies. Node built-ins only.
|
|
60
77
|
|
|
61
78
|
## Everyday commands
|
|
@@ -95,27 +112,38 @@ Step-by-step lifecycle: [docs/routing.md](docs/routing.md#request-lifecycle).
|
|
|
95
112
|
| [Usage](docs/usage.md) | Server and TUI, running Claude Code, shell alias, command reference, logging |
|
|
96
113
|
| [Routing](docs/routing.md) | Rotation, the two kinds of 429, storm control, model routes, session spreading, pinning, prompt cache |
|
|
97
114
|
| [Quota](docs/quota.md) | Quota probe, keep-warm, holding on exhaustion |
|
|
115
|
+
| [OpenAI models](docs/openai.md) | Codex sidecar setup, custom model registration, GPT subagents, limitations |
|
|
98
116
|
| [Configuration](docs/configuration.md) | Config format, every field, environment variables, network tuning |
|
|
99
117
|
| [Proxy modes](docs/proxy-modes.md) | MITM forward proxy, sx.org residential egress |
|
|
100
118
|
| [Compliance](docs/compliance.md) | Terms of service notes |
|
|
101
119
|
|
|
120
|
+
## Releasing this fork
|
|
121
|
+
|
|
122
|
+
Versions are `<upstream base>-rik.<n>`, e.g. `1.1.13-rik.1`. The self-updater orders that tail, so every publish reaches existing installs within a day.
|
|
123
|
+
|
|
124
|
+
1. Rebase onto the upstream release you want as the base, if any.
|
|
125
|
+
2. Bump `version` in `package.json` and commit.
|
|
126
|
+
3. Push to `rik/soonest-weekly-pool` — the Publish workflow runs the tests, publishes to npm, and cuts a GitHub release.
|
|
127
|
+
|
|
128
|
+
The workflow authenticates with npm Trusted Publishing (OIDC), which needs a one-time setup on npmjs.com: `@rikcodes/teamclaude` → Settings → Trusted Publisher → GitHub Actions, owner `rikbrown`, repo `teamclaude`, workflow `publish.yml`. Until that exists, publish by hand:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
pnpm publish --publish-branch rik/soonest-weekly-pool --tag latest --otp=<code>
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
A prerelease version always needs an explicit `--tag`, and `latest` is the tag the self-updater reads.
|
|
135
|
+
|
|
102
136
|
## Security
|
|
103
137
|
|
|
104
|
-
|
|
138
|
+
This repository is a personal fork and is **not** the canonical project. Upstream's canonical sources are unchanged: the [KarpelesLab repository](https://github.com/KarpelesLab/teamclaude) and the [`@karpeleslab/teamclaude`](https://www.npmjs.com/package/@karpeleslab/teamclaude) npm package.
|
|
105
139
|
|
|
106
|
-
|
|
140
|
+
This fork is distributed as this repository and the [`@rikcodes/teamclaude`](https://www.npmjs.com/package/@rikcodes/teamclaude) npm package, published by `rikbrown`. The separate package name means it can never be installed over the canonical one.
|
|
107
141
|
|
|
108
|
-
|
|
142
|
+
Neither is **ever** distributed as a downloadable binary archive, so be wary of any copy that bundles a `.zip` and tells you to extract and run it. See [SECURITY.md](SECURITY.md) for details and how to report issues.
|
|
109
143
|
|
|
110
|
-
##
|
|
144
|
+
## Compliance
|
|
111
145
|
|
|
112
|
-
|
|
113
|
-
<picture>
|
|
114
|
-
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=KarpelesLab/teamclaude&type=date&theme=dark&legend=top-left" />
|
|
115
|
-
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=KarpelesLab/teamclaude&type=date&legend=top-left" />
|
|
116
|
-
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=KarpelesLab/teamclaude&type=date&legend=top-left" />
|
|
117
|
-
</picture>
|
|
118
|
-
</a>
|
|
146
|
+
TeamClaude is a local proxy holding your own credentials and driving your own Claude Code CLI. How that lines up with Anthropic's terms, including the multi-subscription question people ask most, is written up in [docs/compliance.md](docs/compliance.md). Not legal advice.
|
|
119
147
|
|
|
120
148
|
## License
|
|
121
149
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rikcodes/teamclaude",
|
|
3
|
-
"version": "1.1.13-rik.
|
|
3
|
+
"version": "1.1.13-rik.4",
|
|
4
4
|
"description": "Multi-account Claude proxy with automatic quota-based rotation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"src/"
|
|
12
12
|
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "node src/index.js",
|
|
15
|
+
"test": "node --test --test-timeout=120000",
|
|
16
|
+
"lint": "eslint ."
|
|
17
|
+
},
|
|
13
18
|
"keywords": [
|
|
14
19
|
"claude",
|
|
15
20
|
"anthropic",
|
|
@@ -34,10 +39,5 @@
|
|
|
34
39
|
},
|
|
35
40
|
"publishConfig": {
|
|
36
41
|
"access": "public"
|
|
37
|
-
},
|
|
38
|
-
"scripts": {
|
|
39
|
-
"start": "node src/index.js",
|
|
40
|
-
"test": "node --test --test-timeout=120000",
|
|
41
|
-
"lint": "eslint ."
|
|
42
42
|
}
|
|
43
|
-
}
|
|
43
|
+
}
|
package/src/account-manager.js
CHANGED
|
@@ -2,6 +2,7 @@ import { refreshAccessToken, isTokenExpiringSoon, isTokenExpired } from './oauth
|
|
|
2
2
|
import { sameIdentity } from './identity.js';
|
|
3
3
|
import { weeklyBucketForModel, modelGlobMatches } from './model.js';
|
|
4
4
|
import { SessionTracker } from './session-tracker.js';
|
|
5
|
+
import { QuotaProjection, PROJECTED_BUCKETS } from './quota-projection.js';
|
|
5
6
|
|
|
6
7
|
// Re-exported for callers that import these model helpers from here.
|
|
7
8
|
export { isFableModel, parseRequestModel, parseAdvisorModel } from './model.js';
|
|
@@ -21,6 +22,22 @@ const PERSISTED_QUOTA_FIELDS = [
|
|
|
21
22
|
'tokensLimit', 'tokensRemaining', 'requestsLimit', 'requestsRemaining', 'resetsAt',
|
|
22
23
|
];
|
|
23
24
|
|
|
25
|
+
// Longest Codex window still treated as a session bucket. The two lengths seen
|
|
26
|
+
// in practice are 300 minutes (5h) and 10080 (weekly), so a day sits safely
|
|
27
|
+
// between them.
|
|
28
|
+
const CODEX_WEEKLY_MIN_MINUTES = 1440;
|
|
29
|
+
|
|
30
|
+
// A Codex `*-reset-at` header as a ms timestamp. The wire format isn't pinned
|
|
31
|
+
// down (the sidecar forwards it opaquely), so accept epoch seconds, epoch ms,
|
|
32
|
+
// or an ISO-8601 date; anything else is null.
|
|
33
|
+
function parseResetAt(value) {
|
|
34
|
+
if (value == null || value === '') return null;
|
|
35
|
+
const n = Number(value);
|
|
36
|
+
if (Number.isFinite(n)) return n > 1e12 ? n : n * 1000;
|
|
37
|
+
const parsed = Date.parse(value);
|
|
38
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
39
|
+
}
|
|
40
|
+
|
|
24
41
|
function emptyQuota() {
|
|
25
42
|
return {
|
|
26
43
|
// Standard API rate limits (API key accounts)
|
|
@@ -108,7 +125,7 @@ function sampleModelFor(route) {
|
|
|
108
125
|
}
|
|
109
126
|
|
|
110
127
|
export class AccountManager {
|
|
111
|
-
constructor(accounts, switchThreshold = 0.98, { refreshFn = refreshAccessToken, throttleProbeFloorMs, forcedRefreshFloorMs = FORCED_REFRESH_FLOOR_MS, routes, ramp, distributeSessions = false, soonestWeekly, sessionTracker } = {}) {
|
|
128
|
+
constructor(accounts, switchThreshold = 0.98, { refreshFn = refreshAccessToken, throttleProbeFloorMs, forcedRefreshFloorMs = FORCED_REFRESH_FLOOR_MS, routes, ramp, distributeSessions = false, soonestWeekly, projection, sessionTracker } = {}) {
|
|
112
129
|
// How long a just-minted token is trusted against a forced refresh.
|
|
113
130
|
this._forcedRefreshFloorMs = forcedRefreshFloorMs;
|
|
114
131
|
// Injectable for tests (mirrors Prober's probeFn); defaults to the real
|
|
@@ -131,6 +148,7 @@ export class AccountManager {
|
|
|
131
148
|
this.switchThreshold = switchThreshold;
|
|
132
149
|
this.setRoutes(routes);
|
|
133
150
|
this.setSoonestWeekly(soonestWeekly);
|
|
151
|
+
this.setProjection(projection);
|
|
134
152
|
// Storm control: when rotation switches to a fresh account, a burst of
|
|
135
153
|
// in-flight requests (e.g. dozens of agents failing over together) would all
|
|
136
154
|
// hit it at once and instantly throttle it — cascading down the fleet
|
|
@@ -693,6 +711,47 @@ export class AccountManager {
|
|
|
693
711
|
};
|
|
694
712
|
}
|
|
695
713
|
|
|
714
|
+
/**
|
|
715
|
+
* Burn-rate projection settings, applied live on config reload. Enabled by
|
|
716
|
+
* default: the projection is a readout and no selection code consults it, so
|
|
717
|
+
* turning it on cannot change which account serves a request.
|
|
718
|
+
*/
|
|
719
|
+
setProjection(cfg) {
|
|
720
|
+
const c = cfg || {};
|
|
721
|
+
this.projection = new QuotaProjection({
|
|
722
|
+
enabled: c.enabled !== false,
|
|
723
|
+
windowMinutes: c.windowMinutes,
|
|
724
|
+
wasteFloor: c.wasteFloor ?? 0.1,
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Sample every reported bucket. Both quota write paths call this: response
|
|
729
|
+
* headers (updateQuota) and the usage probe (applyUsageData). */
|
|
730
|
+
_recordQuotaSamples(account, now = Date.now()) {
|
|
731
|
+
const q = account.quota;
|
|
732
|
+
for (const bucket of PROJECTED_BUCKETS) {
|
|
733
|
+
if (q[bucket] !== undefined) this.projection.record(account.index, bucket, q[bucket], now);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** Every bucket's projection for one account, keyed by bucket name. Buckets
|
|
738
|
+
* without a usable rate are absent rather than null. */
|
|
739
|
+
projectionsFor(accountIndex, now = Date.now()) {
|
|
740
|
+
const account = this.accounts[accountIndex];
|
|
741
|
+
if (!account) return {};
|
|
742
|
+
const q = account.quota;
|
|
743
|
+
const out = {};
|
|
744
|
+
for (const bucket of PROJECTED_BUCKETS) {
|
|
745
|
+
const projected = this.projection.project(accountIndex, bucket, {
|
|
746
|
+
utilization: q[bucket],
|
|
747
|
+
resetAt: q[`${bucket}Reset`],
|
|
748
|
+
now,
|
|
749
|
+
});
|
|
750
|
+
if (projected) out[bucket] = projected;
|
|
751
|
+
}
|
|
752
|
+
return out;
|
|
753
|
+
}
|
|
754
|
+
|
|
696
755
|
/**
|
|
697
756
|
* Normalize and store the configurable routing table. A route pins a set of
|
|
698
757
|
* model globs to an exclusive set of accounts (and may override the governing
|
|
@@ -1161,6 +1220,25 @@ export class AccountManager {
|
|
|
1161
1220
|
const uStatus = headers['anthropic-ratelimit-unified-status'];
|
|
1162
1221
|
if (uStatus) account.quota.unifiedStatus = uStatus;
|
|
1163
1222
|
|
|
1223
|
+
// OpenAI/Codex windows (`x-codex-*`, forwarded by a translating sidecar for
|
|
1224
|
+
// a ChatGPT-subscription account). Codex reports two windows, `primary` and
|
|
1225
|
+
// `secondary`, whose meaning comes from the declared length rather than the
|
|
1226
|
+
// position: a ChatGPT Pro plan reports its weekly limit as the primary one
|
|
1227
|
+
// and meters no secondary window at all. So each window is filed by length,
|
|
1228
|
+
// which lands it in the same 5h/weekly slots the rest of the code reads —
|
|
1229
|
+
// display, projection and switch-threshold logic apply unchanged. A window
|
|
1230
|
+
// with no length is a bucket the plan does not have, not one at 0% used.
|
|
1231
|
+
// used-percent is 0-100 (not the 0-1 fraction Anthropic reports).
|
|
1232
|
+
for (const window of ['primary', 'secondary']) {
|
|
1233
|
+
const used = parseFloat(headers[`x-codex-${window}-used-percent`]);
|
|
1234
|
+
const minutes = parseInt(headers[`x-codex-${window}-window-minutes`], 10);
|
|
1235
|
+
if (isNaN(used) || !(minutes > 0)) continue;
|
|
1236
|
+
const reset = parseResetAt(headers[`x-codex-${window}-reset-at`]);
|
|
1237
|
+
const weekly = minutes > CODEX_WEEKLY_MIN_MINUTES;
|
|
1238
|
+
account.quota[weekly ? 'unified7d' : 'unified5h'] = used / 100;
|
|
1239
|
+
if (reset != null) account.quota[weekly ? 'unified7dReset' : 'unified5hReset'] = reset;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1164
1242
|
// Standard rate limits (API key accounts)
|
|
1165
1243
|
const tokensLimit = parseInt(headers['anthropic-ratelimit-tokens-limit'], 10);
|
|
1166
1244
|
const tokensRemaining = parseInt(headers['anthropic-ratelimit-tokens-remaining'], 10);
|
|
@@ -1177,6 +1255,8 @@ export class AccountManager {
|
|
|
1177
1255
|
if (tokensReset) account.quota.resetsAt = tokensReset;
|
|
1178
1256
|
else if (requestsReset) account.quota.resetsAt = requestsReset;
|
|
1179
1257
|
|
|
1258
|
+
this._recordQuotaSamples(account);
|
|
1259
|
+
|
|
1180
1260
|
account.usage.totalRequests++;
|
|
1181
1261
|
account.usage.lastUsed = new Date().toISOString();
|
|
1182
1262
|
|
|
@@ -1244,6 +1324,8 @@ export class AccountManager {
|
|
|
1244
1324
|
if (usage.sevenDayFable.resetAt != null) q.unified7dFableReset = usage.sevenDayFable.resetAt;
|
|
1245
1325
|
}
|
|
1246
1326
|
|
|
1327
|
+
this._recordQuotaSamples(account);
|
|
1328
|
+
|
|
1247
1329
|
// If we just learned this account's weekly window while probing, re-evaluate
|
|
1248
1330
|
// selection (same path as learning it from a live response).
|
|
1249
1331
|
if (account.probing && q.unified7dReset != null) {
|
|
@@ -1437,6 +1519,7 @@ export class AccountManager {
|
|
|
1437
1519
|
routes: this.getRoutes(),
|
|
1438
1520
|
sessions: { ...sessions, distribute: this.distributeSessions },
|
|
1439
1521
|
soonestWeekly: { ...this.soonestWeekly },
|
|
1522
|
+
projection: this.projection.settings(),
|
|
1440
1523
|
accounts: this.accounts.map(a => ({
|
|
1441
1524
|
name: a.name,
|
|
1442
1525
|
type: a.type,
|
|
@@ -1447,6 +1530,10 @@ export class AccountManager {
|
|
|
1447
1530
|
sessions: sessions.perAccount[a.index] || 0,
|
|
1448
1531
|
quota: { ...a.quota },
|
|
1449
1532
|
usage: { ...a.usage },
|
|
1533
|
+
projection: (() => {
|
|
1534
|
+
const buckets = this.projectionsFor(a.index);
|
|
1535
|
+
return { headline: this.projection.headline(Object.values(buckets)), buckets };
|
|
1536
|
+
})(),
|
|
1450
1537
|
rateLimitedUntil: a.rateLimitedUntil
|
|
1451
1538
|
? new Date(a.rateLimitedUntil).toISOString()
|
|
1452
1539
|
: null,
|
package/src/claude-env.js
CHANGED
|
@@ -30,7 +30,66 @@ export function encodePinComponent(s) {
|
|
|
30
30
|
// `/tc-acct/` prefix. TC_ACCT itself is then unset, so the pin does not leak
|
|
31
31
|
// into claude or anything it spawns — same reasoning as `run` deleting it from
|
|
32
32
|
// the child environment.
|
|
33
|
-
|
|
33
|
+
// `config.customModels` → the `--settings` JSON that puts each model in the
|
|
34
|
+
// /model picker under its REAL id ({model, label?, description?} rows; typed
|
|
35
|
+
// `/model <id>` also accepts picker rows). contextTokens is ours, not Claude
|
|
36
|
+
// Code's — it feeds CLAUDE_CODE_MAX_CONTEXT_TOKENS below. Null when empty so
|
|
37
|
+
// callers can skip the flag entirely.
|
|
38
|
+
export function buildCustomModelSettings(customModels) {
|
|
39
|
+
if (!customModels?.length) return null;
|
|
40
|
+
const options = customModels.map(({ model, label, description }) => ({
|
|
41
|
+
model,
|
|
42
|
+
...(label ? { label } : {}),
|
|
43
|
+
...(description ? { description } : {}),
|
|
44
|
+
}));
|
|
45
|
+
return JSON.stringify({ modelPicker: { options } });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// `config.customModels` → the `--agents` JSON that makes each model
|
|
49
|
+
// dispatchable as a subagent. The Agent tool's per-invocation `model`
|
|
50
|
+
// parameter is an alias enum (sonnet|opus|haiku|fable) and rejects custom ids;
|
|
51
|
+
// an agent DEFINITION's `model:` field accepts any id, so each custom model
|
|
52
|
+
// gets a general-purpose agent named after it ("dispatch a gpt-5.6-terra
|
|
53
|
+
// subagent" then works out of the box). Null when empty.
|
|
54
|
+
export function buildCustomModelAgents(customModels) {
|
|
55
|
+
if (!customModels?.length) return null;
|
|
56
|
+
const agents = {};
|
|
57
|
+
for (const { model, label } of customModels) {
|
|
58
|
+
agents[model] = {
|
|
59
|
+
description: `General-purpose subagent running on ${label || model} (via TeamClaude). `
|
|
60
|
+
+ `Use when asked to run a task on ${model}.`,
|
|
61
|
+
prompt: `You are a general-purpose subagent running on the ${model} model. `
|
|
62
|
+
+ 'Complete the task you are given and report the results concisely.',
|
|
63
|
+
model,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return JSON.stringify(agents);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The env-only registration for launchers we can't pass flags to (`teamclaude
|
|
70
|
+
// env`). ANTHROPIC_CUSTOM_MODEL_OPTION registers ONE model (env can't express a
|
|
71
|
+
// list — the picker rows need `--settings`, i.e. `teamclaude run`), so the
|
|
72
|
+
// first entry is the one that gets a picker row and typed-/model acceptance.
|
|
73
|
+
// CLAUDE_CODE_MAX_CONTEXT_TOKENS is global for all unknown model ids: use the
|
|
74
|
+
// largest declared window so no custom model is compacted early; deliberately
|
|
75
|
+
// NOT modelOverrides, which would pin the window to the mapped Claude model's.
|
|
76
|
+
export function buildCustomModelVars(customModels) {
|
|
77
|
+
if (!customModels?.length) return {};
|
|
78
|
+
const vars = { ANTHROPIC_CUSTOM_MODEL_OPTION: customModels[0].model };
|
|
79
|
+
if (customModels[0].label) vars.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME = customModels[0].label;
|
|
80
|
+
if (customModels[0].description) vars.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION = customModels[0].description;
|
|
81
|
+
const windows = customModels.map(m => m.contextTokens).filter(n => Number.isFinite(n));
|
|
82
|
+
if (windows.length) vars.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(Math.max(...windows));
|
|
83
|
+
return vars;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Single-quote a value for an unquoted-context shell `export` line (labels and
|
|
87
|
+
// descriptions contain spaces). POSIX: close, escaped quote, reopen.
|
|
88
|
+
function shellQuote(value) {
|
|
89
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function buildClaudeEnvLines({ port, useMitm = true, caPath = null, holdSeconds = 0, account = null, proxyApiKey = '', customModels = null }) {
|
|
34
93
|
const lines = [];
|
|
35
94
|
const pin = (account || '').trim();
|
|
36
95
|
|
|
@@ -61,5 +120,10 @@ export function buildClaudeEnvLines({ port, useMitm = true, caPath = null, holdS
|
|
|
61
120
|
const holdMs = (holdSeconds || 0) * 1000;
|
|
62
121
|
if (holdMs > 0) lines.push(`export API_TIMEOUT_MS=${holdMs + 60_000}`);
|
|
63
122
|
|
|
123
|
+
// Custom (third-party) model registration — see buildCustomModelVars.
|
|
124
|
+
for (const [key, value] of Object.entries(buildCustomModelVars(customModels))) {
|
|
125
|
+
lines.push(`export ${key}=${shellQuote(value)}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
64
128
|
return lines;
|
|
65
129
|
}
|
package/src/config.js
CHANGED
|
@@ -58,6 +58,8 @@ export function createDefaultConfig() {
|
|
|
58
58
|
switchThreshold: 0.98,
|
|
59
59
|
holdSeconds: 0,
|
|
60
60
|
distributeSessions: false,
|
|
61
|
+
projection: { enabled: true, windowMinutes: 90, wasteFloor: 0.1 },
|
|
62
|
+
sessionTitles: { enabled: true, width: 18 },
|
|
61
63
|
eventLogging: 'hide',
|
|
62
64
|
blockedModels: [],
|
|
63
65
|
accounts: [],
|
package/src/index.js
CHANGED
|
@@ -15,12 +15,14 @@ import * as alias from './alias.js';
|
|
|
15
15
|
import { ensureCerts } from './mitm.js';
|
|
16
16
|
import { Prober } from './prober.js';
|
|
17
17
|
import { Warmer } from './warmer.js';
|
|
18
|
+
import { Sidecar } from './sidecar.js';
|
|
18
19
|
import { TUI } from './tui.js';
|
|
20
|
+
import { SessionTitles } from './session-titles.js';
|
|
19
21
|
import { RemoteControl, createAttachSession } from './tui-remote.js';
|
|
20
22
|
import { SxManager } from './sx.js';
|
|
21
23
|
import { autoUpdate, checkForUpdate, currentVersion, runUpdate, installKind, PKG_NAME } from './updater.js';
|
|
22
24
|
import { renderStatus } from './status-renderer.js';
|
|
23
|
-
import { buildClaudeEnvLines, encodePinComponent } from './claude-env.js';
|
|
25
|
+
import { buildClaudeEnvLines, buildCustomModelAgents, buildCustomModelSettings, buildCustomModelVars, encodePinComponent } from './claude-env.js';
|
|
24
26
|
import { serviceKind, installService, uninstallService, serviceStatus, renderService, logPath } from './service.js';
|
|
25
27
|
import { formatTerminalTitle, titleSequence, TITLE_STACK_PUSH, TITLE_STACK_POP } from './terminal-title.js';
|
|
26
28
|
import { getUpstreamProxy, describeProxy } from './upstream-proxy.js';
|
|
@@ -178,7 +180,12 @@ async function serverCommand() {
|
|
|
178
180
|
}
|
|
179
181
|
|
|
180
182
|
const threshold = config.switchThreshold || 0.98;
|
|
181
|
-
const accountManager = new AccountManager(accounts, threshold, { routes: config.routes, ramp: config.stormRamp, distributeSessions: config.distributeSessions, soonestWeekly: config.soonestWeekly });
|
|
183
|
+
const accountManager = new AccountManager(accounts, threshold, { routes: config.routes, ramp: config.stormRamp, distributeSessions: config.distributeSessions, soonestWeekly: config.soonestWeekly, projection: config.projection });
|
|
184
|
+
// Names the activity log's session column from Claude Code's own on-disk
|
|
185
|
+
// session titles. Built whether or not the TUI runs, so a reload has one
|
|
186
|
+
// object to reconfigure.
|
|
187
|
+
const sessionTitles = new SessionTitles(config.sessionTitles);
|
|
188
|
+
|
|
182
189
|
|
|
183
190
|
// Restore quota observed in a previous run so a restart doesn't lose rotation
|
|
184
191
|
// state (passive — we never call the API to re-learn it). Stale windows are
|
|
@@ -242,6 +249,9 @@ async function serverCommand() {
|
|
|
242
249
|
let prober = null;
|
|
243
250
|
// Opt-in keep-warm scheduler (config.warmupSeconds, default 0 = off).
|
|
244
251
|
let warmer = null;
|
|
252
|
+
// Supervised sidecar processes (config.sidecars, default none) — e.g. a local
|
|
253
|
+
// Anthropic→OpenAI translating proxy that a third-party account routes to.
|
|
254
|
+
let sidecar = null;
|
|
245
255
|
const serverStartedAt = Date.now();
|
|
246
256
|
|
|
247
257
|
// sx.org proxy (IP-based-429 workaround). Dormant unless an API key is set in
|
|
@@ -269,6 +279,10 @@ async function serverCommand() {
|
|
|
269
279
|
accountManager.setDistributeSessions(config.distributeSessions);
|
|
270
280
|
config.soonestWeekly = diskConfig.soonestWeekly;
|
|
271
281
|
accountManager.setSoonestWeekly(config.soonestWeekly);
|
|
282
|
+
config.projection = diskConfig.projection;
|
|
283
|
+
accountManager.setProjection(config.projection);
|
|
284
|
+
config.sessionTitles = diskConfig.sessionTitles;
|
|
285
|
+
sessionTitles.configure(config.sessionTitles);
|
|
272
286
|
// Apply an sx.org key/mode change made on disk (e.g. via POST /teamclaude/reload).
|
|
273
287
|
const diskSxKey = diskConfig.sx?.apiKey || null;
|
|
274
288
|
const diskSxMode = diskConfig.sx?.mode || 'always';
|
|
@@ -299,7 +313,7 @@ async function serverCommand() {
|
|
|
299
313
|
|
|
300
314
|
if (useTUI) {
|
|
301
315
|
tui = new TUI({
|
|
302
|
-
accountManager, config, sx, activityLogPath,
|
|
316
|
+
accountManager, config, sx, activityLogPath, sessionTitles,
|
|
303
317
|
saveConfig: () => atomicConfigUpdate(async diskConfig => {
|
|
304
318
|
// Write in-memory accounts as the authoritative state, preserving
|
|
305
319
|
// extra disk-only fields (e.g. importFrom) where the account still exists.
|
|
@@ -418,6 +432,7 @@ async function serverCommand() {
|
|
|
418
432
|
error: null,
|
|
419
433
|
})),
|
|
420
434
|
},
|
|
435
|
+
sidecars: sidecar?.getStatus() || [],
|
|
421
436
|
});
|
|
422
437
|
|
|
423
438
|
const server = createProxyServer(accountManager, config, hooks, sx);
|
|
@@ -487,6 +502,10 @@ async function serverCommand() {
|
|
|
487
502
|
});
|
|
488
503
|
warmer.start();
|
|
489
504
|
|
|
505
|
+
// Launch supervised sidecars (no-op when config.sidecars is empty).
|
|
506
|
+
sidecar = new Sidecar(config.sidecars);
|
|
507
|
+
sidecar.start();
|
|
508
|
+
|
|
490
509
|
// Background self-update for a backgrounded (headless) server. Skipped under
|
|
491
510
|
// the TUI, where npm's install output would corrupt the display — interactive
|
|
492
511
|
// users update via `teamclaude run` (post-session) or `teamclaude update`.
|
|
@@ -507,6 +526,7 @@ async function serverCommand() {
|
|
|
507
526
|
if (!tui) console.log('\n[TeamClaude] Shutting down...');
|
|
508
527
|
prober?.stop();
|
|
509
528
|
warmer?.stop();
|
|
529
|
+
sidecar?.stop();
|
|
510
530
|
if (quotaSaveInterval) clearInterval(quotaSaveInterval);
|
|
511
531
|
await persistQuotaState();
|
|
512
532
|
// Don't linger waiting on keep-alive / streaming connections: actively
|
|
@@ -669,6 +689,7 @@ async function envCommand() {
|
|
|
669
689
|
const lines = buildClaudeEnvLines({
|
|
670
690
|
port, useMitm, caPath, holdSeconds: config.holdSeconds,
|
|
671
691
|
account, proxyApiKey: config.proxy?.apiKey || '',
|
|
692
|
+
customModels: config.customModels,
|
|
672
693
|
});
|
|
673
694
|
process.stdout.write(`${lines.join('\n')}\n`);
|
|
674
695
|
|
|
@@ -726,7 +747,8 @@ async function runCommand() {
|
|
|
726
747
|
// also pins (shipped in 1.1.10). TC_ACCT is the supported way now — it works in
|
|
727
748
|
// MITM mode too, and keeps the pin out of the API path.
|
|
728
749
|
const pinnedBase = isLocalAccountPin(process.env.ANTHROPIC_BASE_URL, port);
|
|
729
|
-
|
|
750
|
+
const proxyUp = await isProxyUp(port);
|
|
751
|
+
if (proxyUp) {
|
|
730
752
|
if (useMitm) {
|
|
731
753
|
// Route ALL of claude's traffic through us as an HTTPS forward proxy, so
|
|
732
754
|
// even hardcoded api.anthropic.com endpoints (e.g. the design MCP) get the
|
|
@@ -774,6 +796,21 @@ async function runCommand() {
|
|
|
774
796
|
process.exit(1);
|
|
775
797
|
}
|
|
776
798
|
|
|
799
|
+
// Register custom (third-party) models with Claude Code — /model picker rows
|
|
800
|
+
// via --settings, typed-/model + window sizing via env — but only when routed
|
|
801
|
+
// through the proxy: launched directly, those models aren't reachable. A
|
|
802
|
+
// caller-supplied --settings wins; merging two would silently drop keys.
|
|
803
|
+
if (proxyUp && config.customModels?.length) {
|
|
804
|
+
Object.assign(env, buildCustomModelVars(config.customModels));
|
|
805
|
+
const settings = buildCustomModelSettings(config.customModels);
|
|
806
|
+
if (settings && !claudeArgs.includes('--settings')) claudeArgs.push('--settings', settings);
|
|
807
|
+
// Dispatchable subagents per custom model — the Agent tool's `model`
|
|
808
|
+
// parameter is an alias enum, so only a named agent definition can carry a
|
|
809
|
+
// custom model id into a subagent.
|
|
810
|
+
const agents = buildCustomModelAgents(config.customModels);
|
|
811
|
+
if (agents && !claudeArgs.includes('--agents')) claudeArgs.push('--agents', agents);
|
|
812
|
+
}
|
|
813
|
+
|
|
777
814
|
// If holdSeconds is set, ensure API_TIMEOUT_MS on the Claude Code side is
|
|
778
815
|
// large enough for the hold to complete. Add 60s padding (one extra poll
|
|
779
816
|
// cycle) so the client doesn't time out while we're still waiting.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Burn-rate projection for quota buckets.
|
|
3
|
+
*
|
|
4
|
+
* Each bucket reports utilization as a 0-1 fraction. Sampling that over a
|
|
5
|
+
* rolling window gives a consumption rate, and the rate against the bucket's
|
|
6
|
+
* known reset answers the question the bars cannot: will this window stop you
|
|
7
|
+
* before it resets, or expire with quota unspent?
|
|
8
|
+
*
|
|
9
|
+
* The rate is a least-squares slope rather than a first-to-last delta, so one
|
|
10
|
+
* large response does not swing the figure. The window is a trade: consumption
|
|
11
|
+
* is bursty and a recent-rate estimate is meant to track it, but utilization
|
|
12
|
+
* arrives quantised to whole percent, so too narrow a window contains no step
|
|
13
|
+
* to measure. Nothing is reported rather than a fabricated rate when the
|
|
14
|
+
* samples cannot support one.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Every bucket that reports utilization, in the order the TUI shows them. */
|
|
18
|
+
export const PROJECTED_BUCKETS = ['unified5h', 'unified7d', 'unified7dSonnet', 'unified7dFable'];
|
|
19
|
+
|
|
20
|
+
/** Buckets whose leftovers are worth reporting. A 5h window refills the same
|
|
21
|
+
* day, so its unspent tail costs nothing and is never reported as a surplus. */
|
|
22
|
+
const WEEKLY_BUCKETS = new Set(['unified7d', 'unified7dSonnet', 'unified7dFable']);
|
|
23
|
+
|
|
24
|
+
/** Row labels, matching the TUI's bar labels so one tag reads against them. */
|
|
25
|
+
const BUCKET_LABELS = {
|
|
26
|
+
unified5h: 'Ses',
|
|
27
|
+
unified7d: 'Wk',
|
|
28
|
+
unified7dSonnet: 'S7',
|
|
29
|
+
unified7dFable: 'F7',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Two samples one second apart would extrapolate a burst across a whole week.
|
|
33
|
+
* Below this span the samples are kept but no rate is reported. */
|
|
34
|
+
const MIN_SPAN_MS = 5 * 60_000;
|
|
35
|
+
|
|
36
|
+
/** Utilization is reported as whole percent, so the signal is a staircase with
|
|
37
|
+
* 1% steps. A window narrower than this can contain no step at all: measured
|
|
38
|
+
* against a 1%/h burn, a 30-minute window reports nothing half the time and
|
|
39
|
+
* ranges 0.4-2.9%/h when it does speak, while 90 minutes holds 0.9-1.1%/h. */
|
|
40
|
+
const DEFAULT_WINDOW_MINUTES = 90;
|
|
41
|
+
|
|
42
|
+
export class QuotaProjection {
|
|
43
|
+
constructor({ enabled = true, windowMinutes = DEFAULT_WINDOW_MINUTES, wasteFloor = 0.1 } = {}) {
|
|
44
|
+
this.enabled = enabled !== false;
|
|
45
|
+
this.windowMs = Math.max(1, windowMinutes) * 60_000;
|
|
46
|
+
this.wasteFloor = Math.max(0, Math.min(1, wasteFloor));
|
|
47
|
+
/** @type {Map<string, Array<{t: number, u: number}>>} */
|
|
48
|
+
this.samples = new Map();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The settings in force, for the status readout. */
|
|
52
|
+
settings() {
|
|
53
|
+
return {
|
|
54
|
+
enabled: this.enabled,
|
|
55
|
+
windowMinutes: this.windowMs / 60_000,
|
|
56
|
+
wasteFloor: this.wasteFloor,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Record one utilization reading. A null reading means the window rolled
|
|
61
|
+
* (_clearExpiredQuotas nulls the bucket at its reset), so the history is
|
|
62
|
+
* dropped: without this the roll reads as a large negative burn. */
|
|
63
|
+
record(accountIndex, bucket, utilization, at = Date.now()) {
|
|
64
|
+
if (!this.enabled) return;
|
|
65
|
+
const key = `${accountIndex}:${bucket}`;
|
|
66
|
+
if (utilization == null || isNaN(utilization)) {
|
|
67
|
+
this.samples.delete(key);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
let list = this.samples.get(key);
|
|
71
|
+
if (!list) {
|
|
72
|
+
list = [];
|
|
73
|
+
this.samples.set(key, list);
|
|
74
|
+
}
|
|
75
|
+
// A window can also roll as a decrease: a probe reports the fresh window
|
|
76
|
+
// before _clearExpiredQuotas nulls the bucket. Utilization never falls
|
|
77
|
+
// within a window, so a drop means the same restart a null does.
|
|
78
|
+
if (list.length && utilization < list[list.length - 1].u) list.length = 0;
|
|
79
|
+
list.push({ t: at, u: utilization });
|
|
80
|
+
const cutoff = at - this.windowMs;
|
|
81
|
+
while (list.length && list[0].t < cutoff) list.shift();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Consumption in utilization per millisecond, or null when the samples in
|
|
85
|
+
* the window cannot support an estimate (too few, too short a span, or no
|
|
86
|
+
* measurable consumption). */
|
|
87
|
+
rate(accountIndex, bucket) {
|
|
88
|
+
if (!this.enabled) return null;
|
|
89
|
+
const list = this.samples.get(`${accountIndex}:${bucket}`);
|
|
90
|
+
if (!list || list.length < 2) return null;
|
|
91
|
+
const span = list[list.length - 1].t - list[0].t;
|
|
92
|
+
if (span < MIN_SPAN_MS) return null;
|
|
93
|
+
|
|
94
|
+
// Times are relative to the first sample: epoch milliseconds squared loses
|
|
95
|
+
// precision in the sums below.
|
|
96
|
+
const t0 = list[0].t;
|
|
97
|
+
let sumT = 0, sumU = 0, sumTT = 0, sumTU = 0;
|
|
98
|
+
for (const { t, u } of list) {
|
|
99
|
+
const x = t - t0;
|
|
100
|
+
sumT += x;
|
|
101
|
+
sumU += u;
|
|
102
|
+
sumTT += x * x;
|
|
103
|
+
sumTU += x * u;
|
|
104
|
+
}
|
|
105
|
+
const n = list.length;
|
|
106
|
+
const denom = n * sumTT - sumT * sumT;
|
|
107
|
+
if (denom === 0) return null;
|
|
108
|
+
const slope = (n * sumTU - sumT * sumU) / denom;
|
|
109
|
+
return slope > 0 ? slope : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Project one bucket against its reset.
|
|
114
|
+
* @returns {{bucket: string, kind: 'deficit'|'surplus', exhaustsInMs?: number,
|
|
115
|
+
* unspent?: number, resetInMs: number} | null}
|
|
116
|
+
*/
|
|
117
|
+
project(accountIndex, bucket, { utilization, resetAt, now = Date.now() } = {}) {
|
|
118
|
+
if (!this.enabled) return null;
|
|
119
|
+
if (utilization == null || resetAt == null) return null;
|
|
120
|
+
const rate = this.rate(accountIndex, bucket);
|
|
121
|
+
if (rate == null) return null;
|
|
122
|
+
|
|
123
|
+
const resetInMs = resetAt - now;
|
|
124
|
+
const remaining = 1 - utilization;
|
|
125
|
+
if (remaining <= 0) return { bucket, kind: 'deficit', exhaustsInMs: 0, resetInMs };
|
|
126
|
+
|
|
127
|
+
const exhaustsInMs = remaining / rate;
|
|
128
|
+
if (exhaustsInMs <= resetInMs) return { bucket, kind: 'deficit', exhaustsInMs, resetInMs };
|
|
129
|
+
|
|
130
|
+
if (!WEEKLY_BUCKETS.has(bucket)) return null;
|
|
131
|
+
const unspent = remaining - rate * resetInMs;
|
|
132
|
+
if (unspent < this.wasteFloor) return null;
|
|
133
|
+
return { bucket, kind: 'surplus', unspent, resetInMs };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Projections in display order: anything that will stop you comes before
|
|
137
|
+
* anything that will merely expire, soonest and largest first. */
|
|
138
|
+
rank(projections) {
|
|
139
|
+
const rank = p => (p.kind === 'deficit' ? 0 : 1);
|
|
140
|
+
return (projections || []).filter(Boolean).sort((a, b) => {
|
|
141
|
+
if (rank(a) !== rank(b)) return rank(a) - rank(b);
|
|
142
|
+
return a.kind === 'deficit' ? a.exhaustsInMs - b.exhaustsInMs : b.unspent - a.unspent;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The most urgent projection, or null when there is nothing to report. */
|
|
147
|
+
headline(projections) {
|
|
148
|
+
return this.rank(projections)[0] || null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Render a projection as a row tag, e.g. "Ses TTL 38m" or "Wk 22% unspent". */
|
|
153
|
+
export function formatProjection(projection) {
|
|
154
|
+
if (!projection) return null;
|
|
155
|
+
const label = BUCKET_LABELS[projection.bucket] || projection.bucket;
|
|
156
|
+
if (projection.kind === 'deficit') return `${label} TTL ${formatDuration(projection.exhaustsInMs)}`;
|
|
157
|
+
return `${label} ${Math.round(projection.unspent * 100)}% unspent`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function formatDuration(ms) {
|
|
161
|
+
const minutes = Math.max(0, Math.round(ms / 60_000));
|
|
162
|
+
if (minutes < 60) return `${minutes}m`;
|
|
163
|
+
const hours = Math.floor(minutes / 60);
|
|
164
|
+
if (hours < 24) return `${hours}h${minutes % 60}m`;
|
|
165
|
+
return `${Math.floor(hours / 24)}d${hours % 24}h`;
|
|
166
|
+
}
|
package/src/server.js
CHANGED
|
@@ -918,12 +918,7 @@ export async function forwardRequest(req, res, body, accountManager, upstream, r
|
|
|
918
918
|
}
|
|
919
919
|
|
|
920
920
|
// Extract rate limit headers
|
|
921
|
-
const rateLimitHeaders =
|
|
922
|
-
for (const [key, value] of upstreamRes.headers.entries()) {
|
|
923
|
-
if (key.startsWith('anthropic-ratelimit-')) {
|
|
924
|
-
rateLimitHeaders[key] = value;
|
|
925
|
-
}
|
|
926
|
-
}
|
|
921
|
+
const rateLimitHeaders = collectRateLimitHeaders(upstreamRes.headers);
|
|
927
922
|
accountManager.updateQuota(account.index, rateLimitHeaders);
|
|
928
923
|
|
|
929
924
|
// Any non-429 response is live proof a rate-limit hold no longer binds —
|
|
@@ -950,7 +945,10 @@ export async function forwardRequest(req, res, body, accountManager, upstream, r
|
|
|
950
945
|
// already recorded the spent bucket's utilization from the headers).
|
|
951
946
|
const rl = rateLimitHeaders;
|
|
952
947
|
const generalRejected = rl['anthropic-ratelimit-unified-5h-status'] === 'rejected'
|
|
953
|
-
|| rl['anthropic-ratelimit-unified-7d-status'] === 'rejected'
|
|
948
|
+
|| rl['anthropic-ratelimit-unified-7d-status'] === 'rejected'
|
|
949
|
+
// A spent Codex window on a sidecar-backed account is the same shape:
|
|
950
|
+
// a durable quota rejection, not a transient throttle.
|
|
951
|
+
|| codexQuotaRejected(rl);
|
|
954
952
|
const fableRejected = rl['anthropic-ratelimit-unified-7d_oi-status'] === 'rejected' && !generalRejected;
|
|
955
953
|
if ((generalRejected || fableRejected) && retryCount < maxRetries) {
|
|
956
954
|
// A Fable-only rejection leaves the account fine for other models, so we
|
|
@@ -1306,6 +1304,25 @@ export function rewriteModel(body, modelMap) {
|
|
|
1306
1304
|
return body;
|
|
1307
1305
|
}
|
|
1308
1306
|
|
|
1307
|
+
// Rate-limit telemetry we pass to AccountManager.updateQuota: Anthropic's
|
|
1308
|
+
// `anthropic-ratelimit-*` family, plus the OpenAI/Codex `x-codex-*` family a
|
|
1309
|
+
// translating sidecar may forward from the ChatGPT backend. Exported for tests.
|
|
1310
|
+
export function collectRateLimitHeaders(headers) {
|
|
1311
|
+
const out = {};
|
|
1312
|
+
for (const [key, value] of headers.entries()) {
|
|
1313
|
+
if (key.startsWith('anthropic-ratelimit-') || key.startsWith('x-codex-')) out[key] = value;
|
|
1314
|
+
}
|
|
1315
|
+
return out;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// Durable Codex quota exhaustion: either subscription window (primary ≈ 5h,
|
|
1319
|
+
// secondary ≈ weekly) reports fully spent. Like a unified "rejected" status,
|
|
1320
|
+
// retrying the same account is futile until the window resets. Exported for tests.
|
|
1321
|
+
export function codexQuotaRejected(rl) {
|
|
1322
|
+
return parseFloat(rl['x-codex-primary-used-percent']) >= 100
|
|
1323
|
+
|| parseFloat(rl['x-codex-secondary-used-percent']) >= 100;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1309
1326
|
function computeRetryAfter(accounts) {
|
|
1310
1327
|
let soonest = Infinity;
|
|
1311
1328
|
for (const acct of accounts) {
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session titles for the activity log.
|
|
3
|
+
*
|
|
4
|
+
* The proxy already knows which Claude Code session a request belongs to: the
|
|
5
|
+
* client sends `x-claude-code-session-id`, and the TUI prints its first six hex
|
|
6
|
+
* characters. That distinguishes concurrent sessions but does not name them.
|
|
7
|
+
*
|
|
8
|
+
* Claude Code stores the name on disk under `~/.claude/projects/<slug>/`. A
|
|
9
|
+
* `/rename` writes `<session-id>/custom-title.json`; it also appends a
|
|
10
|
+
* `custom-title` record to the transcript, which is the only copy for a session
|
|
11
|
+
* renamed before the sidecar existed. Claude Code generates its own title as
|
|
12
|
+
* well and appends it as an `ai-title` record, so most sessions have a usable
|
|
13
|
+
* name without a rename.
|
|
14
|
+
*
|
|
15
|
+
* Lookups are cached and run off the render path: `get` returns what is cached
|
|
16
|
+
* and schedules the read, so a frame never waits on the disk.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readdir, readFile, open } from 'node:fs/promises';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
const DEFAULT_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
|
|
24
|
+
|
|
25
|
+
/** A rename must reach the TUI without a restart, and a re-read costs one
|
|
26
|
+
* directory scan and one 64 KB read. */
|
|
27
|
+
const DEFAULT_TTL_MS = 30_000;
|
|
28
|
+
|
|
29
|
+
/** Measured across 218 transcripts: the last title record sits within 32.7 KB
|
|
30
|
+
* of EOF at p99 and 46.3 KB at the maximum. Reading more would only reach
|
|
31
|
+
* sessions that have written megabytes since their last title. */
|
|
32
|
+
const DEFAULT_TAIL_BYTES = 64 * 1024;
|
|
33
|
+
|
|
34
|
+
/** Columns the activity log gives the session label. Every row pays this width,
|
|
35
|
+
* named or not, so the columns after it stay aligned. A typed name fits: the
|
|
36
|
+
* longest in use is "adv-review-rewrite" at 18. A generated title is a sentence
|
|
37
|
+
* and is cut. */
|
|
38
|
+
const DEFAULT_WIDTH = 18;
|
|
39
|
+
|
|
40
|
+
/** The activity log falls back to this many hex characters of the session id,
|
|
41
|
+
* so a label can never be narrower. */
|
|
42
|
+
const SHORT_ID_LEN = 6;
|
|
43
|
+
|
|
44
|
+
export class SessionTitles {
|
|
45
|
+
constructor({ projectsDir = DEFAULT_PROJECTS_DIR, ttlMs = DEFAULT_TTL_MS, tailBytes = DEFAULT_TAIL_BYTES, ...cfg } = {}) {
|
|
46
|
+
this.projectsDir = projectsDir;
|
|
47
|
+
this.ttlMs = ttlMs;
|
|
48
|
+
this.tailBytes = tailBytes;
|
|
49
|
+
/** @type {Map<string, {title: string|null, at: number}>} */
|
|
50
|
+
this.cache = new Map();
|
|
51
|
+
/** @type {Map<string, Promise<string|null>>} */
|
|
52
|
+
this.inflight = new Map();
|
|
53
|
+
this.configure(cfg);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Apply a config change in place, so a reload takes effect without a
|
|
57
|
+
* restart. An absent key returns to its default. */
|
|
58
|
+
configure(cfg) {
|
|
59
|
+
const { enabled = true, width = DEFAULT_WIDTH } = cfg || {};
|
|
60
|
+
this.enabled = enabled !== false;
|
|
61
|
+
// A label narrower than the short id it falls back to would cut the id.
|
|
62
|
+
this.width = Math.max(SHORT_ID_LEN, Math.trunc(width) || DEFAULT_WIDTH);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The settings in force, for the status readout. */
|
|
66
|
+
settings() {
|
|
67
|
+
return { enabled: this.enabled, width: this.width };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The cached title, or null. Never touches the disk: a miss or a stale entry
|
|
71
|
+
* schedules the read and the caller gets the previous answer until it lands. */
|
|
72
|
+
get(sessionId, now = Date.now()) {
|
|
73
|
+
if (!this.enabled || !sessionId) return null;
|
|
74
|
+
const hit = this.cache.get(sessionId);
|
|
75
|
+
if (!hit || now - hit.at >= this.ttlMs) this._schedule(sessionId, now);
|
|
76
|
+
return hit ? hit.title : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Read the title now. Resolves to null when the session has none. */
|
|
80
|
+
async resolve(sessionId, now = Date.now()) {
|
|
81
|
+
if (!this.enabled || !sessionId) return null;
|
|
82
|
+
return this._schedule(sessionId, now);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Number of reads in flight. */
|
|
86
|
+
pending() {
|
|
87
|
+
return this.inflight.size;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Settle every scheduled read. A resolve can schedule another, so this loops. */
|
|
91
|
+
async idle() {
|
|
92
|
+
while (this.inflight.size) await Promise.all([...this.inflight.values()]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_schedule(sessionId, now) {
|
|
96
|
+
const existing = this.inflight.get(sessionId);
|
|
97
|
+
if (existing) return existing;
|
|
98
|
+
// A missing title is cached like a found one: without that, every frame
|
|
99
|
+
// would rescan the projects directory for a session that has no name.
|
|
100
|
+
const read = this._read(sessionId)
|
|
101
|
+
.catch(() => null)
|
|
102
|
+
.then((title) => {
|
|
103
|
+
this.cache.set(sessionId, { title, at: now });
|
|
104
|
+
return title;
|
|
105
|
+
})
|
|
106
|
+
.finally(() => this.inflight.delete(sessionId));
|
|
107
|
+
this.inflight.set(sessionId, read);
|
|
108
|
+
return read;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The project directory is keyed by the session's cwd, which the proxy does
|
|
112
|
+
* not know, so each directory is tried until one holds the session. */
|
|
113
|
+
async _read(sessionId) {
|
|
114
|
+
const entries = await readdir(this.projectsDir, { withFileTypes: true }).catch(() => []);
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
if (!entry.isDirectory()) continue;
|
|
117
|
+
const project = join(this.projectsDir, entry.name);
|
|
118
|
+
const renamed = await this._fromSidecar(join(project, sessionId, 'custom-title.json'));
|
|
119
|
+
if (renamed) return renamed;
|
|
120
|
+
const recorded = await this._fromTranscript(join(project, `${sessionId}.jsonl`));
|
|
121
|
+
if (recorded) return recorded;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async _fromSidecar(path) {
|
|
127
|
+
const raw = await readFile(path, 'utf8').catch(() => null);
|
|
128
|
+
if (raw == null) return null;
|
|
129
|
+
try {
|
|
130
|
+
return cleanTitle(JSON.parse(raw).customTitle);
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async _fromTranscript(path) {
|
|
137
|
+
const file = await open(path, 'r').catch(() => null);
|
|
138
|
+
if (!file) return null;
|
|
139
|
+
try {
|
|
140
|
+
const { size } = await file.stat();
|
|
141
|
+
const start = Math.max(0, size - this.tailBytes);
|
|
142
|
+
const buffer = Buffer.alloc(Math.min(size, this.tailBytes));
|
|
143
|
+
if (buffer.length) await file.read(buffer, 0, buffer.length, start);
|
|
144
|
+
const lines = buffer.toString('utf8').split('\n');
|
|
145
|
+
// A window that does not start at byte 0 opens mid-line; that fragment is
|
|
146
|
+
// not a JSON record.
|
|
147
|
+
if (start > 0) lines.shift();
|
|
148
|
+
return lastTitle(lines);
|
|
149
|
+
} finally {
|
|
150
|
+
await file.close();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A typed name wins wherever it sits in the file; otherwise the most recent
|
|
156
|
+
* generated one. Both are re-appended as a session runs, so the scan reads
|
|
157
|
+
* backwards and stops at the first match. */
|
|
158
|
+
function lastTitle(lines) {
|
|
159
|
+
let generated = null;
|
|
160
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
161
|
+
const line = lines[i];
|
|
162
|
+
// Transcript lines are mostly multi-kilobyte messages; reject those on a
|
|
163
|
+
// substring before paying for JSON.parse.
|
|
164
|
+
if (!line || line[0] !== '{' || !line.includes('-title"')) continue;
|
|
165
|
+
let record;
|
|
166
|
+
try {
|
|
167
|
+
record = JSON.parse(line);
|
|
168
|
+
} catch {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (record.type === 'custom-title') {
|
|
172
|
+
const typed = cleanTitle(record.customTitle);
|
|
173
|
+
if (typed) return typed;
|
|
174
|
+
}
|
|
175
|
+
if (record.type === 'ai-title' && !generated) generated = cleanTitle(record.aiTitle);
|
|
176
|
+
}
|
|
177
|
+
return generated;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function cleanTitle(value) {
|
|
181
|
+
if (typeof value !== 'string') return null;
|
|
182
|
+
const trimmed = value.trim();
|
|
183
|
+
return trimmed || null;
|
|
184
|
+
}
|
package/src/sidecar.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Sidecar supervisor (codex-proxy feature).
|
|
2
|
+
//
|
|
3
|
+
// TeamClaude is the controller: when a third-party backend account points at a
|
|
4
|
+
// local translating proxy (e.g. raine/claude-code-proxy for the ChatGPT/Codex
|
|
5
|
+
// backend), the server owns that process rather than asking the user to run it
|
|
6
|
+
// by hand or via brew services. Each `config.sidecars[]` entry is spawned on
|
|
7
|
+
// server start, respawned with exponential backoff when it dies, and killed on
|
|
8
|
+
// shutdown. Supervision is process-level only — routing to the sidecar is
|
|
9
|
+
// unchanged (a normal `accounts[].upstream` + route).
|
|
10
|
+
//
|
|
11
|
+
// stdout is ignored (sidecars keep their own log files); stderr's last few
|
|
12
|
+
// lines are kept in a ring buffer so `getStatus()` can say WHY a sidecar is
|
|
13
|
+
// crash-looping without anyone hunting for its logs.
|
|
14
|
+
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
|
|
17
|
+
/** Delay before restart attempt N (0-based): base, doubled per consecutive
|
|
18
|
+
* crash, capped. Pure so the schedule is testable without timers. */
|
|
19
|
+
export function restartDelayMs(restarts, { baseRestartMs, maxRestartMs }) {
|
|
20
|
+
return Math.min(baseRestartMs * 2 ** restarts, maxRestartMs);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class Sidecar {
|
|
24
|
+
constructor(entries, {
|
|
25
|
+
spawnFn = defaultSpawn,
|
|
26
|
+
baseRestartMs = 1000,
|
|
27
|
+
maxRestartMs = 30_000,
|
|
28
|
+
stableMs = 30_000,
|
|
29
|
+
stderrTailLines = 20,
|
|
30
|
+
log = console.log,
|
|
31
|
+
} = {}) {
|
|
32
|
+
this.entries = Array.isArray(entries) ? entries : [];
|
|
33
|
+
this.spawnFn = spawnFn;
|
|
34
|
+
this.baseRestartMs = baseRestartMs;
|
|
35
|
+
this.maxRestartMs = maxRestartMs;
|
|
36
|
+
this.stableMs = stableMs;
|
|
37
|
+
this.stderrTailLines = stderrTailLines;
|
|
38
|
+
this.log = log;
|
|
39
|
+
this.stopping = false;
|
|
40
|
+
// Per-entry runtime state, keyed by entry (parallel array to this.entries).
|
|
41
|
+
this.states = this.entries.map(entry => ({
|
|
42
|
+
entry,
|
|
43
|
+
child: null,
|
|
44
|
+
startedAt: null,
|
|
45
|
+
restarts: 0,
|
|
46
|
+
lastExit: null,
|
|
47
|
+
timer: null,
|
|
48
|
+
stderrTail: [],
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
start() {
|
|
53
|
+
for (const state of this.states) this._spawn(state);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
stop() {
|
|
57
|
+
this.stopping = true;
|
|
58
|
+
for (const state of this.states) {
|
|
59
|
+
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
|
60
|
+
state.child?.kill('SIGTERM');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
getStatus() {
|
|
65
|
+
return this.states.map(state => ({
|
|
66
|
+
name: state.entry.name,
|
|
67
|
+
running: !!state.child,
|
|
68
|
+
pid: state.child?.pid ?? null,
|
|
69
|
+
restarts: state.restarts,
|
|
70
|
+
lastExit: state.lastExit,
|
|
71
|
+
stderrTail: [...state.stderrTail],
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_spawn(state) {
|
|
76
|
+
const { entry } = state;
|
|
77
|
+
const [command, ...args] = entry.command;
|
|
78
|
+
let child;
|
|
79
|
+
try {
|
|
80
|
+
child = this.spawnFn({
|
|
81
|
+
name: entry.name,
|
|
82
|
+
command,
|
|
83
|
+
args,
|
|
84
|
+
env: { ...process.env, ...(entry.env || {}) },
|
|
85
|
+
});
|
|
86
|
+
} catch (err) {
|
|
87
|
+
this._onDown(state, `spawn failed: ${err?.message || err}`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
state.child = child;
|
|
91
|
+
state.startedAt = Date.now();
|
|
92
|
+
child.stderr?.on('data', (chunk) => this._recordStderr(state, chunk));
|
|
93
|
+
child.once('error', (err) => {
|
|
94
|
+
if (state.child !== child) return;
|
|
95
|
+
this._onDown(state, `spawn error: ${err?.message || err}`);
|
|
96
|
+
});
|
|
97
|
+
child.once('exit', (code, signal) => {
|
|
98
|
+
if (state.child !== child) return;
|
|
99
|
+
this._onDown(state, signal ? `signal ${signal}` : `code ${code}`);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
_onDown(state, lastExit) {
|
|
104
|
+
// A run that survived long enough resets the backoff: the next crash is a
|
|
105
|
+
// fresh incident, not a continuation of a crash loop.
|
|
106
|
+
if (state.startedAt && Date.now() - state.startedAt >= this.stableMs) state.restarts = 0;
|
|
107
|
+
state.child = null;
|
|
108
|
+
state.lastExit = lastExit;
|
|
109
|
+
if (this.stopping) return;
|
|
110
|
+
const delay = restartDelayMs(state.restarts, this);
|
|
111
|
+
this.log(`[TeamClaude] Sidecar "${state.entry.name}" down (${lastExit}); restarting in ${Math.round(delay / 1000)}s`);
|
|
112
|
+
state.restarts += 1;
|
|
113
|
+
state.timer = setTimeout(() => {
|
|
114
|
+
state.timer = null;
|
|
115
|
+
this._spawn(state);
|
|
116
|
+
}, delay);
|
|
117
|
+
state.timer.unref?.();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
_recordStderr(state, chunk) {
|
|
121
|
+
const lines = String(chunk).split('\n').map(s => s.trim()).filter(Boolean);
|
|
122
|
+
state.stderrTail.push(...lines);
|
|
123
|
+
if (state.stderrTail.length > this.stderrTailLines) {
|
|
124
|
+
state.stderrTail.splice(0, state.stderrTail.length - this.stderrTailLines);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Real spawner: stdout ignored (sidecars log to their own files), stderr piped
|
|
130
|
+
// for the ring buffer. detached:false so the child dies with us as a backstop.
|
|
131
|
+
function defaultSpawn({ command, args, env }) {
|
|
132
|
+
return spawn(command, args, { env, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
133
|
+
}
|
package/src/status-renderer.js
CHANGED
|
@@ -276,9 +276,12 @@ function formatServerSummary(server, now) {
|
|
|
276
276
|
return started ? `up ${formatDuration(now - started)}` : 'unknown';
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
-
function formatPercent(value) {
|
|
279
|
+
export function formatPercent(value) {
|
|
280
280
|
if (value == null || Number.isNaN(Number(value))) return '-';
|
|
281
|
-
|
|
281
|
+
// The switch threshold can be set to a tenth of a percent, so rounding to a
|
|
282
|
+
// whole one would print a value that was never stored. Reported utilization
|
|
283
|
+
// arrives on a whole-percent grid, so bars are unaffected.
|
|
284
|
+
return `${Math.round(Number(value) * 1000) / 10}%`;
|
|
282
285
|
}
|
|
283
286
|
|
|
284
287
|
function formatNumber(value) {
|
package/src/tui-remote.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { TUI } from './tui.js';
|
|
2
|
+
import { SessionTitles } from './session-titles.js';
|
|
2
3
|
import { modelGlobMatches } from './model.js';
|
|
3
4
|
|
|
4
5
|
// Attach mode — the dashboard against a server running somewhere else (a
|
|
@@ -234,6 +235,10 @@ export function createAttachSession({ control, config, onQuit, pollMs = DEFAULT_
|
|
|
234
235
|
accountManager: am,
|
|
235
236
|
config,
|
|
236
237
|
remote: true,
|
|
238
|
+
// Titles come from this machine's Claude Code sessions. Attaching to a
|
|
239
|
+
// server on another host leaves the ids unresolved, which shows the short
|
|
240
|
+
// id rather than a wrong name.
|
|
241
|
+
sessionTitles: new SessionTitles(config?.sessionTitles),
|
|
237
242
|
// Every screen that writes config is unreachable in attach mode; if one ever
|
|
238
243
|
// becomes reachable, this fails loudly instead of silently dropping a save.
|
|
239
244
|
saveConfig: async () => { throw new Error('attach mode cannot write config'); },
|
package/src/tui.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createWriteStream } from 'node:fs';
|
|
2
2
|
import { importCredentials, fetchProfile } from './oauth.js';
|
|
3
3
|
import { sameIdentity, findUpsertTarget } from './identity.js';
|
|
4
|
+
import { formatProjection } from './quota-projection.js';
|
|
5
|
+
import { formatPercent } from './status-renderer.js';
|
|
4
6
|
import { parseProxyUrl, proxyToUrl, describeProxy, resolveUpstreamProxy, setUpstreamProxy, getUpstreamProxy } from './upstream-proxy.js';
|
|
5
7
|
|
|
6
8
|
// ── ANSI helpers ─────────────────────────────────────────────
|
|
@@ -62,10 +64,12 @@ function sessionColorCode(sid) {
|
|
|
62
64
|
for (let i = 0; i < sid.length; i++) h = (h * 31 + sid.charCodeAt(i)) >>> 0;
|
|
63
65
|
return SESSION_FG[h % SESSION_FG.length];
|
|
64
66
|
}
|
|
65
|
-
// Fixed-width colored
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
// Fixed-width colored session label: the name Claude Code holds on disk for the
|
|
68
|
+
// session (see session-titles.js), else the short id. Blank-padded when there's
|
|
69
|
+
// no session (e.g. a telemetry request). One width for every row, named or not,
|
|
70
|
+
// keeps the columns after it aligned.
|
|
71
|
+
const sessionTag = (sid, title = null, width = SESSION_ID_LEN) =>
|
|
72
|
+
sid ? fg(sessionColorCode(sid), (title || sid.slice(0, SESSION_ID_LEN)).slice(0, width).padEnd(width)) : ' '.repeat(width);
|
|
69
73
|
|
|
70
74
|
// Which quota-family bar (F7/S7) a route binds to, or null for a general route.
|
|
71
75
|
// Auto routes are named 'fable'/'sonnet'; a configured route is classified by its
|
|
@@ -198,7 +202,10 @@ export class TUI {
|
|
|
198
202
|
remote = false, applySwitch = null,
|
|
199
203
|
// Injectable so the import path can be exercised without a real credentials
|
|
200
204
|
// file or a live profile call.
|
|
201
|
-
readCredentials = importCredentials, readProfile = fetchProfile
|
|
205
|
+
readCredentials = importCredentials, readProfile = fetchProfile,
|
|
206
|
+
// Names the activity column against the session id the client sent. Absent
|
|
207
|
+
// or disabled leaves every row showing the short id.
|
|
208
|
+
sessionTitles = null }) {
|
|
202
209
|
this.am = accountManager;
|
|
203
210
|
this.remote = remote;
|
|
204
211
|
this.applySwitch = applySwitch;
|
|
@@ -213,6 +220,7 @@ export class TUI {
|
|
|
213
220
|
this._readCredentials = readCredentials;
|
|
214
221
|
this._readProfile = readProfile;
|
|
215
222
|
this._activityStream = null;
|
|
223
|
+
this.sessionTitles = sessionTitles;
|
|
216
224
|
|
|
217
225
|
this.log = []; // completed activity entries
|
|
218
226
|
this.active = new Map(); // in-flight requests
|
|
@@ -312,9 +320,20 @@ export class TUI {
|
|
|
312
320
|
process.stdin.pause();
|
|
313
321
|
}
|
|
314
322
|
|
|
323
|
+
// A title lookup costs a directory scan and a file read, so it stays off the
|
|
324
|
+
// render path: this returns what is cached and schedules the rest.
|
|
325
|
+
_sessionTag(sid) {
|
|
326
|
+
const titles = this.sessionTitles;
|
|
327
|
+
if (!titles?.enabled) return sessionTag(sid);
|
|
328
|
+
return sessionTag(sid, titles.get(sid), titles.width);
|
|
329
|
+
}
|
|
330
|
+
|
|
315
331
|
// ── server hooks ───────────────────────────────────
|
|
316
332
|
|
|
317
333
|
onRequestStart(id, info) {
|
|
334
|
+
// Start the lookup now, so the title is cached by the time the request ends
|
|
335
|
+
// and its log line is composed.
|
|
336
|
+
this._sessionTag(info.sessionId);
|
|
318
337
|
this.active.set(id, { ...info, t: timestamp(), started: Date.now(), account: null });
|
|
319
338
|
this.render();
|
|
320
339
|
if (this.active.size === 1) this._retick(); // idle → animating
|
|
@@ -338,7 +357,7 @@ export class TUI {
|
|
|
338
357
|
const model = info.model ? ` (${info.model})` : ''; // shown when the request named a model
|
|
339
358
|
const sid = info.sessionId || r?.sessionId || null;
|
|
340
359
|
const pin = (info.pinned || r?.pinned) ? dim(' [pin]') : '';
|
|
341
|
-
this._addLog(`${
|
|
360
|
+
this._addLog(`${this._sessionTag(sid)} ${info.method} ${info.path}${model} → ${acct}${pin} (${info.status}, ${dur}s)`);
|
|
342
361
|
if (this.active.size === 0) this._retick(); // animating → idle
|
|
343
362
|
}
|
|
344
363
|
|
|
@@ -411,13 +430,10 @@ export class TUI {
|
|
|
411
430
|
id: 'threshold',
|
|
412
431
|
label: 'Switch threshold',
|
|
413
432
|
hint: '←→ ±1%',
|
|
414
|
-
value: () =>
|
|
415
|
-
const thr = this.am.switchThreshold ?? this.config.switchThreshold ?? 0.98;
|
|
416
|
-
return green(`${Math.round(thr * 100)}%`);
|
|
417
|
-
},
|
|
433
|
+
value: () => green(formatPercent(this.am.switchThreshold ?? this.config.switchThreshold ?? 0.98)),
|
|
418
434
|
left: () => this._nudgeThreshold(-1),
|
|
419
435
|
right: () => this._nudgeThreshold(+1),
|
|
420
|
-
enter: () => this._promptInput('Switch threshold % (1-100)', v => this._doSetThreshold(v.trim())),
|
|
436
|
+
enter: () => this._promptInput('Switch threshold % (1-100, tenths allowed)', v => this._doSetThreshold(v.trim())),
|
|
421
437
|
});
|
|
422
438
|
|
|
423
439
|
fields.push({
|
|
@@ -448,6 +464,18 @@ export class TUI {
|
|
|
448
464
|
enter: () => this._cycleEventLogging(+1),
|
|
449
465
|
});
|
|
450
466
|
|
|
467
|
+
if (this.sessionTitles) {
|
|
468
|
+
fields.push({
|
|
469
|
+
id: 'sessionTitles',
|
|
470
|
+
label: 'Session titles',
|
|
471
|
+
hint: '←→ toggle',
|
|
472
|
+
value: () => (this.sessionTitles.enabled ? green('on') : gray('off')),
|
|
473
|
+
left: () => this._toggleSessionTitles(),
|
|
474
|
+
right: () => this._toggleSessionTitles(),
|
|
475
|
+
enter: () => this._toggleSessionTitles(),
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
451
479
|
fields.push({
|
|
452
480
|
id: 'routes',
|
|
453
481
|
label: 'Manage routing',
|
|
@@ -572,9 +600,11 @@ export class TUI {
|
|
|
572
600
|
}
|
|
573
601
|
|
|
574
602
|
_nudgeThreshold(deltaPct) {
|
|
575
|
-
|
|
603
|
+
// Stepping from the exact percent, not a rounded one, so a threshold set to
|
|
604
|
+
// a tenth keeps its fraction instead of snapping to the nearest whole.
|
|
605
|
+
const cur = (this.am.switchThreshold ?? this.config.switchThreshold ?? 0.98) * 100;
|
|
576
606
|
const next = Math.max(1, Math.min(100, cur + deltaPct));
|
|
577
|
-
if (next !== cur) this._doSetThreshold(String(next));
|
|
607
|
+
if (next !== cur) return this._doSetThreshold(String(next));
|
|
578
608
|
}
|
|
579
609
|
|
|
580
610
|
_nudgeProbe(deltaSec) {
|
|
@@ -588,7 +618,9 @@ export class TUI {
|
|
|
588
618
|
if (!Number.isFinite(pct) || pct < 1 || pct > 100) {
|
|
589
619
|
this._addLog('Invalid threshold — enter 1–100'); this.mode = 'settings'; if (this.running) this.render(); return;
|
|
590
620
|
}
|
|
591
|
-
|
|
621
|
+
// Tenths of a percent are kept; anything finer is quantised so the stored
|
|
622
|
+
// value is the one the screen shows.
|
|
623
|
+
const v = Math.round(pct * 10) / 1000;
|
|
592
624
|
this.config.switchThreshold = v;
|
|
593
625
|
this.am.switchThreshold = v; // apply to the running rotation immediately
|
|
594
626
|
try { await this.saveConfig(this.config); }
|
|
@@ -848,6 +880,17 @@ export class TUI {
|
|
|
848
880
|
if (this.running) this.render();
|
|
849
881
|
}
|
|
850
882
|
|
|
883
|
+
async _toggleSessionTitles() {
|
|
884
|
+
const next = { ...this.sessionTitles.settings(), enabled: !this.sessionTitles.enabled };
|
|
885
|
+
this.sessionTitles.configure(next);
|
|
886
|
+
// The shared config object is what a save writes and a reload re-applies.
|
|
887
|
+
this.config.sessionTitles = { ...this.config.sessionTitles, ...next };
|
|
888
|
+
try { await this.saveConfig(this.config); }
|
|
889
|
+
catch (e) { this._addLog(`Failed to save: ${e.message}`); }
|
|
890
|
+
this._addLog(`Session titles: ${next.enabled ? 'on' : 'off'}`);
|
|
891
|
+
if (this.running) this.render();
|
|
892
|
+
}
|
|
893
|
+
|
|
851
894
|
async _cycleEventLogging(dir = 1) {
|
|
852
895
|
// Claude Code telemetry display/handling: show → hide → block → show.
|
|
853
896
|
const order = ['show', 'hide', 'block'];
|
|
@@ -1107,7 +1150,7 @@ export class TUI {
|
|
|
1107
1150
|
const m = r.model ? dim(` (${r.model})`) : ''; // filled in as soon as the model is peeked from the stream
|
|
1108
1151
|
const pin = r.pinned ? dim(' [pin]') : '';
|
|
1109
1152
|
const a = r.account ? ` → ${r.account}${pin}` : '';
|
|
1110
|
-
lines.push(` ${sp} ${gray(r.t)} ${
|
|
1153
|
+
lines.push(` ${sp} ${gray(r.t)} ${this._sessionTag(r.sessionId)} ${r.method} ${r.path}${m}${a} ${dim(`(${el}s...)`)}`);
|
|
1111
1154
|
}
|
|
1112
1155
|
|
|
1113
1156
|
// Completed log
|
|
@@ -1230,6 +1273,21 @@ export class TUI {
|
|
|
1230
1273
|
if (q.unified7dSonnet != null && q.unified7dSonnet >= th) blocked.push('Sonnet');
|
|
1231
1274
|
if (q.unified7dFable != null && q.unified7dFable >= th) blocked.push('Fable');
|
|
1232
1275
|
if (blocked.length) line += ` ${red('⊘ ' + blocked.join(' '))}`;
|
|
1276
|
+
|
|
1277
|
+
// Burn-rate tags, most urgent first: TTL for a window that runs out before
|
|
1278
|
+
// it resets, an unspent share for one that expires with quota left. A
|
|
1279
|
+
// deficit will stop this account, so it is colored; a surplus is a note and
|
|
1280
|
+
// stays gray. Optional on the manager so a stand-in without projection
|
|
1281
|
+
// support still renders.
|
|
1282
|
+
const buckets = this.am.projectionsFor?.(idx) || {};
|
|
1283
|
+
const ranked = this.am.projection?.rank(Object.values(buckets)) || [];
|
|
1284
|
+
if (ranked.length) {
|
|
1285
|
+
const tags = ranked.map(p => {
|
|
1286
|
+
const text = formatProjection(p);
|
|
1287
|
+
return p.kind === 'deficit' ? yellow(text) : gray(text);
|
|
1288
|
+
});
|
|
1289
|
+
line += ` ${tags.join(gray(' · '))}`;
|
|
1290
|
+
}
|
|
1233
1291
|
return line;
|
|
1234
1292
|
}
|
|
1235
1293
|
|
|
@@ -1267,6 +1325,7 @@ export class TUI {
|
|
|
1267
1325
|
// ── Activity log
|
|
1268
1326
|
lines.push(bold(' Activity log') + dim(' — what to do with Claude Code\'s telemetry'));
|
|
1269
1327
|
lines.push(row(byId('eventlog')));
|
|
1328
|
+
if (byId('sessionTitles')) lines.push(row(byId('sessionTitles')));
|
|
1270
1329
|
lines.push('');
|
|
1271
1330
|
// ── Routing
|
|
1272
1331
|
lines.push(bold(' Routing') + dim(' — pin model families to specific accounts, or block them outright'));
|