maxpool 1.5.37 → 1.5.39

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.37",
3
+ "version": "1.5.39",
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",
@@ -15,7 +15,8 @@
15
15
  "start": "node src/index.js",
16
16
  "test": "bash scripts/run-tests.sh",
17
17
  "lint": "eslint src/ test/",
18
- "release": "bash scripts/release.sh"
18
+ "release": "bash scripts/release.sh",
19
+ "version": "bash scripts/update-changelog.sh"
19
20
  },
20
21
  "keywords": [
21
22
  "claude",
@@ -38,5 +39,8 @@
38
39
  },
39
40
  "engines": {
40
41
  "node": ">=20.3.0"
42
+ },
43
+ "devDependencies": {
44
+ "git-cliff": "2.13.1"
41
45
  }
42
46
  }
@@ -15,6 +15,15 @@ const BOUNDED_REPOLL_HOLD_MS = 60_000;
15
15
  // session can never ping-pong between two similarly-loaded accounts.
16
16
  const REBALANCE_SCORE_MARGIN = 0.5; // candidate score must be ≤ 50% of the bound account's
17
17
  const REBALANCE_MIN_ABS_GAP = 0.5; // …with a small absolute floor so near-zero scores don't micro-churn
18
+ // Warmup-pull: a freshly-ADDED account (mid-session, no reload) would otherwise idle
19
+ // until a reload clears bindings, because bound sessions only leave a HOT account. So
20
+ // for a bounded window pull migration-safe sessions onto it. Keyed on the account's
21
+ // own onboarding state (addedAt + completedRequests), NOT relative load — so it
22
+ // PROVABLY TERMINATES (once it has served WARMUP_REQUESTS, or WARMUP_MS elapses, it
23
+ // stops warming and never fires again) and cannot oscillate for any #sessions/#accounts
24
+ // ratio, unlike a share/concurrency-gap rebalance (which flaps on the lagging load signal).
25
+ const WARMUP_MS = 5 * 60 * 1000; // a just-added account stays "warming" this long…
26
+ const WARMUP_REQUESTS = 5; // …or until it has served this many requests, whichever comes first
18
27
  // Weekly-pressure tiers, healthiest first. A fresh (unknown) account is the best
19
28
  // migration target; migration requires the candidate be a STRICTLY healthier tier.
20
29
  const WEEKLY_TIER = { unknown: 0, normal: 0, soft: 1, reserve: 2, critical: 3, exhausted: 4 };
