maxpool 1.5.68 → 1.5.70

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.5.68",
3
+ "version": "1.5.70",
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",
@@ -41,6 +41,7 @@
41
41
  "node": ">=20.3.0"
42
42
  },
43
43
  "devDependencies": {
44
+ "eslint": "^9.39.5",
44
45
  "git-cliff": "2.13.1"
45
46
  }
46
47
  }
@@ -1165,6 +1165,10 @@ export class AccountManager {
1165
1165
  if (used >= this.switchThreshold) return true;
1166
1166
  }
1167
1167
 
1168
+ // Provider (z.ai/Kimi) session quota — the 5h token window. Without this a
1169
+ // provider at 95% of its 5h cap reads as fully available.
1170
+ if (q.providerSes != null && q.providerSes >= this.switchThreshold) return true;
1171
+
1168
1172
  return false;
1169
1173
  }
1170
1174
 
@@ -2110,6 +2114,15 @@ export class AccountManager {
2110
2114
  if (q.requestsLimit != null && q.requestsRemaining != null && q.requestsLimit > 0) {
2111
2115
  scarcity = Math.max(scarcity, 1 - q.requestsRemaining / q.requestsLimit);
2112
2116
  }
2117
+ // Provider (z.ai/Kimi) quota — same scoring axis as Anthropic's windows. Without
2118
+ // this, a GLM account at 80% of its 5h session scored the same as one at 5%,
2119
+ // so the scheduler dogpiled the near-cap account until z.ai 429'd it.
2120
+ if (q.providerSes != null) {
2121
+ scarcity = Math.max(scarcity, this._windowScarcity(q.providerSes, q.providerSesReset, FIVE_HOUR_MS, now));
2122
+ }
2123
+ if (q.providerWk != null) {
2124
+ scarcity = Math.max(scarcity, this._windowScarcity(q.providerWk, q.providerWkReset, WEEK_MS, now));
2125
+ }
2113
2126
  return scarcity;
2114
2127
  }
2115
2128
 
