@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # TeamClaude
2
2
 
3
- > **Fork notice (rikbrown).** This fork adds one feature and two reload fixes on top of
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
- [![CI](https://github.com/KarpelesLab/teamclaude/actions/workflows/ci.yml/badge.svg)](https://github.com/KarpelesLab/teamclaude/actions/workflows/ci.yml)
24
- [![npm version](https://img.shields.io/npm/v/@karpeleslab/teamclaude.svg)](https://www.npmjs.com/package/@karpeleslab/teamclaude)
25
- [![node](https://img.shields.io/node/v/@karpeleslab/teamclaude.svg)](https://nodejs.org)
33
+ [![CI](https://github.com/rikbrown/teamclaude/actions/workflows/ci.yml/badge.svg?branch=rik/soonest-weekly-pool)](https://github.com/rikbrown/teamclaude/actions/workflows/ci.yml)
34
+ [![npm version](https://img.shields.io/npm/v/@rikcodes/teamclaude.svg)](https://www.npmjs.com/package/@rikcodes/teamclaude)
35
+ [![node](https://img.shields.io/node/v/@rikcodes/teamclaude.svg)](https://nodejs.org)
26
36
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
- The only canonical sources for TeamClaude are this repository (https://github.com/KarpelesLab/teamclaude) and the [`@karpeleslab/teamclaude`](https://www.npmjs.com/package/@karpeleslab/teamclaude) npm package. TeamClaude is **never** distributed as a downloadable binary archive, so be wary of soft-forks that bundle a `.zip` and tell you to extract and run it. See [SECURITY.md](SECURITY.md) for details and how to report issues.
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
- ## Compliance
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
- 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.
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
- ## Star history
138
+ ## Compliance
111
139
 
112
- <a href="https://www.star-history.com/?repos=KarpelesLab%2Fteamclaude&type=date&legend=top-left">
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.1",
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
+ }
@@ -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
+ }
@@ -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
- return `${Math.round(Number(value) * 100)}%`;
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 short id (blank-padded when there's no session, e.g. a
66
- // telemetry request), so the activity column stays aligned.
67
- const sessionTag = sid =>
68
- sid ? fg(sessionColorCode(sid), sid.slice(0, SESSION_ID_LEN)) : ' '.repeat(SESSION_ID_LEN);
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(`${sessionTag(sid)} ${info.method} ${info.path}${model} → ${acct}${pin} (${info.status}, ${dur}s)`);
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
- const cur = Math.round((this.am.switchThreshold ?? this.config.switchThreshold ?? 0.98) * 100);
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
- const v = Math.round(pct) / 100;
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)} ${sessionTag(r.sessionId)} ${r.method} ${r.path}${m}${a} ${dim(`(${el}s...)`)}`);
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'));