maxpool 1.15.0 → 1.17.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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/prober.js +9 -0
  3. package/src/tui.js +151 -36
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.15.0",
3
+ "version": "1.17.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",
package/src/prober.js CHANGED
@@ -68,6 +68,11 @@ export class Prober {
68
68
  if (this._running) return this._inflight || Promise.resolve();
69
69
  this._running = true;
70
70
  this._stopping = false;
71
+ // Publish the sweep's liveness so the UI can answer "what happens next?"
72
+ // rather than printing a bare "stale". Reported 2026-08-27: a stale marker with
73
+ // no next step reads as a problem the user must fix, when the prober is already
74
+ // retrying on its own.
75
+ this.am.quotaProbeSweeping = true;
71
76
  this._inflight = (async () => {
72
77
  try {
73
78
  // CAPACITY LEDGER: close any open cycle whose window's reset stamp has
@@ -110,6 +115,10 @@ export class Prober {
110
115
  } finally {
111
116
  this._running = false;
112
117
  this._inflight = null;
118
+ this.am.quotaProbeSweeping = false;
119
+ // When the NEXT sweep starts. setInterval fires every intervalMs from the
120
+ // last tick, so "now + interval" is the honest estimate for the UI.
121
+ this.am.quotaProbeNextSweepAt = this.intervalMs > 0 ? Date.now() + this.intervalMs : null;
113
122
  }
114
123
  })();
115
124
  return this._inflight;
package/src/tui.js CHANGED
@@ -139,6 +139,18 @@ function formatMs(ms) {
139
139
  return `${min}m${String(rem).padStart(2, '0')}s`;
140
140
  }
141
141
 
142
+ /** Coarse human duration for AGE and NEXT-REFRESH text: "45s", "12m", "2h".
143
+ * Deliberately not formatMs — "quota 12m03s old" spends precision on a number
144
+ * nobody reads to the second, and the extra digits are what made the old cell
145
+ * look like machine output. */
146
+ function formatAge(ms) {
147
+ if (ms == null || isNaN(ms) || ms < 0) return '';
148
+ if (ms < 60_000) return `${Math.max(1, Math.round(ms / 1000))}s`;
149
+ if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
150
+ if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
151
+ return `${Math.round(ms / 86_400_000)}d`;
152
+ }
153
+
142
154
  function statusColor(status) {
143
155
  if (status == null) return '-';
144
156
  if (status >= 200 && status < 300) return green(String(status));
@@ -162,22 +174,69 @@ function countdown(ts) {
162
174
  return `${Math.ceil(ms / 86_400_000)}d`;
163
175
  }
164
176
 
177
+ /** ACTIVITY cell — "is this account working, and is it healthy?" in plain words.
178
+ * Rewritten twice on owner feedback (2026-08-27): first to symbols ("▶2 · 17/h ·
179
+ * 8.5s"), which read as secret code; now to words. Average latency is GONE —
180
+ * "how long each request takes" is not something the owner acts on. An idle
181
+ * account renders NOTHING.
182
+ *
183
+ * 2 live · 17 req/hr · 1 failed
184
+ * 17 req/hr working, idle this second
185
+ * (blank) resting — nothing to say
186
+ */
165
187
  function loadText(load) {
166
- const cur = load?.current || {};
167
- const m15 = load?.last15m || {};
168
- const h1 = load?.last1h || {};
169
- // "Now" = what this account is handling right THIS moment: N in-flight requests
170
- // (and their combined weight ~ payload size, the scheduler's load input). Kept
171
- // distinct from the "15m"/"1h" THROUGHPUT counts that follow, which the old
172
- // "Load X/Y" label collided with.
173
- const inflight = cur.inFlight || 0;
174
- const weight = cur.activeWeight || 0;
175
- const now = weight > 0 ? `Now ${inflight} (${weight}w)` : `Now ${inflight}`;
176
- const recent = `${m15.requests || 0}r`;
177
- const recentAvg = m15.avgMs != null ? ` ${formatMs(m15.avgMs)}` : '';
178
- const hour = `${h1.requests || 0}r`;
179
- const fails = (m15.failed || 0) > 0 ? ` ${red(`${m15.failed}f`)}` : '';
180
- return `${now} 15m ${recent}${recentAvg}${fails} 1h ${hour}`;
188
+ const inflight = load?.current?.inFlight || 0;
189
+ const hourReq = load?.last1h?.requests || 0;
190
+ const failed = load?.last15m?.failed || 0;
191
+ if (!inflight && !hourReq && !failed) return '';
192
+ const parts = [];
193
+ if (inflight) parts.push(`${inflight} live`);
194
+ if (hourReq) parts.push(`${hourReq} req/hr`);
195
+ if (failed) parts.push(red(`${failed} failed`));
196
+ return parts.join(' · ');
197
+ }
198
+
199
+ /** The usage CAP is an account PROPERTY, not a state — so it renders whenever one
200
+ * is set, on every account type, whatever the account is doing. It previously
201
+ * lived inline in the provider branch only, so the OAuth account the feature was
202
+ * built for (max@gomokka.com) showed no cap anywhere: reported 2026-08-27, "I need
203
+ * to be able to see whether an account has a cap or not." Yellow while the cap is
204
+ * actively holding traffic back, dim otherwise. */
205
+ function capText(a, benched) {
206
+ if (a?.capUtilization == null) return '';
207
+ const t = `cap ${Math.round(a.capUtilization * 100)}%`;
208
+ return benched ? yellow(t) : dim(t);
209
+ }
210
+
211
+ /** PER-ACCOUNT SETTINGS the user set by hand — the last column's whole job
212
+ * (owner, 2026-08-27: "the last column should contain any and all settings that
213
+ * are custom per account"). Fleet-wide settings (routing mode, peak policy) stay
214
+ * in the top header where they already live; only what is scoped to THIS account
215
+ * belongs on THIS row.
216
+ * preferred this account is the manual routing pin (the 'p' key)
217
+ * cap NN% its reserved-capacity ceiling (the 'c' key)
218
+ * Deliberately NOT here: automatic routing policies (peak, fast-refill) — those
219
+ * are the system's behaviour, not the user's settings; they keep their own tags.
220
+ */
221
+ function settingsTags(am, a) {
222
+ const tags = [];
223
+ if (am?.routingMode === 'preferred' && a?.name === am.preferredAccountName) {
224
+ tags.push(cyan('preferred'));
225
+ }
226
+ const capTag = capText(a, capBenched(am, a));
227
+ if (capTag) tags.push(capTag);
228
+ return tags;
229
+ }
230
+
231
+ /** True when the reservation is what is currently keeping traffic off this account
232
+ * — either window at or past the cap. Reads the manager's own predicate so the
233
+ * label can never disagree with routing. */
234
+ function capBenched(am, a) {
235
+ if (a?.capUtilization == null) return false;
236
+ const q = a.quota || {};
237
+ const ses = a.type === 'provider' ? q.providerSes : q.unified5h;
238
+ const wk = a.type === 'provider' ? q.providerWk : q.unified7d;
239
+ return !!(am?._capped?.(a, ses) || am?._capped?.(a, wk));
181
240
  }
182
241
 
183
242
  export function weeklyPolicyText(am, account) {
@@ -190,6 +249,14 @@ export function weeklyPolicyText(am, account) {
190
249
  return yellow(`Cap ${Math.round((account.capUtilization || 0) * 100)}%`);
191
250
  }
192
251
  if (!state || state === 'unknown' || state === 'normal') return '';
252
+ // SAY IT ONCE (2026-08-27). An account Anthropic is rejecting outright already
253
+ // says so twice on this row: the Wk bar reads 100%, and the Status column reads
254
+ // "exhausted". A third "Wk exhausted 100%" tag was pure repetition, and it pushed
255
+ // the genuinely-informative tags (Cap, a per-model sub-limit) off to the right.
256
+ // Dropped ONLY when the Status column carries the same fact — a soft/reserve/
257
+ // critical row, or an exhausted-by-threshold row the status column shows as
258
+ // "active", still needs the tag.
259
+ if (am._isAccountWideRejected?.(account)) return '';
193
260
  const rawState = am._weeklyRawState?.(account) || state;
194
261
  const used = Number(account.quota?.unified7d);
195
262
  const pct = Number.isFinite(used)
@@ -1735,11 +1802,11 @@ export class TUI {
1735
1802
  if (hidden > 0) {
1736
1803
  lines.push(` ${dim(`… ${hidden} disabled account${hidden === 1 ? '' : 's'} hidden — press h to show`)}`);
1737
1804
  }
1738
- // Glossary FOOTER (expands the abbreviations the header + inline labels can't
1739
- // spell out). Below the rows so it never breaks the header↔column alignment.
1740
- if (W >= 88) {
1741
- lines.push(' ' + dim('Legend Ses = 5h · Wk = 7d · Now = in-flight (weight) · 15m/1h = served (avg · f = fails)'));
1742
- }
1805
+ // The glossary FOOTER is gone (owner, 2026-08-27). A legend is a symptom: it
1806
+ // exists to decode a row that could not be read on its own. Every cell now
1807
+ // says what it means in words ("2 live · 17 req/hr", "quota 12m old ·
1808
+ // refreshing in 45s"), so there is nothing left to decode — and one fewer
1809
+ // line of chrome between the operator and the data.
1743
1810
  }
1744
1811
 
1745
1812
  // ── Activity header
@@ -1859,13 +1926,16 @@ export class TUI {
1859
1926
  if (a.enabled !== false && upstreamBlocking && a.status === 'active') {
1860
1927
  effectiveStatus = a.inFlight > 0 ? 'probing' : 'waiting';
1861
1928
  }
1862
- // "blocked" = Anthropic is rejecting the WHOLE account (a 'rejected' unified
1863
- // status corroborated by an exhausted unified bucket). A per-model cap (Fable)
1864
- // is NOT account-wide — it shows as the separate "… maxed" tag, leaving the
1865
- // status column truthful. Keys on a.status (not effectiveStatus) so a genuine
1866
- // block wins even inside an upstream-throttle window.
1929
+ // Anthropic is rejecting the WHOLE account (a 'rejected' unified status
1930
+ // corroborated by an exhausted unified bucket). A per-model cap (Fable) is NOT
1931
+ // account-wide — it shows as the separate "… maxed" tag, leaving the status
1932
+ // column truthful. Keys on a.status (not effectiveStatus) so a genuine block
1933
+ // wins even inside an upstream-throttle window.
1934
+ // Named "exhausted", not "blocked" (2026-08-27): the row's own quota bar and
1935
+ // every other surface call this state exhausted, and two words for one state
1936
+ // read as two different problems.
1867
1937
  if (a.enabled !== false && a.status === 'active' && this.am._isAccountWideRejected?.(a)) {
1868
- effectiveStatus = 'blocked';
1938
+ effectiveStatus = 'exhausted';
1869
1939
  }
1870
1940
  // A dead refresh token surfaces as "reauth" (re-login needed) rather than a
1871
1941
  // generic "error", so the user knows the fix. Display-only — account.status
@@ -1879,7 +1949,7 @@ export class TUI {
1879
1949
  switch (effectiveStatus) {
1880
1950
  case 'active': status = isCur ? green('active') : 'active'; break;
1881
1951
  case 'reauth': status = yellow('reauth'); break;
1882
- case 'blocked': status = red('blocked'); break;
1952
+ case 'blocked': status = red('exhausted'); break;
1883
1953
  case 'probing': status = green('probing'); break;
1884
1954
  case 'waiting': status = yellow('waiting'); break;
1885
1955
  case 'paused': status = yellow('paused'); break;
@@ -1936,6 +2006,15 @@ export class TUI {
1936
2006
  }
1937
2007
  const weekly = weeklyPolicyText(this.am, a);
1938
2008
  if (weekly) line += ` ${weekly}`;
2009
+ // Per-account SETTINGS (preferred pin, usage cap), whatever the account is
2010
+ // doing. weeklyPolicyText renders "Cap 50%" only while the cap is the ACTIVE
2011
+ // weekly state; these are the standing properties, so a capped account that is
2012
+ // exhausted, throttled or idle still says so. The cap is suppressed when the
2013
+ // weekly tag already IS the Cap label (no double tag).
2014
+ for (const tag of settingsTags(this.am, a)) {
2015
+ if (weekly.includes('Cap ') && tag.includes('cap ')) continue;
2016
+ line += ` ${tag}`;
2017
+ }
1939
2018
  // Per-model weekly caps (e.g. Fable, while the unified weekly still has
1940
2019
  // headroom). Show the ACTUAL utilization — "Fable 90%" (yellow) while high but
1941
2020
  // still usable, "Fable maxed" (red) ONLY at genuine exhaustion. This is the
@@ -1958,7 +2037,8 @@ export class TUI {
1958
2037
  // cause (e.g. rate-limited) when a probe failure is on record — rather than imply
1959
2038
  // the last-known value is current.
1960
2039
  line += this._probeHealthNote(a);
1961
- line += ` ${dim(loadText(this._accountLoad(a)))}`;
2040
+ const act = loadText(this._accountLoad(a));
2041
+ if (act) line += ` ${dim(act)}`;
1962
2042
  return line;
1963
2043
  }
1964
2044
 
@@ -1990,10 +2070,35 @@ export class TUI {
1990
2070
  const headerFresh = q.lastHeaderQuotaAt && (Date.now() - q.lastHeaderQuotaAt) <= Math.max(3 * interval, 180_000);
1991
2071
  if (headerFresh) return '';
1992
2072
  }
1993
- const s = q.lastProbeErrorStatus;
1994
- if (s === 429) return ` ${yellow('stale·probe throttled')}`;
1995
- if (s) return ` ${yellow('stale·probe ' + s)}`;
1996
- return ` ${dim('stale')}`;
2073
+ // SAY WHAT HAPPENS NEXT (owner, 2026-08-27): "stale" / "stale·probe throttled"
2074
+ // named an internal mechanism and left the user with nothing to do. Nothing IS
2075
+ // the correct action — the prober retries on its own schedule and backs off
2076
+ // automatically when Anthropic rate-limits it — so the cell now states how old
2077
+ // the numbers are AND when they refresh, in that order.
2078
+ const age = q.lastProbeOkAt ? formatAge(Date.now() - q.lastProbeOkAt) : null;
2079
+ const ageText = age ? `quota ${age} old` : 'quota not read yet';
2080
+ const next = this._probeNextText();
2081
+ const throttled = q.lastProbeErrorStatus === 429;
2082
+ // Throttled is the ONE case worth colouring: it is why the refresh is late, and
2083
+ // it self-clears. Everything else is a plain dim statement of fact.
2084
+ const body = throttled
2085
+ ? `${ageText} · rate-limited, retrying ${next}`
2086
+ : `${ageText} · refreshing ${next}`;
2087
+ return ` ${throttled ? yellow(body) : dim(body)}`;
2088
+ }
2089
+
2090
+ /** "now" while a sweep is in flight, else "in 45s" from the prober's next-tick
2091
+ * stamp. Falls back to the configured interval when no sweep has completed yet
2092
+ * (fresh boot), and to a bare "shortly" when the probe is manual/off. */
2093
+ _probeNextText() {
2094
+ if (this.am.quotaProbeSweeping) return 'now';
2095
+ const at = this.am.quotaProbeNextSweepAt;
2096
+ if (at) {
2097
+ const ms = at - Date.now();
2098
+ return ms > 0 ? `in ${formatAge(ms)}` : 'now';
2099
+ }
2100
+ const interval = this.am.quotaProbeIntervalMs;
2101
+ return interval > 0 ? `every ${formatAge(interval)}` : 'shortly';
1997
2102
  }
1998
2103
 
1999
2104
  _renderProviderAcct(sel, cur, name, type, status, a, bw = 11, showBoth = true) {
@@ -2031,7 +2136,6 @@ export class TUI {
2031
2136
  const m = this.am._fastRefillMultiplier(a);
2032
2137
  if (m < 1) note += ` ${cyan(`fast·refill ×${m.toFixed(2)}`)}`;
2033
2138
  }
2034
- if (a.capUtilization != null) note += ` ${yellow(`cap ${Math.round(a.capUtilization * 100)}%`)}`;
2035
2139
  } else if (q.providerQuotaSource === 'console-only') {
2036
2140
  sesCell = emptyBar('n/a', bw);
2037
2141
  wkCell = emptyBar('n/a', bw);
@@ -2040,15 +2144,26 @@ export class TUI {
2040
2144
  sesCell = emptyBar('probing', bw);
2041
2145
  wkCell = emptyBar('probing', bw);
2042
2146
  }
2147
+ // Settings ride OUTSIDE the quota-readable branch: an account with an
2148
+ // unreadable or not-yet-probed quota still HAS its reservation, and hiding the
2149
+ // setting whenever the probe is quiet is how a shipped feature reads as absent.
2150
+ for (const tag of settingsTags(this.am, a)) note += ` ${tag}`;
2043
2151
 
2044
2152
  let line = ` ${sel}${cur} ${name} ${type} ${status} Ses ${sesCell}`;
2045
2153
  if (showBoth) line += ` Wk ${wkCell}`;
2046
2154
  line += note;
2047
2155
  // Same recent-load columns as OAuth (providers track inFlight + load events),
2048
2156
  // plus a compact Last <status> <ms> — the one signal that matters for a
2049
- // rarely-hit fallback (loadText reads 0r when idle).
2050
- line += ` ${dim(loadText(this._accountLoad(a)))}`;
2051
- if (a.lastStatus) line += ` ${dim('Last')} ${statusColor(a.lastStatus)} ${dim(formatMs(a.lastResponseMs))}`;
2157
+ // rarely-hit fallback. loadText renders empty when fully idle.
2158
+ const act = loadText(this._accountLoad(a));
2159
+ if (act) line += ` ${dim(act)}`;
2160
+ // "Last 200 6.4s" next to a live "17/h · 6.4s" said the same thing twice. Keep it
2161
+ // for the two cases where it is the ONLY thing that speaks: a non-2xx last result
2162
+ // (always worth seeing), or an idle row whose activity cell is blank.
2163
+ const lastOk = a.lastStatus >= 200 && a.lastStatus < 300;
2164
+ if (a.lastStatus && (!lastOk || !act)) {
2165
+ line += ` ${dim('Last')} ${statusColor(a.lastStatus)} ${dim(formatMs(a.lastResponseMs))}`;
2166
+ }
2052
2167
  // Header-derived generic rate-limit (distinct from the monitor-endpoint quota),
2053
2168
  // if the provider upstream returns x-ratelimit-* headers — only when present.
2054
2169
  if (q.genericLimit != null && q.genericRemaining != null) {