@@ -2190,6 +2203,22 @@ export class AccountManager {
2190
2203
  const q = account.quota;
2191
2204
  this._clearExpiredQuotas(account);
2192
2205
  if (this._isAccountWideRejected(account)) return 'exhausted';
2206
+
2207
+ // Provider accounts (GLM/Kimi) carry their quota in providerSes/providerWk, NOT
2208
+ // unified7d — those fields are Anthropic-only. Without this, a provider at 83%
2209
+ // weekly (Kimi measured 2026-08-08) reads as 'unknown' = healthy, so the scheduler
2210
+ // keeps piling onto it instead of spreading load. The same thresholds apply.
2211
+ if (account.type === 'provider') {
2212
+ const sesUsed = q.providerSes != null ? clamp01(q.providerSes) : null;
2213
+ const wkUsed = q.providerWk != null ? clamp01(q.providerWk) : null;
2214
+ const used = Math.max(sesUsed ?? 0, wkUsed ?? 0);
2215
+ if (used >= this.scheduler.weeklyExhaustedThreshold) return 'exhausted';
2216
+ if (used >= this.scheduler.weeklyCriticalThreshold) return 'critical';
2217
+ if (used >= this.scheduler.weeklyReserveThreshold) return 'reserve';
2218
+ if (used >= this.scheduler.weeklySoftThreshold) return 'soft';
2219
+ return 'normal';
2220
+ }
2221
+
2193
2222
  if (q.unified7d == null) return 'unknown';
2194
2223
 
2195
2224
  const used = clamp01(q.unified7d);
@@ -2212,6 +2241,8 @@ export class AccountManager {
2212
2241
  }
2213
2242
 
2214
2243
  _weeklyPaceState(account) {
2244
+ // Provider quota lives in separate fields — see _weeklyRawState.
2245
+ if (account.type === 'provider') return this._weeklyRawState(account);
2215
2246
  if (account.quota.unified7d == null) return 'unknown';
2216
2247
  const effective = this._effectiveWeeklyUsage(account);
2217
2248
  if (effective >= this.scheduler.weeklyExhaustedThreshold) return 'exhausted';
@@ -2970,7 +3001,10 @@ export class AccountManager {
2970
3001
  // Remove existing config-sourced providers that are no longer in the config
2971
3002
  // (handles a config edit that removes an entry).
2972
3003
  const wantedNames = new Set(entries.map(e => e.name).filter(Boolean));
2973
- for (const a of this.accounts) {
3004
+ // Iterate a COPY: removeAccount splices this.accounts, and splicing the array
3005
+ // being iterated makes the loop skip the element after each removal — so
3006
+ // dropping 2 of 3 config providers left one phantom behind.
3007
+ for (const a of [...this.accounts]) {
2974
3008
  if (a.configSourced && !wantedNames.has(a.name)) {
2975
3009
  const idx = this.accounts.indexOf(a);
2976
3010
  if (idx >= 0 && this.accounts[idx].inFlight === 0) this.removeAccount(idx);
package/src/index.js CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  RELOAD_SWAPPED, RELOAD_ROLLED_BACK,
38
38
  MSG_LISTEN, MSG_RELEASE, MSG_TAKEOVER, MSG_PROBE_READY,
39
39
  MSG_RELOAD_REQUEST, MSG_READY, MSG_FAILED, MSG_RELEASED, MSG_PRIMARY, MSG_ROLLED_BACK,
40
+ MSG_TTY_REASSERT,
40
41
  } from './reload-protocol.js';
41
42
 
42
43
  const args = process.argv.slice(2);
@@ -370,7 +371,14 @@ async function supervisorCommand() {
370
371
  // sent PRIMARY during the baton; re-assert the master-accept close.
371
372
  monitorAsActive(newWorker);
372
373
  closeMasterAccept().catch(() => {});
373
- reapOldWorker(oldWorker);
374
+ // Tell the new primary to re-assert raw mode ONCE the old worker has actually
375
+ // exited — its exit resets the shared terminal out from under the new worker
376
+ // (see MSG_TTY_REASSERT). Driven off the real exit event, never a timer: with
377
+ // in-flight streams the old worker can linger, so a timed guess would leave the
378
+ // terminal working for a while and then die mid-session.
379
+ reapOldWorker(oldWorker, () => {
380
+ try { newWorker.send({ type: MSG_TTY_REASSERT }); } catch { /* worker gone */ }
381
+ });
374
382
  return;
375
383
  }
376
384
 
@@ -460,14 +468,21 @@ async function supervisorCommand() {
460
468
 
461
469
  // Drain + reap a released worker. The worker exits itself once its bounded
462
470
  // in-flight finishes; the supervisor SIGKILLs it if it outlives the drain cap.
463
- function reapOldWorker(worker) {
471
+ function reapOldWorker(worker, onExited = () => {}) {
464
472
  if (!worker) return;
465
473
  // +30s grace over the worker's own RELOAD_DRAIN_MS hardCap so its clean exit(0)
466
474
  // (fired while its streaming socket keeps the loop alive) always wins the race —
467
475
  // SIGKILL only ever reaps a genuinely wedged worker, never cuts a live stream.
468
476
  const cap = RELOAD_DRAIN_MS + 30_000;
469
477
  let reaped = false;
470
- const finish = () => { if (reaped) return; reaped = true; clearTimeout(timer); };
478
+ // `onExited` fires on BOTH paths (clean exit and SIGKILL): the terminal is clobbered
479
+ // by the exit itself, however that exit came about.
480
+ const finish = () => {
481
+ if (reaped) return;
482
+ reaped = true;
483
+ clearTimeout(timer);
484
+ try { onExited(); } catch { /* never let the callback break reaping */ }
485
+ };
471
486
  const timer = setTimeout(() => {
472
487
  if (reaped) return;
473
488
  console.error(`[Maxpool] Old worker outlived ${Math.ceil(cap / 1000)}s reload-drain cap; SIGKILL.`);
@@ -613,9 +628,7 @@ async function serverWorkerCommand() {
613
628
  const { resolveSecrets } = await import('./secret-resolver.js');
614
629
  // Split: GCP-sourced (secretName) vs direct (apiKey). Both produce a token
615
630
  // the same way — the resolution path is the only difference.
616
- const gcpEntries = config.providers.filter(p => p.secretName);
617
- const directEntries = config.providers.filter(p => p.apiKey && !p.secretName);
618
- const secretNames = gcpEntries.map(p => p.secretName);
631
+ const secretNames = config.providers.filter(p => p.secretName).map(p => p.secretName);
619
632
  const resolved = await resolveSecrets(secretNames);
620
633
  const entries = config.providers.map(p => ({
621
634
  ...p,
@@ -1389,6 +1402,29 @@ async function serverWorkerCommand() {
1389
1402
  // sends MSG_PRIMARY (the baton waits on it).
1390
1403
  await listenOnHandle(handle);
1391
1404
  await becomePrimary({ viaTakeover: true });
1405
+ } else if (msg?.type === MSG_TTY_REASSERT) {
1406
+ // The old worker has now EXITED, and its exit reset the shared terminal out
1407
+ // of raw mode (libuv's uv_tty_reset_mode on process exit). Reclaim it.
1408
+ //
1409
+ // The toggle OFF then ON is load-bearing: Node short-circuits setRawMode(true)
1410
+ // when it believes isRaw is already true — and it does believe that, because
1411
+ // the clobber happened in ANOTHER process and left our flag untouched. A bare
1412
+ // setRawMode(true) here is a silent no-op (verified on a real pty 2026-08-07).
1413
+ if (process.stdin.isTTY) {
1414
+ try {
1415
+ process.stdin.setRawMode(false);
1416
+ process.stdin.setRawMode(true);
1417
+ process.stdin.resume();
1418
+ } catch { /* terminal gone — nothing to reclaim */ }
1419
+ }
1420
+ // Re-assert the screen state too: the dying worker's alt-screen exit and
1421
+ // cursor-show can land after ours.
1422
+ if (tui?.running && process.stdout.isTTY) {
1423
+ try {
1424
+ process.stdout.write('\x1b[?1049h\x1b[?25l');
1425
+ tui.render();
1426
+ } catch { /* ignore */ }
1427
+ }
1392
1428
  } else if (msg?.type === MSG_ROLLED_BACK) {
1393
1429
  // Why the swap failed decides whether a cold restart is safe (see the watchdog).
1394
1430
  lastRollbackReason = msg.reason || null;
package/src/oauth.js CHANGED
@@ -289,13 +289,22 @@ export async function fetchUsage(accessToken) {
289
289
  // (account.upstream is the Anthropic-compat endpoint). Zero-spend read.
290
290
  const ZAI_QUOTA_URL = 'https://api.z.ai/api/monitor/usage/quota/limit';
291
291
 
292
- /** Classify one z.ai `limits[]` entry into a Ses (5h) or Wk (weekly) TOKEN window.
293
- * Only `TOKENS_LIMIT` maps to the quota bars; `TIME_LIMIT` is a tool-call cap
294
- * (web-search/reader counts) and is intentionally ignored. `unit` is z.ai's
295
- * window enum (3 = 5-hour session, 6 = weekly); we fall back to reset-distance
296
- * when the code is unfamiliar so a new plan tier still classifies sanely. */
292
+ // z.ai reports a plan's consumption cap under DIFFERENT type names depending on the
293
+ // plan: `TOKENS_LIMIT` on older coding plans, `CREDIT_LIMIT` on newer ones. Both are
294
+ // the same thing for our purposes — "how much of your allowance is used" — and both
295
+ // carry the same `percentage` + `nextResetTime` + `unit` fields. Accepting only
296
+ // TOKENS_LIMIT left every CREDIT_LIMIT account with no quota reading at all, so the
297
+ // TUI showed "probing" forever even though the poll succeeded (measured 2026-08-07 on
298
+ // a newly-provisioned `max` plan returning CREDIT_LIMIT unit 3 + unit 6).
299
+ const ZAI_CONSUMPTION_TYPES = new Set(['TOKENS_LIMIT', 'CREDIT_LIMIT']);
300
+
301
+ /** Classify one z.ai `limits[]` entry into a Ses (5h) or Wk (weekly) consumption
302
+ * window. `TIME_LIMIT` is a tool-call cap (web-search/reader counts) and is
303
+ * intentionally ignored. `unit` is z.ai's window enum (3 = 5-hour session,
304
+ * 6 = weekly); we fall back to reset-distance when the code is unfamiliar so a new
305
+ * plan tier still classifies sanely. */
297
306
  export function classifyZaiLimit(l, now = Date.now()) {
298
- if (!l || l.type !== 'TOKENS_LIMIT') return null;
307
+ if (!l || !ZAI_CONSUMPTION_TYPES.has(l.type)) return null;
299
308
  const reset = Number(l.nextResetTime);
300
309
  const resetAt = Number.isFinite(reset) && reset > 0 ? reset : null;
301
310
  const pct = typeof l.percentage === 'number' ? l.percentage : parseFloat(l.percentage);
@@ -303,7 +312,13 @@ export function classifyZaiLimit(l, now = Date.now()) {
303
312
  let bucket;
304
313
  if (l.unit === 3) bucket = 'ses';
305
314
  else if (l.unit === 6) bucket = 'wk';
306
- else if (resetAt) bucket = (resetAt - now) <= 12 * 60 * 60 * 1000 ? 'ses' : 'wk';
315
+ else if (l.type === 'CREDIT_LIMIT') {
316
+ // The reset-distance fallback is NOT extended to CREDIT_LIMIT. `unit: 5` is what
317
+ // z.ai uses for the monthly TOOL-call cap; if it ever renames that cap to
318
+ // CREDIT_LIMIT the fallback would render a web-search allowance as the weekly
319
+ // model-quota bar. Only the two window enums we have actually observed count.
320
+ return null;
321
+ } else if (resetAt) bucket = (resetAt - now) <= 12 * 60 * 60 * 1000 ? 'ses' : 'wk';
307
322
  else bucket = 'ses';
308
323
  return { bucket, utilization, resetAt };
309
324
  }
@@ -406,11 +421,22 @@ export async function fetchProviderUsage(account) {
406
421
  }
407
422
  const now = Date.now();
408
423
  let ses = null, wk = null;
424
+ // Two entries can land in the SAME bucket (e.g. a plan reporting both a
425
+ // TOKENS_LIMIT and a CREDIT_LIMIT for its 5h window). Keep the HIGHER utilization:
426
+ // last-wins was silently optimistic — 95% followed by 4% reported 4%, i.e. an
427
+ // account at its cap rendered as nearly empty.
428
+ const keepWorse = (cur, next) => {
429
+ if (!cur) return next;
430
+ if (next.utilization == null) return cur;
431
+ if (cur.utilization == null) return next;
432
+ return next.utilization > cur.utilization ? next : cur;
433
+ };
409
434
  for (const l of (Array.isArray(data.data.limits) ? data.data.limits : [])) {
410
435
  const c = classifyZaiLimit(l, now);
411
436
  if (!c) continue;
412
- if (c.bucket === 'ses') ses = { utilization: c.utilization, resetAt: c.resetAt };
413
- else if (c.bucket === 'wk') wk = { utilization: c.utilization, resetAt: c.resetAt };
437
+ const entry = { utilization: c.utilization, resetAt: c.resetAt };
438
+ if (c.bucket === 'ses') ses = keepWorse(ses, entry);
439
+ else if (c.bucket === 'wk') wk = keepWorse(wk, entry);
414
440
  }
415
441
  return { ses, wk, level: data.data.level || null, source: 'zai' };
416
442
  } catch (err) {
package/src/prober.js CHANGED
@@ -176,9 +176,18 @@ export class Prober {
176
176
  await this.am.ensureTokenFresh(account.index);
177
177
  let usage = await this._withTimeout(this.probeFn(account.credential));
178
178
  if (usage?.status === 401) {
179
- // Token rejected — force a refresh and retry once.
180
- await this.am.ensureTokenFresh(account.index, true);
181
- usage = await this._withTimeout(this.probeFn(account.credential));
179
+ // Token rejected — force a refresh and retry once. NOT for a DISABLED account:
180
+ // `force` deliberately overrides the no-rotate guard in ensureTokenFresh (it
181
+ // exists for user-initiated re-auth), so forcing here would rotate the very
182
+ // single-use token that guard protects. Worse, it is GUARANTEED to fire for a
183
+ // disabled account: blocking the proactive refresh means the token always
184
+ // expires, which always 401s — moving the rotation from the controlled
185
+ // pre-expiry path into this error path. Accept the 401 and record it; the
186
+ // account refreshes normally the moment it is re-enabled.
187
+ if (account.enabled !== false) {
188
+ await this.am.ensureTokenFresh(account.index, true);
189
+ usage = await this._withTimeout(this.probeFn(account.credential));
190
+ }
182
191
  }
183
192
  if (usage == null) { // timed out
184
193
  this.am.recordProbeError?.(account.index, 'probe timed out', null);
@@ -126,3 +126,11 @@ export async function runReloadBaton({
126
126
  log('reload: cutover complete; new worker is primary');
127
127
  return RELOAD_SWAPPED;
128
128
  }
129
+
130
+ // Sent to the NEW primary worker once the OLD worker has actually exited. Node calls
131
+ // libuv's uv_tty_reset_mode() on process exit, which restores the termios the dying
132
+ // process saved when IT first enabled raw mode — clobbering the live worker's terminal
133
+ // back to canonical (cooked) mode. `process.stdin.isRaw` still reads true, so nothing
134
+ // in-process can detect it; the TUI keeps rendering while the kernel line-buffers, so
135
+ // every keystroke (r / q / p) is swallowed. Measured on a real pty 2026-08-07.
136
+ export const MSG_TTY_REASSERT = 'tty-reassert';
package/src/tui.js CHANGED
@@ -599,7 +599,7 @@ export class TUI {
599
599
 
600
600
  _keyProviders(k) {
601
601
  if (k === 'a') {
602
- this._providerAddStep('name');
602
+ this._providerAddStep('type');
603
603
  } else if (k === 'd') {
604
604
  this._startProviderSelection('delete');
605
605
  } else if (k === 't') {
@@ -609,35 +609,22 @@ export class TUI {
609
609
  }
610
610
  }
611
611
 
612
- // Multi-step input for adding a provider. Steps: name → type → secret name.
613
- _providerAddStep(step, prev) {
614
- if (step === 'name') {
612
+ // Multi-step input for adding a provider. Steps: type → secret/key → name.
613
+ // Name LAST so it can be pre-filled from the secret (RESTRICTED_AL_MAXPOOL_ZAI →
614
+ // "glm al") instead of asking the user to invent one before they've said which
615
+ // account it is.
616
+ _providerAddStep(step, prev = {}) {
617
+ if (step === 'type') {
615
618
  this.mode = 'input';
616
- this.inputPrompt = 'Provider display name (e.g. glm-ahmed)';
617
- this.inputBuf = '';
618
- this.inputSensitive = false;
619
- this.inputCb = value => {
620
- const name = String(value || '').trim();
621
- if (!name) { this.mode = 'providers'; return; }
622
- if (this.am.accounts.some(a => a.name === name)) {
623
- this._addLog(`Account "${name}" already exists`); this.mode = 'providers'; return;
624
- }
625
- this._providerAddStep('type', { name });
626
- };
627
- } else if (step === 'type') {
628
- this.mode = 'input';
629
- this.inputPrompt = `Type for ${prev.name} (zai or kimi)`;
619
+ this.inputPrompt = 'Provider type (zai or kimi)';
630
620
  this.inputBuf = 'zai';
631
621
  this.inputSensitive = false;
632
622
  this.inputCb = value => {
633
623
  const provider = String(value || '').trim().toLowerCase();
634
624
  if (provider !== 'zai' && provider !== 'kimi') { this._addLog('Type must be zai or kimi'); this.mode = 'providers'; return; }
635
- // Suggest a name derived from the provider type
636
- const suggested = provider === 'kimi' ? 'kimi' : 'glm';
637
- this._providerAddStep('secret', { ...prev, provider, suggestedName: suggested });
625
+ this._providerAddStep('secret', { ...prev, provider });
638
626
  };
639
627
  } else if (step === 'secret') {
640
- const suggested = TUI.deriveProviderName(prev.provider, '');
641
628
  this.mode = 'input';
642
629
  this.inputPrompt = `${prev.name}: GCP secret name OR paste API key directly`;
643
630
  this.inputBuf = '';
@@ -648,19 +635,29 @@ export class TUI {
648
635
  // Heuristic: a GCP secret name is uppercase/dashes/underscores and short.
649
636
  // An API key is long and contains dots/mixed-case/alphanumeric.
650
637
  const looksLikeSecretName = /^[A-Z][A-Z0-9_-]{2,60}$/.test(input) && !input.includes('.');
651
- if (looksLikeSecretName) {
652
- await this._doAddProvider({ ...prev, secretName: input });
653
- } else {
654
- // Direct key paste — store in config (0600, same protection as OAuth tokens).
655
- await this._doAddProvider({ ...prev, apiKey: input });
638
+ const keyed = looksLikeSecretName ? { secretName: input } : { apiKey: input };
639
+ // The name step is LAST so it can offer a default derived from the secret —
640
+ // `RESTRICTED_AL_MAXPOOL_ZAI` → `glm al`. Pre-filled and editable: press Enter
641
+ // to accept, or type over it.
642
+ this._providerAddStep('name', { ...prev, ...keyed });
643
+ };
644
+ } else if (step === 'name') {
645
+ this.mode = 'input';
646
+ this.inputPrompt = 'Name for this provider (Enter to accept)';
647
+ this.inputBuf = TUI.deriveProviderName(prev.provider, prev.secretName || '');
648
+ this.inputSensitive = false;
649
+ this.inputCb = async value => {
650
+ const name = String(value || '').trim() || TUI.deriveProviderName(prev.provider, prev.secretName || '');
651
+ if (this.am.accounts.some(a => a.name === name)) {
652
+ this._addLog(`Account "${name}" already exists`); this.mode = 'providers'; return;
656
653
  }
654
+ await this._doAddProvider({ ...prev, name });
657
655
  };
658
656
  }
659
657
  }
660
658
 
661
659
  async _doAddProvider({ name, provider, secretName, apiKey }) {
662
660
  this.mode = 'providers';
663
- const isDirect = !secretName && apiKey;
664
661
  if (secretName) this._addLog(`Resolving secret "${secretName}" from GCP…`);
665
662
  else this._addLog(`Adding "${name}" with direct API key…`);
666
663
  this.render();
@@ -709,7 +706,7 @@ export class TUI {
709
706
  this.mode = 'select';
710
707
  }
711
708
 
712
- _renderProviders(buf, width) {
709
+ _renderProviders(buf, _width) {
713
710
  const providers = this.am.accounts.filter(a => a.type === 'provider');
714
711
  buf.push(`${bold('Providers (GLM / Kimi)')} ${dim('— managed via GCP Secret Manager')}`);
715
712
  buf.push('');