maxpool 1.7.0 → 1.8.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Multi-account Claude Code proxy with adaptive, rate-aware load balancing across Claude accounts",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -44,4 +44,4 @@
44
44
  "eslint": "^9.39.5",
45
45
  "git-cliff": "2.13.1"
46
46
  }
47
- }
47
+ }
@@ -1,4 +1,5 @@
1
1
  import { refreshAccessToken, isTokenExpiringSoon, modelFamily, tokenFingerprint } from './oauth.js';
2
+ import { CapacityLedger } from './capacity-ledger.js';
2
3
  import { peakWindowState, DEFAULT_PEAK_CAP } from './peak-window.js';
3
4
 
4
5
  // Bounded re-poll hold for an account blocked ONLY by a transient, self-clearing
@@ -332,6 +333,9 @@ export class AccountManager {
332
333
  this.preferredAccountName = null;
333
334
  this.sessionBindings = new Map();
334
335
  this._peakCache = null; // per-UTC-minute peak-window memo (see _peakStateFor)
336
+ // CAPACITY LEDGER (2026-08-22): observed tokens per completed window cycle, so
337
+ // "true capacity" is comparable across providers. Restored from state on boot.
338
+ this.capacity = new CapacityLedger();
335
339
  this.sessionPolicies = new Map();
336
340
  this.upstreamThrottle = {
337
341
  until: null,
@@ -1058,9 +1062,14 @@ export class AccountManager {
1058
1062
  let changed = false;
1059
1063
  let session = false;
1060
1064
 
1061
- // Clear expired unified quotas
1065
+ // Clear expired unified quotas. The capacity cycle closes HERE, before the stamp
1066
+ // is nulled: this is the authoritative rollover moment, and it fires wherever the
1067
+ // rollover is noticed (TUI render tick, every routed request) — not only on a
1068
+ // prober sweep that happens to land inside the sub-second window before the stamp
1069
+ // disappears (without this, OAuth cycles essentially never close: red-team 2026-08-22).
1062
1070
  if (q.unified5h != null && q.unified5hReset && now >= q.unified5hReset) {
1063
1071
  console.log(`[Maxpool] Account "${account.name}" session quota reset`);
1072
+ this.capacity?.closeCycle?.(account.name, 'ses', q.unified5hReset, { resetAt: q.unified5hReset });
1064
1073
  q.unified5h = null;
1065
1074
  q.unified5hReset = null;
1066
1075
  changed = true;
@@ -1068,6 +1077,7 @@ export class AccountManager {
1068
1077
  }
1069
1078
  if (q.unified7d != null && q.unified7dReset && now >= q.unified7dReset) {
1070
1079
  console.log(`[Maxpool] Account "${account.name}" weekly quota reset`);
1080
+ this.capacity?.closeCycle?.(account.name, 'wk', q.unified7dReset, { resetAt: q.unified7dReset });
1071
1081
  q.unified7d = null;
1072
1082
  q.unified7dReset = null;
1073
1083
  q.unifiedStatus = null;
@@ -1914,6 +1924,10 @@ export class AccountManager {
1914
1924
  setAccountEnabled(index, enabled) {
1915
1925
  const account = this.accounts[index];
1916
1926
  if (!account) return false;
1927
+ // CAPACITY LEDGER: a cycle that spent part of its life DISABLED did not get the
1928
+ // chance to deliver its true capacity, so it is not a capacity observation —
1929
+ // flag it partial (shown, excluded from the averages).
1930
+ if (!enabled && account.enabled) this.capacity.markPartial(account.name, { disabled: true });
1917
1931
  account.enabled = Boolean(enabled);
1918
1932
  if (!enabled && account.name === this.preferredAccountName) {
1919
1933
  this.setRoutingMode('automatic');
@@ -2668,6 +2682,10 @@ export class AccountManager {
2668
2682
  const account = this.accounts[accountIndex];
2669
2683
  if (!account || !usage) return;
2670
2684
  const q = account.quota;
2685
+ // CAPACITY LEDGER: prev stamps, so a probe observing the window ADVANCE closes
2686
+ // the old cycle (the OAuth twin of the applyProviderUsage hook).
2687
+ const prevSesReset = q.unified5hReset;
2688
+ const prevWkReset = q.unified7dReset;
2671
2689
 
2672
2690
  if (usage.fiveHour) {
2673
2691
  if (usage.fiveHour.utilization != null) q.unified5h = clamp01(usage.fiveHour.utilization);
@@ -2677,6 +2695,12 @@ export class AccountManager {
2677
2695
  if (usage.sevenDay.utilization != null) q.unified7d = clamp01(usage.sevenDay.utilization);
2678
2696
  if (usage.sevenDay.resetAt != null) q.unified7dReset = usage.sevenDay.resetAt;
2679
2697
  }
2698
+ // Stamp-advance close (same guard as the provider path): a FRESHER stamp means
2699
+ // the old window rolled over — close its cycle at the old boundary.
2700
+ // noteCapacityWindowAdvance already no-ops on a missing/unchanged/older stamp, so
2701
+ // there is nothing to gate here — pass the raw (possibly undefined) value through.
2702
+ this.noteCapacityWindowAdvance(account.name, 'ses', prevSesReset, usage.fiveHour?.resetAt);
2703
+ this.noteCapacityWindowAdvance(account.name, 'wk', prevWkReset, usage.sevenDay?.resetAt);
2680
2704
  // Only a SUCCESSFUL probe carrying the flag speaks to this. A header-driven update
2681
2705
  // can't see the limits[] array, and a FAILED read knows nothing about the account's
2682
2706
  // caps — either one claiming "uncapped" would mislabel a capped account as having no
@@ -2787,6 +2811,11 @@ export class AccountManager {
2787
2811
  const account = this.accounts[accountIndex];
2788
2812
  if (!account || !usage) return;
2789
2813
  const q = account.quota;
2814
+ // CAPACITY LEDGER: snapshot the previous reset stamps so a probe observing the
2815
+ // window ADVANCE (new stamp) closes the capacity cycle at the old boundary —
2816
+ // covers windows whose old stamp was never learned (clock-close can't fire).
2817
+ const prevSesReset = q.providerSesReset;
2818
+ const prevWkReset = q.providerWkReset;
2790
2819
  if (usage.error) {
2791
2820
  // Distinguish "no pollable quota" (Kimi) from a transient probe failure.
2792
2821
  // Never clear existing values on a transient error — let them age into the
@@ -2814,6 +2843,12 @@ export class AccountManager {
2814
2843
  q.weeklyAbsent = true;
2815
2844
  }
2816
2845
  q.lastProbeOkAt = Date.now();
2846
+ // Stamp-advance close: a FRESHER reset stamp means the old window rolled over —
2847
+ // the tokens accrued since the last close belong to the cycle that just ended.
2848
+ // noteCapacityWindowAdvance already no-ops on a missing/unchanged/older stamp
2849
+ // (same-window re-report, clock-skew backward re-report) — pass values through.
2850
+ this.noteCapacityWindowAdvance(account.name, 'ses', prevSesReset, usage.ses?.resetAt);
2851
+ this.noteCapacityWindowAdvance(account.name, 'wk', prevWkReset, usage.wk?.resetAt);
2817
2852
  }
2818
2853
 
2819
2854
  /**
@@ -2949,6 +2984,76 @@ export class AccountManager {
2949
2984
  }
2950
2985
  }
2951
2986
 
2987
+ /** Restore the ledger from persisted state. A BOOT GAP (the open cycle's last
2988
+ * accrual is far behind now) means maxpool was down for part of that cycle, so the
2989
+ * cycle is no longer a truthful capacity observation — flag it partial (B2/SC6).
2990
+ * It still displays; it is excluded from averages. */
2991
+ restoreCapacityState(payload, now = Date.now(), downtimeMs = null) {
2992
+ this.capacity = CapacityLedger.fromSerialized(payload);
2993
+ // Partial is keyed on MAXPOOL'S OWN downtime, NEVER on the account's last request:
2994
+ // an account parked >10min mid-cycle is normal fleet rotation, and keying on its
2995
+ // last request discarded valid observations on every reload (red-team F3). The
2996
+ // caller passes measured downtime (state mtime at save → boot now); when it
2997
+ // cannot (a seamless reload handoff — the old worker was provably serving
2998
+ // throughout), null skips the check entirely.
2999
+ // 60s absorbs a restart's own turnaround; anything longer is real downtime, and
3000
+ // it disqualifies EVERY open cycle (maxpool was not serving, so no account could
3001
+ // deliver its true capacity) — the downtime is a property of the process, not of
3002
+ // any one account's traffic.
3003
+ if (!(downtimeMs > 60_000)) return;
3004
+ const spannedDays = new Set();
3005
+ for (let t = now - downtimeMs; t <= now; t += 3600_000) spannedDays.add(new Date(t).toISOString().slice(0, 10));
3006
+ spannedDays.add(new Date(now).toISOString().slice(0, 10));
3007
+ for (const name of this.capacity.accounts()) {
3008
+ if (this.capacity.openCycle(name, 'ses') || this.capacity.openCycle(name, 'wk')) {
3009
+ this.capacity.markPartial(name);
3010
+ }
3011
+ for (const day of spannedDays) this.capacity.markDayPartial(name, day);
3012
+ }
3013
+ }
3014
+
3015
+ /** Accrue ONE request's tokens into the capacity ledger (per-request values; the
3016
+ * server seam has already applied max-semantics for streamed output). */
3017
+ accrueCapacity(accountIndex, { input = 0, output = 0 } = {}) {
3018
+ const account = this.accounts[accountIndex];
3019
+ if (!account) return;
3020
+ this.capacity.accrue(account.name, { input, output });
3021
+ }
3022
+
3023
+ /** Close any window cycle whose reset time has passed — CLOCK-AUTHORITATIVE, so a
3024
+ * stale or dead probe can never leave a cycle open and mis-attribute the next
3025
+ * window's tokens to it (pre-mortem M5; worst case is the no-weekly account whose
3026
+ * probe latches refreshDead). Safe to call on every render tick and prober tick. */
3027
+ closeExpiredCapacityCycles(now = Date.now()) {
3028
+ for (const a of this.accounts) {
3029
+ const q = a.quota || {};
3030
+ const pairs = a.type === 'provider'
3031
+ ? [['ses', 'providerSesReset'], ['wk', 'providerWkReset']]
3032
+ : [['ses', 'unified5hReset'], ['wk', 'unified7dReset']];
3033
+ for (const [win, stampKey] of pairs) {
3034
+ const resetAt = q[stampKey];
3035
+ // Close ONLY. This path deliberately does NOT null the stamp: the rollover
3036
+ // stays single-shot because closeCycle FOLDS a same-boundary repeat into the
3037
+ // already-closed cycle (one boundary, one cycle), and the very next
3038
+ // refreshExpiredQuotas / request-path _clearExpiredQuotas nulls the stamp with
3039
+ // its full original side effects — the reset log, the `session` signal that
3040
+ // drives _switchOnSessionReset, and the weekly `unifiedStatus` clear. Nulling
3041
+ // here as well (round-2) made a prober-first notice silently swallow all of
3042
+ // those whenever the sweep won the race (red-team round 3, RT3-1).
3043
+ if (resetAt && now >= resetAt) {
3044
+ this.capacity.closeCycle(a.name, win, resetAt, { resetAt });
3045
+ }
3046
+ }
3047
+ }
3048
+ }
3049
+
3050
+ /** Close a cycle because a probe observed the window ADVANCE (a new reset stamp) —
3051
+ * covers the case where the old stamp was never learned. */
3052
+ noteCapacityWindowAdvance(accountName, window, prevResetAt, nextResetAt) {
3053
+ if (!prevResetAt || !nextResetAt || nextResetAt <= prevResetAt) return;
3054
+ this.capacity.closeCycle(accountName, window, prevResetAt, { resetAt: prevResetAt });
3055
+ }
3056
+
2952
3057
  /**
2953
3058
  * Update cumulative token usage from response body data.
2954
3059
  */
@@ -0,0 +1,273 @@
1
+ /**
2
+ * True-capacity ledger — observed tokens per completed window cycle.
3
+ *
4
+ * The measurement model (user spec 2026-08-22): when a 5h or weekly window completes,
5
+ * the tokens delivered during that cycle ARE the account's measured capacity for the
6
+ * window. History per account per window: last / prev / prev-1 / avg3 / avg10 /
7
+ * all-time — in tokens, so a GLM row and a Claude row compare apples to apples.
8
+ *
9
+ * The no-weekly account (legacy z.ai TOKENS_LIMIT, weeklyAbsent) has no weekly TANK:
10
+ * it gets full 5h cycle history plus a rolling-7d THROUGHPUT from UTC day buckets —
11
+ * a volume, never a limit, rendered distinctly.
12
+ *
13
+ * PURE: no I/O, injected clock. Persistence + accrual seams live in account-manager /
14
+ * index; this module owns the data model and its invariants.
15
+ * Pre-mortem (2026-08-22) blockers B1/B2 + majors M3-M7 are the load-bearing comments.
16
+ */
17
+
18
+ const SCHEMA_VERSION = 1;
19
+ const MAX_CYCLES_PER_WINDOW = 50;
20
+ const MAX_DAY_BUCKETS = 10;
21
+
22
+ export class CapacityLedger {
23
+ constructor({ now = () => Date.now() } = {}) {
24
+ this._now = now;
25
+ // accountName → { ses: {open?, closed: []}, wk: {open?, closed: []}, days: {utcDay: {tokens, partial}} }
26
+ this._accounts = new Map();
27
+ }
28
+
29
+ /** Restore from a serialized payload (state.json). Tolerant: unknown schemaVersion →
30
+ * start empty and KEEP the file (the caller preserves it); a corrupt shape → empty. */
31
+ static fromSerialized(payload, { now = () => Date.now() } = {}) {
32
+ const l = new CapacityLedger({ now });
33
+ if (!payload || payload.schemaVersion !== SCHEMA_VERSION) return l;
34
+ try {
35
+ for (const [name, rec] of Object.entries(payload.accounts || {})) {
36
+ const a = { ses: { open: null, closed: [] }, wk: { open: null, closed: [] }, days: {} };
37
+ for (const w of ['ses', 'wk']) {
38
+ if (rec[w]?.open) a[w].open = { ...rec[w].open };
39
+ if (Array.isArray(rec[w]?.closed)) a[w].closed = rec[w].closed.slice(-MAX_CYCLES_PER_WINDOW);
40
+ }
41
+ if (rec.days && typeof rec.days === 'object') {
42
+ for (const [d, v] of Object.entries(rec.days)) a.days[d] = { ...v };
43
+ }
44
+ l._accounts.set(name, a);
45
+ }
46
+ } catch { return new CapacityLedger({ now }); }
47
+ return l;
48
+ }
49
+
50
+ serialize() {
51
+ const accounts = {};
52
+ for (const [name, rec] of this._accounts) {
53
+ accounts[name] = {
54
+ ses: { open: rec.ses.open ? { ...rec.ses.open } : null, closed: rec.ses.closed.slice() },
55
+ wk: { open: rec.wk.open ? { ...rec.wk.open } : null, closed: rec.wk.closed.slice() },
56
+ days: Object.fromEntries(Object.entries(rec.days).map(([d, v]) => [d, { ...v }])),
57
+ };
58
+ }
59
+ return { schemaVersion: SCHEMA_VERSION, accounts };
60
+ }
61
+
62
+ _rec(name) {
63
+ let r = this._accounts.get(name);
64
+ if (!r) {
65
+ r = { ses: { open: null, closed: [] }, wk: { open: null, closed: [] }, days: {} };
66
+ this._accounts.set(name, r);
67
+ }
68
+ return r;
69
+ }
70
+
71
+ // ── Accrual ──────────────────────────────────────────────────────────────────
72
+
73
+ /** Accrue one served request's tokens. `tokens` = {input, output} for THIS request,
74
+ * already per-request-max semantics for output (the caller derives them; see
75
+ * M3 in the pre-mortem — Anthropic interim deltas are CUMULATIVE, so the SSE seam
76
+ * passes the running max, and this adds it exactly once per request).
77
+ * count_tokens requests are the CALLER's job to skip (M4) — they never reach here. */
78
+ accrue(name, { input, output }, at = this._now()) {
79
+ if (!(input > 0) && !(output > 0)) return;
80
+ const rec = this._rec(name);
81
+ for (const w of ['ses', 'wk']) {
82
+ // Open the window lazily if nothing is open (a mid-cycle boot or a window whose
83
+ // reset stamp was never learned). startedAt is the accrual time then — the cycle
84
+ // will be flagged partial by the caller if the boot gap warrants it (B1/B2).
85
+ if (!rec[w].open) {
86
+ rec[w].open = { startedAt: at, tokensSoFar: 0, lastAccrualAt: at, complete: true, disabledDuring: false };
87
+ }
88
+ rec[w].open.tokensSoFar += input + output;
89
+ rec[w].open.lastAccrualAt = at;
90
+ }
91
+ const day = new Date(at).toISOString().slice(0, 10);
92
+ rec.days[day] = rec.days[day] || { tokens: 0, partial: false };
93
+ rec.days[day].tokens += input + output;
94
+ this._evictDays(rec);
95
+ }
96
+
97
+ _evictDays(rec) {
98
+ const keys = Object.keys(rec.days).sort();
99
+ while (keys.length > MAX_DAY_BUCKETS) {
100
+ delete rec.days[keys.shift()];
101
+ }
102
+ }
103
+
104
+ // ── Cycle lifecycle ─────────────────────────────────────────────────────────
105
+
106
+ /** Mark the open cycles as partial (maxpool was down / an account was disabled).
107
+ * Called by the boot path when a gap is detected (B2), and by the disable hook. */
108
+ markPartial(name, { disabled = false } = {}) {
109
+ const rec = this._accounts.get(name);
110
+ if (!rec) return;
111
+ for (const w of ['ses', 'wk']) {
112
+ if (!rec[w].open) continue;
113
+ // TWO INDEPENDENT axes, deliberately not collapsed: `complete:false` = maxpool
114
+ // was down for part of the cycle; `disabledDuring` = the operator took the
115
+ // account out of rotation. Setting both for a disable made the disabled flag
116
+ // query-redundant and therefore untested (red-team F6-1) — each now excludes on
117
+ // its own, and each is pinned by its own test.
118
+ if (disabled) rec[w].open.disabledDuring = true;
119
+ else rec[w].open.complete = false;
120
+ }
121
+ }
122
+
123
+ /** Close the open cycle for a window (M5: clock-authoritative — the close is keyed
124
+ * on `endedAt`, which the caller derives from the reset stamp or the clock, and the
125
+ * cycle keeps its own book regardless of probe health). No-op if none open. */
126
+ closeCycle(name, window, endedAt = this._now(), { resetAt = null } = {}) {
127
+ const rec = this._accounts.get(name);
128
+ if (!rec || !rec[window]?.open) return null;
129
+ const open = rec[window].open;
130
+ // ONE CYCLE PER BOUNDARY CLOSURE — the structural backstop for the two-closer race
131
+ // (round-2 F1). If both closers observe the SAME reset stamp within the same
132
+ // rollover moment, the second close's tokens are a straddling tail of that same
133
+ // window, so they FOLD INTO that cycle instead of becoming a tiny fabricated second
134
+ // one that drags every average down. A LATER window reporting the same numeric stamp
135
+ // value (clock coincidence) is distinguished by endedAt. Pinned by I3.
136
+ const prev = rec[window].closed[rec[window].closed.length - 1];
137
+ if (resetAt != null && prev && prev.resetAt === resetAt && prev.endedAt === endedAt
138
+ && open.complete && !open.disabledDuring) {
139
+ // Fold ONLY a complete tail: folding a partial/disabled tail would flip the
140
+ // flags on the prior legitimate observation and ERASE it from the averages
141
+ // (round 3, RT3-2) — strictly worse than leaving a tiny excluded cycle.
142
+ prev.tokens += open.tokensSoFar;
143
+ rec[window].open = null;
144
+ return prev;
145
+ }
146
+ rec[window].closed.push({
147
+ startedAt: open.startedAt,
148
+ endedAt,
149
+ tokens: open.tokensSoFar,
150
+ complete: open.complete,
151
+ disabledDuring: open.disabledDuring,
152
+ resetAt,
153
+ });
154
+ if (rec[window].closed.length > MAX_CYCLES_PER_WINDOW) rec[window].closed.shift();
155
+ rec[window].open = null;
156
+ return rec[window].closed[rec[window].closed.length - 1];
157
+ }
158
+
159
+ // ── Queries ─────────────────────────────────────────────────────────────────
160
+
161
+ /** The columns the TUI renders: last, prev, prev1, avg3, avg10, allTime — over
162
+ * COMPLETE, not-disabled cycles only (D4/D5: partial and operator-disabled cycles
163
+ * are observations, not capacity). */
164
+ windowStats(name, window) {
165
+ const rec = this._accounts.get(name);
166
+ const closed = (rec?.[window]?.closed || []).filter(c => c.complete && !c.disabledDuring);
167
+ if (!closed.length) return null;
168
+ const avg = (arr) => arr.length ? Math.round(arr.reduce((a, b) => a + b, 0) / arr.length) : null;
169
+ const tokens = closed.map(c => c.tokens);
170
+ return {
171
+ last: closed[closed.length - 1].tokens,
172
+ prev: closed.length >= 2 ? closed[closed.length - 2].tokens : null,
173
+ prev1: closed.length >= 3 ? closed[closed.length - 3].tokens : null,
174
+ avg3: avg(tokens.slice(-3)),
175
+ avg10: avg(tokens.slice(-10)),
176
+ allTime: avg(tokens),
177
+ cycles: closed.length,
178
+ };
179
+ }
180
+
181
+ /** Rolling-7d throughput (the no-weekly account's weekly figure). The window is
182
+ * keyed on CALENDAR days — [today-6 .. today] UTC — NOT "the last 7 buckets":
183
+ * buckets exist only where accrual happened, so a bucket-slice silently stretches
184
+ * across idle gaps and over-reports (red-team F4). A missing day contributes 0 by
185
+ * ABSENCE (present in dayKeys, absent from days) — and with MAX_DAY_BUCKETS=10 an
186
+ * idle day no longer even evicts; only real activity ages out. `partial` is true
187
+ * when any bucket in the window is flagged partial — the figure is ≤ observed. */
188
+ rollingThroughput(name, days = 7) {
189
+ const rec = this._accounts.get(name);
190
+ if (!rec) return { tokens: 0, partial: false };
191
+ // The window is anchored on the ledger's OWN clock — the last `days` CALENDAR days
192
+ // ending today. An earlier "latest recorded day" anchor reported a weeks-old window
193
+ // as if it were current (idle GLM fallback showed a stale 25M "7d volume" with no
194
+ // disclosure — red-team round 2, F2). Now: idle → 0, honestly.
195
+ const today = new Date(this._now()).toISOString().slice(0, 10);
196
+ const cutoff = this._utcDayMinus(today, Math.min(days - 1, MAX_DAY_BUCKETS - 1));
197
+ let tokens = 0, partial = false;
198
+ for (const [d, v] of Object.entries(rec.days)) {
199
+ if (d >= cutoff && d <= today) { tokens += v.tokens; if (v.partial) partial = true; }
200
+ }
201
+ return { tokens, partial };
202
+ }
203
+
204
+ _utcDayMinus(day, n) {
205
+ const d = new Date(`${day}T00:00:00Z`);
206
+ d.setUTCDate(d.getUTCDate() - n);
207
+ return d.toISOString().slice(0, 10);
208
+ }
209
+
210
+ /**
211
+ * Merge a DELTA of accrual into a base payload (B2 drain-exit merge-flush).
212
+ *
213
+ * The released worker keeps serving in-flight requests for up to RELOAD_DRAIN_MS
214
+ * AFTER its final flush, and then exits bare — so every token delivered during the
215
+ * drain was measured and thrown away. It cannot simply re-write its own ledger:
216
+ * the NEW worker owns the file by then and has its own accrual. So at exit it
217
+ * computes what it accrued SINCE its final flush (after − before) and adds only
218
+ * that delta onto whatever the new worker has on disk.
219
+ *
220
+ * Adds to the OPEN cycles and the day buckets — the only places drain-time tokens
221
+ * can land. A cycle the new worker already closed is not re-opened (the tokens
222
+ * belonged to a window that has since rolled; dropping them is correct, and the
223
+ * cycle is a completed observation we must not mutate after the fact).
224
+ */
225
+ static mergeDelta(basePayload, beforePayload, afterPayload) {
226
+ const base = (basePayload && basePayload.schemaVersion === SCHEMA_VERSION)
227
+ ? JSON.parse(JSON.stringify(basePayload))
228
+ : { schemaVersion: SCHEMA_VERSION, accounts: {} };
229
+ const before = beforePayload?.accounts || {};
230
+ const after = afterPayload?.accounts || {};
231
+ for (const [name, aRec] of Object.entries(after)) {
232
+ const bRec = before[name] || {};
233
+ for (const w of ['ses', 'wk']) {
234
+ const aOpen = aRec[w]?.open, bOpen = bRec[w]?.open;
235
+ if (!aOpen) continue;
236
+ const target = base.accounts[name]?.[w];
237
+ // The BASE's open cycle is the one being amended, so the same-cycle check is
238
+ // against IT — not against our own before-snapshot (which trivially matches
239
+ // our own after-snapshot and so never fired). A different startedAt means the
240
+ // window rolled during the drain: the delta belongs to a window the new worker
241
+ // has already closed, and crediting it to the fresh cycle would inflate the
242
+ // very next capacity reading by a whole window of traffic (red-team F6-3).
243
+ if (!target?.open) continue;
244
+ if (target.open.startedAt !== aOpen.startedAt) continue;
245
+ const delta = (bOpen && bOpen.startedAt === aOpen.startedAt)
246
+ ? aOpen.tokensSoFar - bOpen.tokensSoFar
247
+ : aOpen.tokensSoFar;
248
+ if (!(delta > 0)) continue;
249
+ target.open.tokensSoFar += delta;
250
+ target.open.lastAccrualAt = Math.max(target.open.lastAccrualAt || 0, aOpen.lastAccrualAt || 0);
251
+ }
252
+ for (const [day, v] of Object.entries(aRec.days || {})) {
253
+ const bTok = bRec.days?.[day]?.tokens || 0;
254
+ const delta = (v.tokens || 0) - bTok;
255
+ if (!(delta > 0)) continue;
256
+ base.accounts[name] = base.accounts[name]
257
+ || { ses: { open: null, closed: [] }, wk: { open: null, closed: [] }, days: {} };
258
+ const days = base.accounts[name].days;
259
+ days[day] = days[day] || { tokens: 0, partial: false };
260
+ days[day].tokens += delta;
261
+ }
262
+ }
263
+ return base;
264
+ }
265
+
266
+ dayKeys(name) { return Object.keys(this._accounts.get(name)?.days || {}).sort(); }
267
+ openCycle(name, window) { return this._accounts.get(name)?.[window]?.open || null; }
268
+ accounts() { return [...this._accounts.keys()]; }
269
+ markDayPartial(name, utcDay) {
270
+ const rec = this._accounts.get(name);
271
+ if (rec?.days[utcDay]) rec.days[utcDay].partial = true;
272
+ }
273
+ }
package/src/config.js CHANGED
@@ -289,6 +289,22 @@ export async function readGeneration(path) {
289
289
  */
290
290
  let _stateWriteChain = Promise.resolve();
291
291
 
292
+ /**
293
+ * Amended state write at an UNCHANGED generation (drain-exit capacity merge-flush).
294
+ * saveState always bumps `_generation`; a released worker amending the file the NEW
295
+ * lease-holder owns must NOT bump it, or the holder's `stateGeneration` is stale from
296
+ * that instant and every guarded periodic write is refused for its entire tenure.
297
+ * Serialized onto the same write chain; NEVER used for ordinary state updates.
298
+ */
299
+ export function saveStateUnbumped(state, generation) {
300
+ const run = async () => {
301
+ await atomicWrite(getStatePath(), JSON.stringify({ ...state, _generation: generation }, null, 2) + '\n');
302
+ };
303
+ const result = _stateWriteChain.then(run, run);
304
+ _stateWriteChain = result.then(() => {}, () => {});
305
+ return result;
306
+ }
307
+
292
308
  export function saveState(state, { expectedGeneration = null } = {}) {
293
309
  // Serialize state writes (a 60s interval flush can otherwise race the final
294
310
  // baton flush, read the same on-disk generation, and double-write).
package/src/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import net from 'node:net';
4
+ import { stat } from 'node:fs/promises';
4
5
  import { spawn, spawnSync } from 'node:child_process';
5
6
  import { createInterface } from 'node:readline';
6
- import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConfigPath, loadState, saveState, getStatePath, getLogPath, readGeneration, flushConfigWrites, flushStateWrites } from './config.js';
7
+ import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConfigPath, loadState, saveState, getStatePath, getLogPath, readGeneration, flushConfigWrites, flushStateWrites, saveStateUnbumped } from './config.js';
7
8
  import { setEventLogPath, installConsoleMirror, setConsoleStdoutSuppressed } from './event-log.js';
8
9
  import { SleepGuard } from './sleep-guard.js';
9
10
  import { AccountManager } from './account-manager.js';
@@ -27,6 +28,7 @@ net.setDefaultAutoSelectFamilyAttemptTimeout(
27
28
  Math.max(1000, Number(process.env.MAXPOOL_FAMILY_ATTEMPT_TIMEOUT_MS) || 5000),
28
29
  );
29
30
  import { Prober } from './prober.js';
31
+ import { CapacityLedger } from './capacity-ledger.js';
30
32
  import { loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon, tokenFingerprint } from './oauth.js';
31
33
  import { TUI } from './tui.js';
32
34
  import { RestartController } from './restart-controller.js';
@@ -606,6 +608,18 @@ async function serverWorkerCommand() {
606
608
  // lease holder); a cold/direct worker reads the on-disk state file.
607
609
  const savedState = await loadState();
608
610
  if (savedState?.quota) accountManager.restoreQuotaState(savedState.quota);
611
+ if (savedState?.capacity) {
612
+ // Measured maxpool downtime = state-file last write → boot. A seamless reload
613
+ // worker's state was handed over live (no gap) → null skips the partial check.
614
+ let downtimeMs = null;
615
+ try {
616
+ if (!isReloadWorker) {
617
+ const st = await stat(getStatePath());
618
+ downtimeMs = Date.now() - st.mtimeMs;
619
+ }
620
+ } catch { downtimeMs = null; }
621
+ accountManager.restoreCapacityState(savedState.capacity, Date.now(), downtimeMs);
622
+ }
609
623
  // Runtime fallback providers (glm-fallback/kimi-fallback) are created lazily from
610
624
  // `cc all` request headers, not config — so without restoring them here a restart
611
625
  // shows only the config OAuth accounts until the next `cc all` request re-sends the
@@ -675,12 +689,47 @@ async function serverWorkerCommand() {
675
689
  // Runtime providers so glm-fallback/kimi-fallback survive a restart (they're
676
690
  // header-derived, not in config). Empty [] when none — harmless.
677
691
  runtimeProviders: accountManager.exportRuntimeProviders(),
692
+ // CAPACITY LEDGER (2026-08-22) rides the SAME payload on purpose: it inherits
693
+ // the writer lease, the generation guard and the serialized write chain. A
694
+ // separate file would need its own copy of all three (pre-mortem B2), and the
695
+ // OPEN cycle must persist or a mid-cycle restart records a near-empty cycle as
696
+ // a real capacity observation and poisons the averages forever (B1).
697
+ capacity: accountManager.capacity?.serialize?.() || null,
678
698
  },
679
699
  { expectedGeneration },
680
700
  )
681
701
  .then(written => { if (written != null) stateGeneration = written; })
682
702
  .catch(() => {});
683
703
  };
704
+ // CAPACITY LEDGER drain-exit merge-flush (B2). `capacityFlushSnapshot` is taken at
705
+ // the released worker's FINAL flush; everything accrued after it is drain-time work
706
+ // whose tokens would otherwise be discarded when this worker exits bare.
707
+ let capacityFlushSnapshot = null;
708
+ const mergeFlushCapacityDelta = async () => {
709
+ if (!capacityFlushSnapshot) return;
710
+ const after = accountManager.capacity?.serialize?.();
711
+ if (!after) return;
712
+ const disk = await loadState();
713
+ if (!disk) { // a failed read must not be "merged" into a quota-less state file (RT2-4)
714
+ console.error('[Maxpool] Capacity drain-flush skipped: state file unreadable — drain-time token delta not persisted.');
715
+ return;
716
+ }
717
+ const capacity = CapacityLedger.mergeDelta(disk.capacity, capacityFlushSnapshot, after);
718
+ // Re-write the FULL on-disk state with only `capacity` replaced: quota and
719
+ // runtimeProviders belong to the NEW worker now and must not be reverted to ours.
720
+ // No generation guard — we are deliberately amending a file another writer owns,
721
+ // and a same-instant race costs at most this drain's delta.
722
+ const { _generation, ...rest } = disk;
723
+ // NO generation bump: re-write the file at the SAME generation so the new
724
+ // worker's stateGeneration stays valid. saveState refuses a lower generation
725
+ // only — writing the observed one back is accepted and bumps nothing. Bumping
726
+ // here (the naive first version) wedged the new worker's periodic flush for its
727
+ // entire tenure: onDisk advanced past its stateGeneration and every guarded
728
+ // write was refused forever (red-team 2026-08-22).
729
+ await saveStateUnbumped({ ...rest, capacity }, _generation ?? 0);
730
+ await flushStateWrites();
731
+ };
732
+
684
733
  // Persist quota every minute; unref so it never keeps the process alive.
685
734
  let quotaSaveInterval = null;
686
735
 
@@ -1339,6 +1388,11 @@ async function serverWorkerCommand() {
1339
1388
  // flipped hasLease=false, and persistQuotaState no-ops without the lease —
1340
1389
  // so an unforced call here silently drops the reload's final quota flush.
1341
1390
  await persistQuotaState(true);
1391
+ // 3b) CAPACITY LEDGER: snapshot what we had flushed. We keep serving in-flight
1392
+ // requests for up to RELOAD_DRAIN_MS after this, and those tokens are real
1393
+ // measured capacity — but the NEW worker owns the state file by then. At
1394
+ // exit we merge (now − this snapshot) onto whatever it has written (B2).
1395
+ capacityFlushSnapshot = accountManager.capacity?.serialize?.() || null;
1342
1396
  // 4) Barrier: ensure every queued config write (a rotated-token persist is
1343
1397
  // fire-and-forget) AND state write has actually hit disk before we hand
1344
1398
  // off (M3 — otherwise the new worker boots from the invalidated token).
@@ -1368,14 +1422,22 @@ async function serverWorkerCommand() {
1368
1422
  // exit(0). Cap = RELOAD_DRAIN_MS (above the idle reaper) so a long streaming
1369
1423
  // response finishes instead of being cut; the supervisor SIGKILLs us only if
1370
1424
  // we outlive its (slightly longer) cap.
1425
+ // Exit only AFTER folding the drain-time capacity delta into the new worker's
1426
+ // state file. Bounded (1s) and best-effort: losing the delta is a measurement
1427
+ // gap, hanging the released worker is an operational fault, so the timeout wins.
1428
+ const exitAfterMergeFlush = () => {
1429
+ Promise.race([mergeFlushCapacityDelta(), delay(1000)])
1430
+ .catch(() => {})
1431
+ .finally(() => process.exit(0));
1432
+ };
1371
1433
  const waitForDrain = () => {
1372
- if (restartController.activeRequests.size === 0) { process.exit(0); return; }
1434
+ if (restartController.activeRequests.size === 0) { exitAfterMergeFlush(); return; }
1373
1435
  };
1374
1436
  const drainPoll = setInterval(waitForDrain, 200);
1375
1437
  drainPoll.unref?.();
1376
1438
  const hardCap = setTimeout(() => {
1377
1439
  console.error(`[Maxpool] Released worker reload-drain cap reached with ${restartController.activeRequests.size} active; exiting.`);
1378
- process.exit(0);
1440
+ exitAfterMergeFlush();
1379
1441
  }, RELOAD_DRAIN_MS);
1380
1442
  hardCap.unref?.();
1381
1443
  waitForDrain();
package/src/prober.js CHANGED
@@ -70,6 +70,12 @@ export class Prober {
70
70
  this._stopping = false;
71
71
  this._inflight = (async () => {
72
72
  try {
73
+ // CAPACITY LEDGER: close any open cycle whose window's reset stamp has
74
+ // passed. Runs at the top of every sweep (lease-holder only — probeAll
75
+ // fires from the prober, which start/stop with the lease) so a window that
76
+ // rolled over between requests still closes promptly, not only on the next
77
+ // accrual or stamp-advance.
78
+ try { this.am.closeExpiredCapacityCycles?.(); } catch { /* never block probing */ }
73
79
  // DISABLED accounts are INTENTIONALLY still probed (no `a.enabled` filter):
74
80
  // a user often disables an account precisely BECAUSE it's exhausted, and still
75
81
  // wants to see its quota recover — so keep refreshing its usage for visibility
package/src/server.js CHANGED
@@ -1285,7 +1285,19 @@ async function forwardRequest(
1285
1285
 
1286
1286
  if (isStreaming) {
1287
1287
  const streamLog = logDir ? [] : null;
1288
- await streamResponse(upstreamRes.body, res, upstreamRes.status, responseHeaders, account.index, accountManager, streamLog, requestInfo);
1288
+ // CAPACITY LEDGER: accrue in a FINALLY — a stream that dies mid-flight throws
1289
+ // from streamResponse, and the tokens genuinely delivered before the failure
1290
+ // are real capacity; skipping them under-counts exactly the longest
1291
+ // generations (red-team F5). One accrual per request, per-stream running max
1292
+ // (M3 — cumulative interim deltas must not be summed).
1293
+ try {
1294
+ await streamResponse(upstreamRes.body, res, upstreamRes.status, responseHeaders, account.index, accountManager, streamLog, requestInfo);
1295
+ } finally {
1296
+ accountManager.accrueCapacity?.(account.index, {
1297
+ input: requestInfo._capacityInput || 0,
1298
+ output: requestInfo._capacityOutput || 0,
1299
+ });
1300
+ }
1289
1301
  accountManager.releaseAccount(lease, { success: true, status: upstreamRes.status });
1290
1302
  if (logDir) {
1291
1303
  logSections.push(`=== RESPONSE BODY (streamed) ===\n${streamLog.join('')}`);
@@ -1312,7 +1324,7 @@ async function forwardRequest(
1312
1324
  clearTimeout(bodyTimer);
1313
1325
  }
1314
1326
  const buf = Buffer.from(arr);
1315
- extractUsageFromBody(buf, account.index, accountManager);
1327
+ extractUsageFromBody(buf, account.index, accountManager, requestInfo);
1316
1328
  markThinkingFromResponse(buf, accountManager, requestInfo);
1317
1329
  accountManager.releaseAccount(lease, { success: upstreamRes.status < 500, status: upstreamRes.status });
1318
1330
  if (logDir) {
@@ -2914,10 +2926,18 @@ function parseSSEEvent(event, accountIndex, accountManager, requestInfo = {}) {
2914
2926
 
2915
2927
  try {
2916
2928
  const data = JSON.parse(dataLine.slice(6));
2917
- if (data.type === 'message_start' && data.message?.usage) {
2918
- accountManager.updateUsage(accountIndex, data.message.usage.input_tokens, 0);
2919
- } else if (data.type === 'message_delta' && data.usage) {
2920
- accountManager.updateUsage(accountIndex, 0, data.usage.output_tokens);
2929
+ // CAPACITY LEDGER (2026-08-22): stream-level usage feeds the per-cycle ledger.
2930
+ // M3: Anthropic interim message_delta usage is CUMULATIVE — add-semantics inflates
2931
+ // output on long generations. Track a per-stream RUNNING MAX, accrue once at end.
2932
+ // M4: count_tokens responses are prompt-size echoes, not delivered work — skip.
2933
+ if (!requestInfo.isCountTokens) {
2934
+ if (data.type === 'message_start' && data.message?.usage) {
2935
+ accountManager.updateUsage(accountIndex, data.message.usage.input_tokens, 0);
2936
+ requestInfo._capacityInput = Math.max(requestInfo._capacityInput || 0, data.message.usage.input_tokens || 0);
2937
+ } else if (data.type === 'message_delta' && data.usage) {
2938
+ accountManager.updateUsage(accountIndex, 0, data.usage.output_tokens);
2939
+ requestInfo._capacityOutput = Math.max(requestInfo._capacityOutput || 0, data.usage.output_tokens || 0);
2940
+ }
2921
2941
  }
2922
2942
  if (sseEventContainsThinking(data)) {
2923
2943
  accountManager.markSessionThinkingProtected?.(requestInfo.sessionKey, requestInfo.model);
@@ -2933,11 +2953,20 @@ function sseEventContainsThinking(data) {
2933
2953
  || data?.delta?.type === 'signature_delta';
2934
2954
  }
2935
2955
 
2936
- function extractUsageFromBody(buffer, accountIndex, accountManager) {
2956
+ function extractUsageFromBody(buffer, accountIndex, accountManager, requestInfo = {}) {
2937
2957
  try {
2938
2958
  const json = JSON.parse(buffer.toString());
2939
2959
  if (json.usage) {
2940
2960
  accountManager.updateUsage(accountIndex, json.usage.input_tokens, json.usage.output_tokens);
2961
+ // CAPACITY LEDGER — M4: a /count_tokens response is a prompt-size echo with ZERO
2962
+ // work delivered. Claude Code calls it constantly; counting it would inflate every
2963
+ // cycle by whole prompt sizes.
2964
+ if (!requestInfo.isCountTokens) {
2965
+ accountManager.accrueCapacity?.(accountIndex, {
2966
+ input: json.usage.input_tokens || 0,
2967
+ output: json.usage.output_tokens || 0,
2968
+ });
2969
+ }
2941
2970
  }
2942
2971
  } catch {
2943
2972
  // not JSON or no usage
package/src/tui.js CHANGED
@@ -68,7 +68,10 @@ function rpad(s, w) {
68
68
  return gap > 0 ? s + ' '.repeat(gap) : s;
69
69
  }
70
70
 
71
- /** Truncate a string with ANSI codes to exactly w visible characters, then reset. */
71
+ /** Truncate a string with ANSI codes to exactly w visible characters, then reset.
72
+ * The trailing RESET is appended only when something was actually cut — a no-op
73
+ * truncate that unconditionally appended it shifted `.padEnd()` (raw-length) fields
74
+ * 4 columns narrow for every string shorter than the width (red-team round 3, RT3-4). */
72
75
  function truncate(s, w) {
73
76
  let visible = 0;
74
77
  let out = '';
@@ -82,7 +85,7 @@ function truncate(s, w) {
82
85
  visible++;
83
86
  i++;
84
87
  }
85
- return out + RESET;
88
+ return visible < w ? out + ' '.repeat(w - visible) + RESET : out + RESET;
86
89
  }
87
90
 
88
91
  /** Fit a line to exactly w columns: truncate if too long, pad if too short. */
@@ -116,6 +119,16 @@ function quotaLabel(ratio, resetTs, width) {
116
119
  return (rst || pct).slice(0, width);
117
120
  }
118
121
 
122
+ /** Compact token count for the capacity page. Tokens are the apples-to-apples unit
123
+ * across Claude / GLM / Kimi, so the column must stay narrow and scannable. */
124
+ function formatTokens(n) {
125
+ if (n == null) return '--';
126
+ if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
127
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
128
+ if (n >= 1_000) return `${Math.round(n / 1_000)}k`;
129
+ return String(n);
130
+ }
131
+
119
132
  function formatMs(ms) {
120
133
  if (ms == null || isNaN(ms)) return '-';
121
134
  if (ms < 1000) return `${Math.round(ms)}ms`;
@@ -257,7 +270,8 @@ export class TUI {
257
270
 
258
271
  this.log = []; // completed activity entries
259
272
  this.active = new Map(); // in-flight requests
260
- this.mode = 'normal'; // normal | accounts | routing | select | input | confirm | providers | addtype
273
+ this.mode = 'normal'; // normal | accounts | routing | capacity | select | input | confirm | providers | addtype
274
+ this.capacityWindow = 'ses'; // capacity page window: 'ses' (5h) | 'wk' (weekly)
261
275
  // Hide disabled accounts from the table. With 8 dead/disabled accounts the live
262
276
  // ones scroll off the top; `h` collapses them to a one-line summary.
263
277
  this.hideDisabled = false;
@@ -408,6 +422,7 @@ export class TUI {
408
422
  case 'accounts': this._keyAccounts(k); break;
409
423
  case 'routing': this._keyRouting(k); break;
410
424
  case 'updates': this._keyUpdates(k); break;
425
+ case 'capacity': this._keyCapacity(k); break;
411
426
  case 'addtype': this._keyAddType(k); break;
412
427
  case 'select': this._keySelect(k); break;
413
428
  case 'input': this._keyInput(k); break;
@@ -451,6 +466,8 @@ export class TUI {
451
466
  );
452
467
  } else if (k === 'u') {
453
468
  this.mode = 'updates';
469
+ } else if (k === 'y') {
470
+ this.mode = 'capacity';
454
471
  } else if (k === 'h') {
455
472
  this.hideDisabled = !this.hideDisabled;
456
473
  this._addLog(this.hideDisabled ? 'Hiding disabled accounts' : 'Showing all accounts');
@@ -908,8 +925,8 @@ export class TUI {
908
925
  this.config.scheduler.providers = { ...(this.am.scheduler.providers || {}) };
909
926
  await this.saveConfig(this.config);
910
927
  this._addLog(next
911
- ? 'Peak hours: prefer other accounts (your routing mode is overridden during the window)'
912
- : 'Peak hours: keep your routing mode all day (the weekly cap still applies)');
928
+ ? 'Peak hours: GLM ranks last during the window (overrides the routing mode)'
929
+ : 'Peak hours: routing runs normally during the window (the cap still applies)');
913
930
  }
914
931
 
915
932
  /** Cycle the peak weekly cap across the values that mean something. */
@@ -1598,6 +1615,19 @@ export class TUI {
1598
1615
  lines.push(` ${cyan(' Parked')} ${dim(`${queuedCount} request${queuedCount === 1 ? '' : 's'} held oldest ${formatMs(oldest)} · ${why}`)}`);
1599
1616
  }
1600
1617
 
1618
+ // ── Capacity page — a DEDICATED screen, not a footer panel. It replaces the
1619
+ // account/activity body wholesale so the six columns get the full width; the
1620
+ // header above (version, routing, throttle) stays so the operator never loses
1621
+ // where they are. Reads the in-memory ledger only — no fs per frame.
1622
+ if (this.mode === 'capacity') {
1623
+ lines.push(...this._renderCapacityPage(W));
1624
+ while (lines.length < H - 2) lines.push('');
1625
+ lines.push(' ' + dim('─'.repeat(W - 2)));
1626
+ lines.push(this._renderFooter());
1627
+ this._writeScreen(lines, W, H);
1628
+ return;
1629
+ }
1630
+
1601
1631
  // ── Accounts
1602
1632
  if (this.am.accounts.length === 0) {
1603
1633
  lines.push('');
@@ -1696,7 +1726,12 @@ export class TUI {
1696
1726
  if (this.mode === 'updates') lines.push(...this._renderUpdatesDetail());
1697
1727
  lines.push(this._renderFooter());
1698
1728
 
1699
- // Write buffer
1729
+ this._writeScreen(lines, W, H);
1730
+ }
1731
+
1732
+ /** Full-screen buffer write: cursor home, H rows fitted to W, then hide the cursor
1733
+ * (shown only in input mode). */
1734
+ _writeScreen(lines, W, H) {
1700
1735
  let buf = `${ESC}H`;
1701
1736
  for (let i = 0; i < H; i++) {
1702
1737
  buf += fitLine(lines[i] || '', W);
@@ -1955,10 +1990,102 @@ export class TUI {
1955
1990
  };
1956
1991
  }
1957
1992
 
1993
+ _keyCapacity(k) {
1994
+ if (k === 'w') { this.capacityWindow = this.capacityWindow === 'wk' ? 'ses' : 'wk'; }
1995
+ else if (k === 'esc' || k === 'q') { this.mode = 'normal'; }
1996
+ }
1997
+
1998
+ /**
1999
+ * Capacity page — how much an account ACTUALLY delivers before it runs out.
2000
+ *
2001
+ * Each completed 5h/weekly cycle's token total IS that account's measured capacity
2002
+ * for the window, so the columns compare a Claude account and a GLM account on the
2003
+ * same axis. Only COMPLETE, never-disabled cycles count (a cycle maxpool sat out
2004
+ * part of is an observation, not a capacity).
2005
+ *
2006
+ * The no-weekly account (legacy z.ai plan) has no weekly TANK to fill, so its weekly
2007
+ * row is a rolling-7d THROUGHPUT — a volume, labelled as such, never a limit.
2008
+ */
2009
+ _renderCapacityPage(W) {
2010
+ const out = [];
2011
+ const win = this.capacityWindow === 'wk' ? 'wk' : 'ses';
2012
+ const ledger = this.am.capacity;
2013
+ const title = win === 'wk' ? 'Weekly (7d) capacity' : 'Session (5h) capacity';
2014
+ out.push('');
2015
+ out.push(` ${bold(title)} ${dim('— tokens delivered per completed cycle, per account')}`);
2016
+ out.push('');
2017
+
2018
+ if (!ledger) { out.push(yellow(' Capacity ledger unavailable on this worker.')); return out; }
2019
+
2020
+ // Narrow terminals: drop trailing columns rather than let fitLine chop a number
2021
+ // mid-digit (at W=80 the full 6-column row is 82+ chars — every row silently lost
2022
+ // its last two cells). The dropped ones are the aggregates, not the observations.
2023
+ const ALL_COLS = ['Last', 'Prev', 'Prev-1', 'Avg 3', 'Avg 10', 'All time'];
2024
+ const CW = 9;
2025
+ const nameW = 12;
2026
+ let COLS = ALL_COLS;
2027
+ while (COLS.length > 1 && (nameW + PROVIDER_W + 2 + COLS.length * CW + 14) > W) {
2028
+ COLS = COLS.slice(0, -1);
2029
+ }
2030
+ const header = ' ' + 'Account'.padEnd(nameW) + ' ' + 'Provider'.padEnd(PROVIDER_W) + ' '
2031
+ + COLS.map(c => c.padStart(CW)).join('') + ' Cycles';
2032
+ out.push(dimUnderline(fitLine(header, W)));
2033
+
2034
+ let anyData = false;
2035
+ for (const i of this._displayOrder()) {
2036
+ const a = this.am.accounts[i];
2037
+ if (!a) continue;
2038
+ if (this.hideDisabled && a.enabled === false) continue;
2039
+ const noWeekly = win === 'wk' && a.type === 'provider' && a.quota?.weeklyAbsent;
2040
+ const name = truncate(a.name, nameW).padEnd(nameW);
2041
+ const prov = gray(providerLabel(a).padEnd(PROVIDER_W));
2042
+ if (noWeekly) {
2043
+ // No weekly limit — the favourite legacy plan. There is no cycle to complete,
2044
+ // so a capacity number would be a fiction. Show what it actually DID move.
2045
+ const t = ledger.rollingThroughput(a.name, 7);
2046
+ anyData = anyData || t.tokens > 0;
2047
+ const vol = t.tokens > 0 ? formatTokens(t.tokens) : '--';
2048
+ // Always disclose the window's age boundary: today is unfinished, so the 7d
2049
+ // figure grows through the day; a genuinely partial day adds the ≤-observed floor.
2050
+ const note = t.partial
2051
+ ? ' (≤ observed — maxpool was down part of the window; includes today, in progress)'
2052
+ : ' (includes today, in progress)';
2053
+ out.push(' ' + name + ' ' + prov + ' '
2054
+ + cyan(`no weekly limit · 7d volume ${vol}`) + dim(note));
2055
+ continue;
2056
+ }
2057
+ const st = ledger.windowStats(a.name, win);
2058
+ if (!st) {
2059
+ out.push(' ' + name + ' ' + prov + ' ' + dim('no completed cycle yet'));
2060
+ continue;
2061
+ }
2062
+ anyData = true;
2063
+ const all = { Last: st.last, Prev: st.prev, 'Prev-1': st.prev1, 'Avg 3': st.avg3, 'Avg 10': st.avg10, 'All time': st.allTime };
2064
+ const cells = COLS.map(c => formatTokens(all[c]).padStart(CW)).join('');
2065
+ out.push(' ' + name + ' ' + prov + ' ' + cells + ' ' + dim(String(st.cycles)));
2066
+ }
2067
+
2068
+ out.push('');
2069
+ if (!anyData) {
2070
+ // Fresh install: the page is honest about WHY it is empty and WHEN it fills,
2071
+ // instead of showing zeros that read like an account delivering nothing.
2072
+ out.push(' ' + yellow('No completed cycles yet.') + dim(
2073
+ win === 'wk'
2074
+ ? ' A weekly figure appears after an account\'s 7d window resets once.'
2075
+ : ' A session figure appears after an account\'s 5h window resets once.'));
2076
+ }
2077
+ out.push(' ' + dim('A cycle counts only if maxpool ran for all of it and the account stayed enabled.'));
2078
+ return out;
2079
+ }
2080
+
1958
2081
  _renderFooter() {
1959
2082
  switch (this.mode) {
1960
2083
  case 'normal':
1961
- return ` ${bold('a')} Accounts ${bold('m')} Routing ${bold('h')} Hide disabled ${dim('│')} ${bold('u')} Updates ${bold('r')} Restart ${bold('q')} Stop server`;
2084
+ return ` ${bold('a')} Accounts ${bold('m')} Routing ${bold('y')} Capacity ${bold('h')} Hide disabled ${dim('│')} ${bold('u')} Updates ${bold('r')} Restart ${bold('q')} Stop server`;
2085
+ case 'capacity': {
2086
+ const other = this.capacityWindow === 'wk' ? '5h session' : 'weekly (7d)';
2087
+ return ` ${bold('w')} Show ${cyan(other)} ${bold('Esc')} Back`;
2088
+ }
1962
2089
  case 'updates': {
1963
2090
  const state = this._autoUpdateOn() ? green('on') : dim('off');
1964
2091
  return ` ${bold('c')} Check & apply now ${bold('t')} Automatic updates: ${state} ↻ ${bold('Esc')} Back`;
@@ -1986,9 +2113,9 @@ export class TUI {
1986
2113
  const ps = this.am._peakSettingsFor(peakFam.provider);
1987
2114
  const st = this.am._peakStateFor?.(peakFam.provider);
1988
2115
  const now = st?.inPeak ? red(' NOW') : '';
1989
- const dep = ps.depreference ? yellow('prefer others') : cyan('keep my mode');
2116
+ const dep = ps.depreference ? yellow('GLM last') : cyan('normal');
1990
2117
  const cap = ps.cap >= 1 ? 'off' : ps.cap === 0 ? 'never' : `${Math.round(ps.cap * 100)}%`;
1991
- peakPart = ` ${dim('│')} ${bold('d')} Peak${now}: ${dep} ${bold('c')} cap ${cyan(cap)}`;
2118
+ peakPart = ` ${dim('│')} ${bold(' d ')}Peak${now}: ${dep} ${bold(' c ')}cap ${cyan(cap)}`;
1992
2119
  }
1993
2120
  return ` ${bold('f')} Routing: ${cyan(mode.label)} ↻${provPart}${peakPart} ${bold('p')} Preference ${bold('Esc')} Back`;
1994
2121
  }