@@ -1138,6 +1147,54 @@ export class AccountManager {
1138
1147
  * All gates must hold: thinking-safe + not mid queue-admission + bound is hot +
1139
1148
  * a genuinely-healthy alternative that is BOTH much cheaper AND a strictly
1140
1149
  * healthier weekly tier (so concurrency jitter alone can never trigger a move). */
1150
+ /** Is this account still in its post-add onboarding window? A freshly-ADDED
1151
+ * account (addedAt set only by addAccount, never on boot) that has served fewer
1152
+ * than WARMUP_REQUESTS within WARMUP_MS. Providers are never "warming" targets. */
1153
+ _isWarming(account, now = Date.now()) {
1154
+ if (!account || account.type === 'provider') return false;
1155
+ if (account.addedAt == null) return false; // boot/config account → never warming
1156
+ if (account.completedRequests >= WARMUP_REQUESTS) return false; // onboarded → terminates the pull
1157
+ return (now - account.addedAt) < WARMUP_MS;
1158
+ }
1159
+
1160
+ /** Warmup-pull target: the best (lowest-score) healthy, migration-eligible,
1161
+ * still-WARMING NON-provider account to onboard a freshly-added account WITHOUT a
1162
+ * reload, or null. Returned DIRECTLY (not via the score-loop fallthrough) so the
1163
+ * destination is GUARANTEED non-provider even under 'always' cross-provider policy
1164
+ * — a signed-thinking session can never be shuttled onto a provider here (which
1165
+ * the shared candidate loop, keyed only on _matchesRequest, would not prevent).
1166
+ * Fires only when the bound account is itself established (not warming) AND is
1167
+ * actually carrying recent load — relieving a real carrier onto the fresh account,
1168
+ * never churning fresh↔fresh or re-homing an idle session. */
1169
+ _warmupPullTarget(bound, profile, excludedIndexes, requestInfo, now = Date.now()) {
1170
+ // Cheapest early-out first: the overwhelmingly common steady state has NO warming
1171
+ // account, so bail before the migration-safety / load / fleet-scoring work. This
1172
+ // runs on every bound request's selection — keep the no-warming path near-free.
1173
+ if (!this.accounts.some(a => this._isWarming(a, now))) return null;
1174
+ if (!this._migrationSafeForRequest(requestInfo)) return null;
1175
+ if (requestInfo.queueTicket || requestInfo.queueAdmitted) return null;
1176
+ if (this._isWarming(bound, now)) return null;
1177
+ if (this._loadSummary(bound, this.scheduler.spreadWindowMs, now).weight <= 0) return null;
1178
+ const ctx = this._scoringContext();
1179
+ let best = null;
1180
+ let bestScore = Infinity;
1181
+ for (const account of this.accounts) {
1182
+ if (account.index === bound.index) continue;
1183
+ if (account.type === 'provider') continue; // GUARANTEED non-provider destination
1184
+ if (excludedIndexes.has(account.index)) continue;
1185
+ if (!this._isWarming(account, now)) continue;
1186
+ if (!this._matchesRequest(account, profile, requestInfo)) continue;
1187
+ // Genuinely-healthy target only (same bar as the hot-rebalance candidate scan).
1188
+ if (!this._isAvailable(account, { allowWeeklyReserve: false, allowWeeklyCritical: false, model: requestInfo.model })) continue;
1189
+ const score = this._scoreAccount(account, requestInfo, ctx);
1190
+ if (score < bestScore) {
1191
+ bestScore = score;
1192
+ best = account;
1193
+ }
1194
+ }
1195
+ return best;
1196
+ }
1197
+
1141
1198
  _shouldRebalanceBoundSession(bound, profile, excludedIndexes, requestInfo, scoringCtx) {
1142
1199
  if (!this._migrationSafeForRequest(requestInfo)) return false;
1143
1200
  if (requestInfo.queueTicket || requestInfo.queueAdmitted) return false;
@@ -1350,10 +1407,24 @@ export class AccountManager {
1350
1407
  }
1351
1408
  }
1352
1409
  const bound = this._boundAccount(requestInfo.sessionKey, profile, excludedIndexes, requestInfo);
1353
- if (bound
1354
- && !this._hasHigherPriorityAvailable(bound, profile, excludedIndexes, requestInfo)
1355
- && !this._shouldRebalanceBoundSession(bound, profile, excludedIndexes, requestInfo, scoringCtx)) {
1356
- return bound;
1410
+ if (bound) {
1411
+ // Warmup-pull: onboard a freshly-ADDED account (added mid-session, no reload)
1412
+ // by DIRECTLY re-homing this migration-safe session onto the warming account,
1413
+ // before the sticky "stay bound" return. Directed (not via the score loop) so
1414
+ // the destination is guaranteed non-provider. Bounded + self-terminating via
1415
+ // _isWarming; each session re-homes at most once → no flap. Skipped for a
1416
+ // preferred/pinned request (handled above) and any non-migration-safe request.
1417
+ const warmupTarget = this._warmupPullTarget(
1418
+ bound, profile, excludedIndexes, requestInfo, scoringCtx?.now,
1419
+ );
1420
+ if (warmupTarget) {
1421
+ this.currentIndex = warmupTarget.index;
1422
+ return warmupTarget; // _bindSession re-homes the session on acquire
1423
+ }
1424
+ if (!this._hasHigherPriorityAvailable(bound, profile, excludedIndexes, requestInfo)
1425
+ && !this._shouldRebalanceBoundSession(bound, profile, excludedIndexes, requestInfo, scoringCtx)) {
1426
+ return bound;
1427
+ }
1357
1428
  }
1358
1429
  // Else fall through to the candidate score loop, which re-homes the session
1359
1430
  // onto the best healthy account via _bindSession on acquire.
@@ -2488,6 +2559,10 @@ export class AccountManager {
2488
2559
  provisionalRateLimitFingerprint: null,
2489
2560
  recoveredAt: null,
2490
2561
  lastQuotaLogKey: null,
2562
+ // Onboarding clock for the warmup-pull — set ONLY here (mid-session add),
2563
+ // never on the boot/config construction path, so an established fleet account
2564
+ // is never "warming". Lets a just-added account draw load without a reload.
2565
+ addedAt: Date.now(),
2491
2566
  });
2492
2567
  return index;
2493
2568
  }
