@rikcodes/teamclaude 1.1.13-rik.1 → 1.1.13-rik.2
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 +37 -15
- package/package.json +7 -7
- package/src/account-manager.js +53 -1
- package/src/config.js +2 -0
- package/src/index.js +12 -2
- package/src/quota-projection.js +166 -0
- package/src/session-titles.js +184 -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,6 +1,6 @@
|
|
|
1
1
|
# TeamClaude
|
|
2
2
|
|
|
3
|
-
> **Fork notice (rikbrown).** This fork adds
|
|
3
|
+
> **Fork notice (rikbrown).** This fork adds three features and two reload fixes on top of
|
|
4
4
|
> [KarpelesLab/teamclaude](https://github.com/KarpelesLab/teamclaude):
|
|
5
5
|
>
|
|
6
6
|
> - **[Soonest-weekly rotation](docs/routing.md#soonest-weekly-rotation)** (`soonestWeekly`, opt-in): rank
|
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
> current account when another resets more than `poolHours` sooner, and balance `distributeSessions` within
|
|
9
9
|
> that pool instead of across all accounts. Spends the quota closest to refreshing first, so a window no
|
|
10
10
|
> longer rolls over with quota unspent.
|
|
11
|
+
> - **[Burn-rate projection](docs/quota.md#burn-rate-projection)** (`projection`, on by default): sample each
|
|
12
|
+
> bucket's consumption over a rolling window and tag every account row with whichever window binds
|
|
13
|
+
> first — `Ses TTL 38m` when it runs out before it resets, `Wk 22% unspent` when the reset arrives
|
|
14
|
+
> first and that much expires. A readout only: no selection code reads it.
|
|
15
|
+
> - **[Session titles](docs/usage.md#session-titles-in-the-activity-log)** (`sessionTitles`, on by default):
|
|
16
|
+
> name each activity row after the Claude Code session that sent the request, reading the title
|
|
17
|
+
> `/rename` writes and the one Claude Code generates. A session with neither keeps its short id.
|
|
11
18
|
> - `soonestWeekly` and `distributeSessions` changes now apply on config reload; upstream applies
|
|
12
19
|
> `distributeSessions` only at startup.
|
|
13
20
|
>
|
|
@@ -18,11 +25,14 @@
|
|
|
18
25
|
> npm install -g @rikcodes/teamclaude
|
|
19
26
|
> ```
|
|
20
27
|
>
|
|
28
|
+
> Already have upstream installed globally? Run `npm uninstall -g @karpeleslab/teamclaude` first —
|
|
29
|
+
> both packages provide the `teamclaude` command.
|
|
30
|
+
>
|
|
21
31
|
> Branch: `rik/soonest-weekly-pool`. Everything else matches upstream.
|
|
22
32
|
|
|
23
|
-
[](https://github.com/rikbrown/teamclaude/actions/workflows/ci.yml)
|
|
34
|
+
[](https://www.npmjs.com/package/@rikcodes/teamclaude)
|
|
35
|
+
[](https://nodejs.org)
|
|
26
36
|
[](LICENSE)
|
|
27
37
|
|
|
28
38
|
Multi-account Claude proxy with automatic quota-based rotation for [Claude Code](https://claude.ai/claude-code).
|
|
@@ -48,6 +58,8 @@ Already logged into Claude Code? `teamclaude import` takes its credentials inste
|
|
|
48
58
|
## What it does
|
|
49
59
|
|
|
50
60
|
- 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.
|
|
61
|
+
- 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).
|
|
62
|
+
- 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
63
|
- 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
64
|
- 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
65
|
- 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.
|
|
@@ -99,23 +111,33 @@ Step-by-step lifecycle: [docs/routing.md](docs/routing.md#request-lifecycle).
|
|
|
99
111
|
| [Proxy modes](docs/proxy-modes.md) | MITM forward proxy, sx.org residential egress |
|
|
100
112
|
| [Compliance](docs/compliance.md) | Terms of service notes |
|
|
101
113
|
|
|
114
|
+
## Releasing this fork
|
|
115
|
+
|
|
116
|
+
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.
|
|
117
|
+
|
|
118
|
+
1. Rebase onto the upstream release you want as the base, if any.
|
|
119
|
+
2. Bump `version` in `package.json` and commit.
|
|
120
|
+
3. Push to `rik/soonest-weekly-pool` — the Publish workflow runs the tests, publishes to npm, and cuts a GitHub release.
|
|
121
|
+
|
|
122
|
+
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:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
pnpm publish --publish-branch rik/soonest-weekly-pool --tag latest --otp=<code>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
A prerelease version always needs an explicit `--tag`, and `latest` is the tag the self-updater reads.
|
|
129
|
+
|
|
102
130
|
## Security
|
|
103
131
|
|
|
104
|
-
|
|
132
|
+
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
133
|
|
|
106
|
-
|
|
134
|
+
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
135
|
|
|
108
|
-
|
|
136
|
+
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
137
|
|
|
110
|
-
##
|
|
138
|
+
## Compliance
|
|
111
139
|
|
|
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>
|
|
140
|
+
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
141
|
|
|
120
142
|
## License
|
|
121
143
|
|
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.2",
|
|
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';
|
|
@@ -108,7 +109,7 @@ function sampleModelFor(route) {
|
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
export class AccountManager {
|
|
111
|
-
constructor(accounts, switchThreshold = 0.98, { refreshFn = refreshAccessToken, throttleProbeFloorMs, forcedRefreshFloorMs = FORCED_REFRESH_FLOOR_MS, routes, ramp, distributeSessions = false, soonestWeekly, sessionTracker } = {}) {
|
|
112
|
+
constructor(accounts, switchThreshold = 0.98, { refreshFn = refreshAccessToken, throttleProbeFloorMs, forcedRefreshFloorMs = FORCED_REFRESH_FLOOR_MS, routes, ramp, distributeSessions = false, soonestWeekly, projection, sessionTracker } = {}) {
|
|
112
113
|
// How long a just-minted token is trusted against a forced refresh.
|
|
113
114
|
this._forcedRefreshFloorMs = forcedRefreshFloorMs;
|
|
114
115
|
// Injectable for tests (mirrors Prober's probeFn); defaults to the real
|
|
@@ -131,6 +132,7 @@ export class AccountManager {
|
|
|
131
132
|
this.switchThreshold = switchThreshold;
|
|
132
133
|
this.setRoutes(routes);
|
|
133
134
|
this.setSoonestWeekly(soonestWeekly);
|
|
135
|
+
this.setProjection(projection);
|
|
134
136
|
// Storm control: when rotation switches to a fresh account, a burst of
|
|
135
137
|
// in-flight requests (e.g. dozens of agents failing over together) would all
|
|
136
138
|
// hit it at once and instantly throttle it — cascading down the fleet
|
|
@@ -693,6 +695,47 @@ export class AccountManager {
|
|
|
693
695
|
};
|
|
694
696
|
}
|
|
695
697
|
|
|
698
|
+
/**
|
|
699
|
+
* Burn-rate projection settings, applied live on config reload. Enabled by
|
|
700
|
+
* default: the projection is a readout and no selection code consults it, so
|
|
701
|
+
* turning it on cannot change which account serves a request.
|
|
702
|
+
*/
|
|
703
|
+
setProjection(cfg) {
|
|
704
|
+
const c = cfg || {};
|
|
705
|
+
this.projection = new QuotaProjection({
|
|
706
|
+
enabled: c.enabled !== false,
|
|
707
|
+
windowMinutes: c.windowMinutes,
|
|
708
|
+
wasteFloor: c.wasteFloor ?? 0.1,
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** Sample every reported bucket. Both quota write paths call this: response
|
|
713
|
+
* headers (updateQuota) and the usage probe (applyUsageData). */
|
|
714
|
+
_recordQuotaSamples(account, now = Date.now()) {
|
|
715
|
+
const q = account.quota;
|
|
716
|
+
for (const bucket of PROJECTED_BUCKETS) {
|
|
717
|
+
if (q[bucket] !== undefined) this.projection.record(account.index, bucket, q[bucket], now);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** Every bucket's projection for one account, keyed by bucket name. Buckets
|
|
722
|
+
* without a usable rate are absent rather than null. */
|
|
723
|
+
projectionsFor(accountIndex, now = Date.now()) {
|
|
724
|
+
const account = this.accounts[accountIndex];
|
|
725
|
+
if (!account) return {};
|
|
726
|
+
const q = account.quota;
|
|
727
|
+
const out = {};
|
|
728
|
+
for (const bucket of PROJECTED_BUCKETS) {
|
|
729
|
+
const projected = this.projection.project(accountIndex, bucket, {
|
|
730
|
+
utilization: q[bucket],
|
|
731
|
+
resetAt: q[`${bucket}Reset`],
|
|
732
|
+
now,
|
|
733
|
+
});
|
|
734
|
+
if (projected) out[bucket] = projected;
|
|
735
|
+
}
|
|
736
|
+
return out;
|
|
737
|
+
}
|
|
738
|
+
|
|
696
739
|
/**
|
|
697
740
|
* Normalize and store the configurable routing table. A route pins a set of
|
|
698
741
|
* model globs to an exclusive set of accounts (and may override the governing
|
|
@@ -1177,6 +1220,8 @@ export class AccountManager {
|
|
|
1177
1220
|
if (tokensReset) account.quota.resetsAt = tokensReset;
|
|
1178
1221
|
else if (requestsReset) account.quota.resetsAt = requestsReset;
|
|
1179
1222
|
|
|
1223
|
+
this._recordQuotaSamples(account);
|
|
1224
|
+
|
|
1180
1225
|
account.usage.totalRequests++;
|
|
1181
1226
|
account.usage.lastUsed = new Date().toISOString();
|
|
1182
1227
|
|
|
@@ -1244,6 +1289,8 @@ export class AccountManager {
|
|
|
1244
1289
|
if (usage.sevenDayFable.resetAt != null) q.unified7dFableReset = usage.sevenDayFable.resetAt;
|
|
1245
1290
|
}
|
|
1246
1291
|
|
|
1292
|
+
this._recordQuotaSamples(account);
|
|
1293
|
+
|
|
1247
1294
|
// If we just learned this account's weekly window while probing, re-evaluate
|
|
1248
1295
|
// selection (same path as learning it from a live response).
|
|
1249
1296
|
if (account.probing && q.unified7dReset != null) {
|
|
@@ -1437,6 +1484,7 @@ export class AccountManager {
|
|
|
1437
1484
|
routes: this.getRoutes(),
|
|
1438
1485
|
sessions: { ...sessions, distribute: this.distributeSessions },
|
|
1439
1486
|
soonestWeekly: { ...this.soonestWeekly },
|
|
1487
|
+
projection: this.projection.settings(),
|
|
1440
1488
|
accounts: this.accounts.map(a => ({
|
|
1441
1489
|
name: a.name,
|
|
1442
1490
|
type: a.type,
|
|
@@ -1447,6 +1495,10 @@ export class AccountManager {
|
|
|
1447
1495
|
sessions: sessions.perAccount[a.index] || 0,
|
|
1448
1496
|
quota: { ...a.quota },
|
|
1449
1497
|
usage: { ...a.usage },
|
|
1498
|
+
projection: (() => {
|
|
1499
|
+
const buckets = this.projectionsFor(a.index);
|
|
1500
|
+
return { headline: this.projection.headline(Object.values(buckets)), buckets };
|
|
1501
|
+
})(),
|
|
1450
1502
|
rateLimitedUntil: a.rateLimitedUntil
|
|
1451
1503
|
? new Date(a.rateLimitedUntil).toISOString()
|
|
1452
1504
|
: null,
|
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
|
@@ -16,6 +16,7 @@ import { ensureCerts } from './mitm.js';
|
|
|
16
16
|
import { Prober } from './prober.js';
|
|
17
17
|
import { Warmer } from './warmer.js';
|
|
18
18
|
import { TUI } from './tui.js';
|
|
19
|
+
import { SessionTitles } from './session-titles.js';
|
|
19
20
|
import { RemoteControl, createAttachSession } from './tui-remote.js';
|
|
20
21
|
import { SxManager } from './sx.js';
|
|
21
22
|
import { autoUpdate, checkForUpdate, currentVersion, runUpdate, installKind, PKG_NAME } from './updater.js';
|
|
@@ -178,7 +179,12 @@ async function serverCommand() {
|
|
|
178
179
|
}
|
|
179
180
|
|
|
180
181
|
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 });
|
|
182
|
+
const accountManager = new AccountManager(accounts, threshold, { routes: config.routes, ramp: config.stormRamp, distributeSessions: config.distributeSessions, soonestWeekly: config.soonestWeekly, projection: config.projection });
|
|
183
|
+
// Names the activity log's session column from Claude Code's own on-disk
|
|
184
|
+
// session titles. Built whether or not the TUI runs, so a reload has one
|
|
185
|
+
// object to reconfigure.
|
|
186
|
+
const sessionTitles = new SessionTitles(config.sessionTitles);
|
|
187
|
+
|
|
182
188
|
|
|
183
189
|
// Restore quota observed in a previous run so a restart doesn't lose rotation
|
|
184
190
|
// state (passive — we never call the API to re-learn it). Stale windows are
|
|
@@ -269,6 +275,10 @@ async function serverCommand() {
|
|
|
269
275
|
accountManager.setDistributeSessions(config.distributeSessions);
|
|
270
276
|
config.soonestWeekly = diskConfig.soonestWeekly;
|
|
271
277
|
accountManager.setSoonestWeekly(config.soonestWeekly);
|
|
278
|
+
config.projection = diskConfig.projection;
|
|
279
|
+
accountManager.setProjection(config.projection);
|
|
280
|
+
config.sessionTitles = diskConfig.sessionTitles;
|
|
281
|
+
sessionTitles.configure(config.sessionTitles);
|
|
272
282
|
// Apply an sx.org key/mode change made on disk (e.g. via POST /teamclaude/reload).
|
|
273
283
|
const diskSxKey = diskConfig.sx?.apiKey || null;
|
|
274
284
|
const diskSxMode = diskConfig.sx?.mode || 'always';
|
|
@@ -299,7 +309,7 @@ async function serverCommand() {
|
|
|
299
309
|
|
|
300
310
|
if (useTUI) {
|
|
301
311
|
tui = new TUI({
|
|
302
|
-
accountManager, config, sx, activityLogPath,
|
|
312
|
+
accountManager, config, sx, activityLogPath, sessionTitles,
|
|
303
313
|
saveConfig: () => atomicConfigUpdate(async diskConfig => {
|
|
304
314
|
// Write in-memory accounts as the authoritative state, preserving
|
|
305
315
|
// extra disk-only fields (e.g. importFrom) where the account still exists.
|
|
@@ -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
|
+
}
|
|
@@ -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/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'));
|