package/src/index.js CHANGED
@@ -702,6 +702,7 @@ async function serverWorkerCommand() {
702
702
  let tui = null;
703
703
  let server = null;
704
704
  let syncTimer = null;
705
+ let updateTimer = null;
705
706
  let draining = false;
706
707
  let restartController = null;
707
708
  // Set once this worker hands the terminal to a new worker during a seamless TUI
@@ -805,6 +806,7 @@ async function serverWorkerCommand() {
805
806
  if (draining) return;
806
807
  draining = true;
807
808
  if (syncTimer) clearInterval(syncTimer);
809
+ if (updateTimer) clearInterval(updateTimer);
808
810
  if (tui?.running) { tui.stop(); }
809
811
  console.log('\n[Maxpool] Restarting server now; queued requests will reconnect automatically.');
810
812
  server.closeAllConnections?.();
@@ -893,6 +895,7 @@ async function serverWorkerCommand() {
893
895
 
894
896
  draining = true;
895
897
  if (syncTimer) clearInterval(syncTimer);
898
+ if (updateTimer) clearInterval(updateTimer);
896
899
  if (tui?.running) tui.stop();
897
900
  // Best-effort final flush + settle writes (fire-and-forget; the drain timeout
898
901
  // below bounds total quit time, and write barriers protect the on-disk state).
@@ -1015,6 +1018,21 @@ async function serverWorkerCommand() {
1015
1018
  }, syncIntervalMs);
1016
1019
  syncTimer.unref();
1017
1020
 
1021
+ // Periodic update re-check. The startup probe (in becomePrimary) is one-shot, but a
1022
+ // long-lived session (days) must still be reminded of versions published AFTER it
1023
+ // started. This timer is UNCONDITIONAL (NOT reload-guarded) so it survives a seamless
1024
+ // `r`-reload — otherwise taking an update (which the banner tells users to do via `r`)
1025
+ // would permanently disable all future update detection. It only refreshes
1026
+ // versionInfo (the persistent TUI banner is the reminder, so no repeated log spam);
1027
+ // autoUpdate progress lines still surface. unref so it never blocks a clean exit.
1028
+ const updateIntervalMs = Math.max(60_000, Number(process.env.MAXPOOL_UPDATE_CHECK_INTERVAL_MS) || 6 * 60 * 60 * 1000);
1029
+ updateTimer = setInterval(() => {
1030
+ if (config?.updateCheck === false) return;
1031
+ const autoNotify = msg => { if (config?.autoUpdate) (tui?._addLog ? tui._addLog(msg) : console.log(`[Maxpool] ${msg}`)); };
1032
+ maybeCheckForUpdate(config, autoNotify, info => { accountManager.versionInfo = info; }).catch(() => {});
1033
+ }, updateIntervalMs);
1034
+ updateTimer.unref();
1035
+
1018
1036
  // Become the live primary: start serving UI/logs, take the writer lease, run
1019
1037
  // the update check. `viaTakeover` true means we acquired the socket through the
1020
1038
  // baton (a reload) — freeze the update check so a reload doesn't re-probe npm
@@ -1092,6 +1110,7 @@ async function serverWorkerCommand() {
1092
1110
  // proceeding (not a rollback). Disarm the rollback self-heal.
1093
1111
  if (reloadWatchdog) { clearTimeout(reloadWatchdog); reloadWatchdog = null; }
1094
1112
  if (syncTimer) clearInterval(syncTimer);
1113
+ if (updateTimer) clearInterval(updateTimer);
1095
1114
  // Stop accepting NEW connections; KEEP in-flight requests alive.
1096
1115
  server.maxpoolBeginDrain?.();
1097
1116
  server.close(() => {});
package/src/server.js CHANGED
@@ -56,6 +56,17 @@ const DEFAULT_QUEUE = {
56
56
  // Non-streaming requests have no SSE heartbeat to keep them alive, so a long
57
57
  // hold would die on the client timeout anyway. Cap their wait conservatively.
58
58
  nonStreamMaxWaitMs: 5 * 60 * 1000,
59
+ // Proactive streaming keep-alive: a STREAMING request gets ZERO client bytes
60
+ // while maxpool selects a route, cycles failover, and waits for the upstream's
61
+ // first byte (up to UPSTREAM_TTFB_MS=120s) — the queue heartbeat only starts
62
+ // once a request is QUEUED. If that client-silent window exceeds the client's
63
+ // own idle timeout (Claude Code aborts "Stream idle timeout - no chunks
64
+ // received", observed as low as ~23s), the client gives up on a request maxpool
65
+ // is still patiently serving. If the upstream hasn't delivered bytes within this
66
+ // grace, commit the SSE stream + start the heartbeat so the client never idles
67
+ // out. Kept above a normal fast TTFB (1-5s → common case never early-commits,
68
+ // behavior unchanged) and well below the client idle floor. Env-overridable.
69
+ streamForwardGraceMs: Math.max(1000, Number(process.env.MAXPOOL_STREAM_FORWARD_GRACE_MS) || 10000),
59
70
  // count_tokens is cheap non-streaming metadata — cap its queue wait VERY low so it
60
71
  // fast-fails with a retryable 429 instead of hanging silently past the client's idle
61
72
  // window. Kept well under any plausible client idle timeout (observed errors as low
@@ -492,6 +503,28 @@ async function forwardRequest(
492
503
  UPSTREAM_TTFB_MS,
493
504
  );
494
505
  ttfbTimer.unref?.();
506
+ // Proactive streaming keep-alive. UPSTREAM_TTFB_MS (120s) is FAR above the
507
+ // client's own idle timeout (~23-60s), and the queue heartbeat only starts once
508
+ // a request is QUEUED — so a streaming request whose selected upstream is slow to
509
+ // first byte, or that burns the client's timeout cycling failover, sends the
510
+ // client ZERO bytes and is aborted ("Stream idle timeout - no chunks received")
511
+ // on a request maxpool is still serving. If bytes haven't arrived within the
512
+ // grace, commit the SSE stream + heartbeat so the client stays alive. The
513
+ // deadline is anchored ONCE per request (??=) so it also bounds CUMULATIVE
514
+ // failover time across re-forwards, not each attempt independently.
515
+ const streamForwardGraceMs = queueConfig?.streamForwardGraceMs == null
516
+ ? 10000
517
+ : Math.max(0, Number(queueConfig.streamForwardGraceMs) || 0);
518
+ let streamGraceTimer = null;
519
+ if (requestInfo.stream && !res.headersSent) {
520
+ requestInfo.streamGraceDeadline ??= Date.now() + streamForwardGraceMs;
521
+ const graceDelay = Math.max(0, requestInfo.streamGraceDeadline - Date.now());
522
+ streamGraceTimer = setTimeout(
523
+ () => commitStreamGraceHeartbeat(res, requestInfo, queueConfig, accountManager),
524
+ graceDelay,
525
+ );
526
+ streamGraceTimer.unref?.();
527
+ }
495
528
  try {
496
529
  let upstreamRes;
497
530
  try {
@@ -504,6 +537,9 @@ async function forwardRequest(
504
537
  });
505
538
  } finally {
506
539
  clearTimeout(ttfbTimer);
540
+ // Stop the grace timer for THIS attempt — but LEAVE streamGraceDeadline set so
541
+ // a re-forward (failover) re-arms for the REMAINING time to the shared deadline.
542
+ if (streamGraceTimer) clearTimeout(streamGraceTimer);
507
543
  }
508
544
  // Response arrived — the pre-response leak window is over. Stop guarding for
509
545
  // client-disconnect via abort (streamResponse handles mid-stream disconnects).
@@ -1153,7 +1189,7 @@ function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRe
1153
1189
  return `All ${n} accounts exhausted. Retry in ${retryAfter}s.`;
1154
1190
  }
1155
1191
 
1156
- export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin, isAnthropicIncompatBody, streamResponse, startIdleRequestReaper };
1192
+ export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, commitStreamGraceHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin, isAnthropicIncompatBody, streamResponse, startIdleRequestReaper };
1157
1193
 
1158
1194
  async function readErrorBody(upstreamRes, limitBytes = 64 * 1024) {
1159
1195
  if (!upstreamRes.body) return '';
@@ -1507,26 +1543,56 @@ async function queueAndRetry(
1507
1543
  ).then(() => true);
1508
1544
  }
1509
1545
 
1546
+ // The stream-forward grace-timer callback (extracted so its logic is unit-testable
1547
+ // in isolation). Fires when a STREAMING request's upstream is slow to first byte:
1548
+ // commits the SSE stream + heartbeat so the client never idle-times-out. Client-
1549
+ // abort-safe — an async timer has NO synchronous liveness precondition (unlike the
1550
+ // queue caller), so if the client vanished during the forward window (or the stream
1551
+ // is already committed) it reaps the queue slot and bails WITHOUT touching the dead
1552
+ // socket. ensureQueueHeartbeat is itself hardened against a throwing write, but this
1553
+ // guard is the cheaper first line of defense against the worker-bounce race.
1554
+ function commitStreamGraceHeartbeat(res, requestInfo, queueConfig, accountManager) {
1555
+ if (res.destroyed || res.writableEnded || res.headersSent) {
1556
+ clearQueueHeartbeat(requestInfo);
1557
+ accountManager.removeQueuedRequest?.(requestInfo);
1558
+ return;
1559
+ }
1560
+ ensureQueueHeartbeat(res, requestInfo, queueConfig, accountManager);
1561
+ }
1562
+
1510
1563
  function ensureQueueHeartbeat(res, requestInfo, queueConfig, accountManager) {
1511
1564
  if (!requestInfo.stream || requestInfo.queueHeartbeatActive || res.headersSent) return;
1512
1565
  const heartbeatMs = Math.max(1000, Number(queueConfig.heartbeatMs) || 10_000);
1513
- res.writeHead(200, {
1514
- 'Content-Type': 'text/event-stream',
1515
- 'Cache-Control': 'no-cache',
1516
- Connection: 'keep-alive',
1517
- 'X-Accel-Buffering': 'no',
1518
- });
1519
- res.flushHeaders?.();
1520
- res.write(': maxpool queued\n\n');
1521
- requestInfo.queueHeartbeatActive = true;
1522
1566
  // The heartbeat is the liveness probe: if the client is gone (socket
1523
- // destroyed/ended, or the write throws EPIPE/ERR_STREAM_DESTROYED), release
1567
+ // destroyed/ended, or a write throws EPIPE/ERR_STREAM_DESTROYED), release
1524
1568
  // the queue slot + bytes IMMEDIATELY rather than letting a dead ticket occupy
1525
1569
  // the queue until its (up to 7d) deadline — the ghost-leak guard.
1526
1570
  const reapDead = () => {
1527
1571
  clearQueueHeartbeat(requestInfo);
1528
1572
  accountManager?.removeQueuedRequest?.(requestInfo);
1529
1573
  };
1574
+ // The INITIAL commit can throw if the socket died between the caller's last
1575
+ // liveness check and here. The synchronous queue caller (queueAndRetry) bails
1576
+ // on res.destroyed right before calling, but the ASYNC stream-forward grace
1577
+ // timer has no such precondition — the client can vanish mid-window. An
1578
+ // unguarded throw here has NO 'error' listener → uncaughtException → the worker
1579
+ // process.exits and bounces EVERY in-flight stream. Guard the initial write
1580
+ // exactly like the interval callback below so ensureQueueHeartbeat is safe from
1581
+ // ANY caller, sync or async.
1582
+ try {
1583
+ res.writeHead(200, {
1584
+ 'Content-Type': 'text/event-stream',
1585
+ 'Cache-Control': 'no-cache',
1586
+ Connection: 'keep-alive',
1587
+ 'X-Accel-Buffering': 'no',
1588
+ });
1589
+ res.flushHeaders?.();
1590
+ res.write(': maxpool queued\n\n');
1591
+ } catch {
1592
+ reapDead();
1593
+ return;
1594
+ }
1595
+ requestInfo.queueHeartbeatActive = true;
1530
1596
  requestInfo.queueHeartbeatTimer = setInterval(() => {
1531
1597
  if (res.destroyed || res.writableEnded) { reapDead(); return; }
1532
1598
  try {
package/src/tui.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createInterface } from 'node:readline';
2
2
  import { fetchProfile, loginOAuth, tokenFingerprint } from './oauth.js';
3
- import { appendEventLog } from './event-log.js';
3
+ import { appendEventLog, setConsoleStdoutSuppressed } from './event-log.js';
4
4
 
5
5
  // ── ANSI helpers ─────────────────────────────────────────────
6
6
 
@@ -26,14 +26,27 @@ const vw = s => strip(s).length;
26
26
 
27
27
  // ── Accounts-table columns ───────────────────────────────────
28
28
  // Fixed column widths shared by the header row (acctHeader) AND every data row, so
29
- // the header labels stay aligned with the columns they name. The Account/Type/
30
- // Status/Quota start offsets (4/17/26/40) are pure functions of these widths + the
29
+ // the header labels stay aligned with the columns they name. The Account/Provider/
30
+ // Status/Quota start offsets (4/21/31/45) are pure functions of these widths + the
31
31
  // 4-col row prefix, independent of the quota-bar width.
32
- const NAME_W = 12; // a.name.slice(0, NAME_W).padEnd(NAME_W)
33
- const TYPE_W = 8; // a.type.padEnd(TYPE_W) — fits "provider"
32
+ const NAME_W = 16; // a.name.slice(0, NAME_W).padEnd(NAME_W) — wide enough for a full email
33
+ const PROVIDER_W = 9; // providerLabel(a).padEnd(PROVIDER_W) — fits "Anthropic"
34
34
  const STATUS_W = 13; // rpad(status, STATUS_W) — fits "throttled 59s"
35
35
  const ROW_PREFIX = ' '; // ' ' + sel(1) + cur(1) + ' ' — 4 cols before the name
36
36
 
37
+ // Human provider name for the accounts-table "Provider" column. account.provider
38
+ // is 'anthropic' for oauth/apikey accounts (they ARE Anthropic — the oauth-vs-key
39
+ // billing split is still shown by the Ses/Wk vs Tok/Req quota-bar labels, not here),
40
+ // and 'zai'/'kimi' for the GLM/Kimi fallback providers. Explicit map + a graceful
41
+ // Titlecase default so a future provider (Codex/Grok) renders sanely; truncated to
42
+ // the column width so a long name can never misalign the row.
43
+ const PROVIDER_LABELS = { anthropic: 'Anthropic', zai: 'z.ai', kimi: 'Moonshot', openai: 'OpenAI', codex: 'Codex', grok: 'Grok', xai: 'Grok' };
44
+ function providerLabel(a) {
45
+ const key = a.provider || (a.type === 'provider' ? 'provider' : 'anthropic');
46
+ const label = PROVIDER_LABELS[key] || (key.charAt(0).toUpperCase() + key.slice(1));
47
+ return label.slice(0, PROVIDER_W);
48
+ }
49
+
37
50
  /**
38
51
  * Aligned column header for the accounts table. Names the three columns that carry
39
52
  * NO inline label (Account / Type / Status) plus a group label over the two quota
@@ -45,7 +58,7 @@ function acctHeader(W) {
45
58
  const quota = W >= 88 ? 'Quota (used% · resets-in)' : 'Quota';
46
59
  return ROW_PREFIX
47
60
  + 'Account'.padEnd(NAME_W) + ' '
48
- + 'Type'.padEnd(TYPE_W) + ' '
61
+ + 'Provider'.padEnd(PROVIDER_W) + ' '
49
62
  + 'Status'.padEnd(STATUS_W) + ' '
50
63
  + quota;
51
64
  }
@@ -221,7 +234,7 @@ function emptyBar(label, w = 10) {
221
234
  return `${ESC}100m${' '.repeat(lp)}${text}${' '.repeat(rp)}${RESET}`;
222
235
  }
223
236
 
224
- export const __tuiTest = { formatReset, quotaLabel, bar, emptyBar, strip, loadText, countdown, acctHeader, fitLine };
237
+ export const __tuiTest = { formatReset, quotaLabel, bar, emptyBar, strip, loadText, countdown, acctHeader, fitLine, providerLabel };
225
238
 
226
239
  function timestamp() {
227
240
  return new Date().toLocaleTimeString('en-US', { hour12: false });
@@ -422,11 +435,9 @@ export class TUI {
422
435
  'Reload account credentials and newly added accounts from the config file.',
423
436
  () => this._doSync(),
424
437
  );
425
- } else if (k === 't' && this.am.accounts.length > 0) {
426
- // Manual on/off toggle straight from the top level (also under a → Accounts).
427
- // select → confirm → returns to 'normal', so this never strands the user.
428
- this._startSelection('toggle');
429
438
  }
439
+ // Enable/disable lives ONLY under [a] Accounts now (with rename/delete/login) —
440
+ // one home for every account mutation, instead of a duplicate top-level toggle.
430
441
  }
431
442
 
432
443
  _keyAccounts(k) {
@@ -469,9 +480,9 @@ export class TUI {
469
480
  );
470
481
  } else if (k === 'p' && this.am.accounts.some(account => account.type !== 'provider')) {
471
482
  this._startSelection('prefer');
472
- } else if (k === 'f') {
483
+ } else if (k === 'f' || k === 'F') {
473
484
  // Cycle the cross-provider fallback policy in place — reversible + non-destructive,
474
- // so no confirm dialog (unlike restart/delete).
485
+ // so no confirm dialog (unlike restart/delete). Accept F too (Shift-f muscle memory).
475
486
  this._cycleCrossProviderPolicy();
476
487
  } else if (k === 'esc' || k === 'q') {
477
488
  this.mode = 'normal';
@@ -554,9 +565,25 @@ export class TUI {
554
565
  this.selIdx = selectable.includes(this.am.currentIndex) ? this.am.currentIndex : selectable[0];
555
566
  }
556
567
 
568
+ // Real am.accounts indices in DISPLAY order: non-provider (Claude/OAuth/apikey)
569
+ // accounts first, providers (GLM/Kimi fallback) last, stable within each group.
570
+ // The single source of truth for row order — the render loop AND selection nav
571
+ // both iterate it, so the highlight can never desync from the visible list. The
572
+ // canonical am.accounts array order is never mutated (routing/index actions safe).
573
+ _displayOrder() {
574
+ const nonProv = [];
575
+ const prov = [];
576
+ this.am.accounts.forEach((a, i) => (a.type === 'provider' ? prov : nonProv).push(i));
577
+ return [...nonProv, ...prov];
578
+ }
579
+
557
580
  _selectableIndexes(action) {
558
- return this.am.accounts
559
- .map((account, index) => ({ account, index }))
581
+ // Map over _displayOrder() BEFORE filtering so the nav array is in DISPLAY order
582
+ // with the existing selectability filter intact — _keySelect steps this array, so
583
+ // visual order == nav order automatically and it can never land on a provider
584
+ // (prefer) or a non-configurable runtime account.
585
+ return this._displayOrder()
586
+ .map(index => ({ account: this.am.accounts[index], index }))
560
587
  .filter(({ account }) => {
561
588
  if (action === 'prefer') return account.type !== 'provider' && account.enabled;
562
589
  return this._configAccountIndex(account) >= 0;
@@ -627,6 +654,12 @@ export class TUI {
627
654
  async _doLogin() {
628
655
  const wasRunning = this.running;
629
656
  if (wasRunning) this.stop();
657
+ // Mute the console-log MIRROR to stdout during the interactive prompt so routing
658
+ // logs (console.log, e.g. the glm-fallback 429 churn) don't flood over the readline
659
+ // "Name this account" prompt. The prompts + errors use process.stdout.write, which
660
+ // BYPASSES the mirror, so they stay visible. Login runs only on the terminal-owning
661
+ // primary (mirror baseline false), so the finally restores it to false.
662
+ setConsoleStdoutSuppressed(true);
630
663
  try {
631
664
  process.stdout.write('\nOpening browser to log into Claude…\n');
632
665
  const creds = await loginOAuth();
@@ -644,6 +677,9 @@ export class TUI {
644
677
  rl.close();
645
678
  name = String(answer || '').trim() || suggested;
646
679
  }
680
+ // Lift the mute BEFORE the upsert so its persist-confirmation (a console.log,
681
+ // deliberately visible during the stopped-TUI login) reaches the user.
682
+ setConsoleStdoutSuppressed(false);
647
683
  // Message honors the ACTUAL result: if a manually-typed name collided with a
648
684
  // different existing account, upsert updates in place → "Re-authenticated", never
649
685
  // a false "Added new account".
@@ -654,6 +690,7 @@ export class TUI {
654
690
  } catch (e) {
655
691
  process.stdout.write(`\nLogin failed: ${e.message}\n`);
656
692
  } finally {
693
+ setConsoleStdoutSuppressed(false); // restore on every path (incl. the catch)
657
694
  if (wasRunning) this.start();
658
695
  }
659
696
  }
@@ -933,14 +970,34 @@ export class TUI {
933
970
  // ── Header
934
971
  const v = this.am.versionInfo;
935
972
  const verStr = v?.current ? ` ${dim('v' + v.current)}` : '';
936
- // "↑ vX.Y.Z" when a newer npm version is published; nothing when on latest or
937
- // the check hasn't resolved yet (so the header never falsely implies up-to-date).
938
- const updStr = (v?.hasUpdate && v?.latest) ? ` ${yellow('↑ v' + v.latest)}` : '';
939
- const left = bold(' Maxpool') + verStr + updStr;
973
+ const left = bold(' Maxpool') + verStr;
940
974
  const port = this.config.proxy?.port || 3456;
941
975
  const right = `Port ${port} ${green('▲')} `;
942
976
  lines.push(left + ' '.repeat(Math.max(1, W - vw(left) - vw(right))) + right);
943
977
  lines.push(' ' + dim('─'.repeat(W - 2)));
978
+ // Restart/reload in progress: admission is paused while active requests drain, then
979
+ // the process swaps. Without live feedback the screen looks FROZEN (identical
980
+ // dashboard, nothing moving) from R→Yes until the swap — the reported bug. Show an
981
+ // animated draining indicator. It clears when the swap completes (fresh worker
982
+ // renders) or a rollback resumes admission (admissionPaused → false).
983
+ if (this.am.admissionPaused) {
984
+ // Total TUI in-flight — an honest, user-meaningful "your work is finishing" count.
985
+ // (The reload gates its drain on UPSTREAM requests only, so this can read slightly
986
+ // higher than the controller's gating count; queued/idle ones reconnect, not lost.)
987
+ const inflight = this.active.size;
988
+ const detail = inflight > 0
989
+ ? `draining ${inflight} active request${inflight === 1 ? '' : 's'}…`
990
+ : 'finishing up…';
991
+ lines.push(` ${cyan(SPINNER[this.frame])} ${yellow('Restarting')} ${dim(detail)}`);
992
+ }
993
+ // Update reminder: a prominent, actionable banner when a newer npm version is
994
+ // published — nothing when on latest or the check hasn't resolved (no permanent
995
+ // blank line). The persistent banner IS the reminder; a long-lived session's
996
+ // periodic re-check keeps it current (index.js updateTimer refreshes versionInfo).
997
+ if (v?.hasUpdate && v?.latest) {
998
+ lines.push(' ' + yellow(`↑ Update available: v${v.current} → v${v.latest}`)
999
+ + dim(" · run 'npm i -g maxpool', then press r"));
1000
+ }
944
1001
  const routing = this.am.routingMode === 'preferred'
945
1002
  ? `Manual preference: ${this.am.preferredAccountName} (automatic failover)`
946
1003
  : 'Automatic load balancing';
@@ -991,11 +1048,17 @@ export class TUI {
991
1048
  // misaligned second header).
992
1049
  lines.push(dimUnderline(acctHeader(W)));
993
1050
  const showBoth = W >= 70;
1051
+ // 61/50 = the fixed pre-bar column span (prefix + Account + Provider + Status +
1052
+ // gaps); grew by +4 with the wider 16-col Account column (was 57/46 at NAME_W 12).
994
1053
  const bw = showBoth
995
- ? Math.max(5, Math.min(20, Math.floor((W - 56) / 2)))
996
- : Math.max(5, Math.min(20, W - 45));
997
-
998
- for (let i = 0; i < this.am.accounts.length; i++) {
1054
+ ? Math.max(5, Math.min(20, Math.floor((W - 61) / 2)))
1055
+ : Math.max(5, Math.min(20, W - 50));
1056
+
1057
+ // Claude/OAuth accounts first, providers (GLM/Kimi fallback) last — a stable
1058
+ // display order over the canonical am.accounts array (which stays untouched so
1059
+ // routing/index-keyed actions are unaffected). Selection navigation shares the
1060
+ // SAME order via _selectableIndexes → _displayOrder.
1061
+ for (const i of this._displayOrder()) {
999
1062
  lines.push(this._renderAcct(i, bw, showBoth));
1000
1063
  }
1001
1064
  // Glossary FOOTER (expands the abbreviations the header + inline labels can't
@@ -1074,12 +1137,16 @@ export class TUI {
1074
1137
 
1075
1138
  // Name (bold if selected)
1076
1139
  const rawName = a.name.slice(0, NAME_W).padEnd(NAME_W);
1077
- const name = isSel ? bold(rawName) : rawName;
1140
+ // A disabled account reads as "off" — dim the whole name. Applied as a SINGLE SGR
1141
+ // (never dim(bold(...)) — bold()'s trailing RESET would cancel the dim mid-string),
1142
+ // so a disabled row is never also bold-selected.
1143
+ const name = a.enabled === false ? dim(rawName) : (isSel ? bold(rawName) : rawName);
1078
1144
 
1079
- // Type — pad to 8 so "provider" (8 chars) doesn't overflow a 7-wide column and
1080
- // shift the whole provider row (incl. its quota bars) 1 char out of alignment
1081
- // with the "oauth"/"apikey" rows.
1082
- const type = gray(a.type.padEnd(TYPE_W));
1145
+ // Provider column — the real vendor (Anthropic / z.ai / Moonshot), padded to
1146
+ // PROVIDER_W so "Anthropic" (9 chars) never overflows and shifts the row (incl.
1147
+ // its quota bars) out of alignment with the shorter provider labels. This single
1148
+ // cell is reused by both the oauth/apikey row below and _renderProviderAcct.
1149
+ const type = gray(providerLabel(a).padEnd(PROVIDER_W));
1083
1150
 
1084
1151
  // Status
1085
1152
  let status;
@@ -1111,7 +1178,7 @@ export class TUI {
1111
1178
  case 'probing': status = green('probing'); break;
1112
1179
  case 'waiting': status = yellow('waiting'); break;
1113
1180
  case 'paused': status = yellow('paused'); break;
1114
- case 'disabled': status = gray('disabled'); break;
1181
+ case 'disabled': status = red('✕ disabled'); break;
1115
1182
  case 'throttled': {
1116
1183
  // A transient auto-recovering cooldown — show the remaining time (from
1117
1184
  // rateLimitedUntil) so it reads as "recovering in Ns", not stuck.
@@ -1274,11 +1341,16 @@ export class TUI {
1274
1341
  _renderFooter() {
1275
1342
  switch (this.mode) {
1276
1343
  case 'normal':
1277
- return ` ${bold('a')} Accounts ${bold('t')} On/off ${bold('m')} Routing ${bold('s')} Sync ${bold('r')} Restart ${bold('q')} Stop`;
1344
+ return ` ${bold('a')} Accounts ${bold('m')} Routing ${bold('s')} Sync ${bold('r')} Restart ${bold('q')} Stop`;
1278
1345
  case 'accounts':
1279
1346
  return ` ${bold('l')} Login/re-auth (browser) ${bold('k')} API key ${bold('n')} Rename ${bold('t')} Enable/disable ${bold('d')} Delete ${bold('Esc')} Back`;
1280
- case 'routing':
1281
- return ` ${bold('a')} Automatic ${bold('p')} Manual preference ${bold('f')} Cross-provider fallback ${bold('Esc')} Back`;
1347
+ case 'routing': {
1348
+ // Show the CURRENT cross-provider policy inline so pressing f visibly changes it
1349
+ // right here at the footer (the policy also renders in the header, far from the
1350
+ // keypress — the "f does nothing" report).
1351
+ const xp = this.am._crossProviderFallbackPolicy?.() || 'when-exhausted';
1352
+ return ` ${bold('a')} Automatic ${bold('p')} Manual preference ${bold('f')} Cross-provider: ${cyan(xp)} ↻ ${bold('Esc')} Back`;
1353
+ }
1282
1354
  case 'select': {
1283
1355
  const act = this.selAction === 'prefer'
1284
1356
  ? 'prefer'