maxpool 1.5.38 → 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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +19 -0
  3. package/src/tui.js +58 -22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.5.38",
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",
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/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
 
@@ -27,9 +27,9 @@ const vw = s => strip(s).length;
27
27
  // ── Accounts-table columns ───────────────────────────────────
28
28
  // Fixed column widths shared by the header row (acctHeader) AND every data row, so
29
29
  // the header labels stay aligned with the columns they name. The Account/Provider/
30
- // Status/Quota start offsets (4/17/27/41) are pure functions of these widths + the
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)
32
+ const NAME_W = 16; // a.name.slice(0, NAME_W).padEnd(NAME_W) — wide enough for a full email
33
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
@@ -435,11 +435,9 @@ export class TUI {
435
435
  'Reload account credentials and newly added accounts from the config file.',
436
436
  () => this._doSync(),
437
437
  );
438
- } else if (k === 't' && this.am.accounts.length > 0) {
439
- // Manual on/off toggle straight from the top level (also under a → Accounts).
440
- // select → confirm → returns to 'normal', so this never strands the user.
441
- this._startSelection('toggle');
442
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.
443
441
  }
444
442
 
445
443
  _keyAccounts(k) {
@@ -482,9 +480,9 @@ export class TUI {
482
480
  );
483
481
  } else if (k === 'p' && this.am.accounts.some(account => account.type !== 'provider')) {
484
482
  this._startSelection('prefer');
485
- } else if (k === 'f') {
483
+ } else if (k === 'f' || k === 'F') {
486
484
  // Cycle the cross-provider fallback policy in place — reversible + non-destructive,
487
- // so no confirm dialog (unlike restart/delete).
485
+ // so no confirm dialog (unlike restart/delete). Accept F too (Shift-f muscle memory).
488
486
  this._cycleCrossProviderPolicy();
489
487
  } else if (k === 'esc' || k === 'q') {
490
488
  this.mode = 'normal';
@@ -656,6 +654,12 @@ export class TUI {
656
654
  async _doLogin() {
657
655
  const wasRunning = this.running;
658
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);
659
663
  try {
660
664
  process.stdout.write('\nOpening browser to log into Claude…\n');
661
665
  const creds = await loginOAuth();
@@ -673,6 +677,9 @@ export class TUI {
673
677
  rl.close();
674
678
  name = String(answer || '').trim() || suggested;
675
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);
676
683
  // Message honors the ACTUAL result: if a manually-typed name collided with a
677
684
  // different existing account, upsert updates in place → "Re-authenticated", never
678
685
  // a false "Added new account".
@@ -683,6 +690,7 @@ export class TUI {
683
690
  } catch (e) {
684
691
  process.stdout.write(`\nLogin failed: ${e.message}\n`);
685
692
  } finally {
693
+ setConsoleStdoutSuppressed(false); // restore on every path (incl. the catch)
686
694
  if (wasRunning) this.start();
687
695
  }
688
696
  }
@@ -962,14 +970,34 @@ export class TUI {
962
970
  // ── Header
963
971
  const v = this.am.versionInfo;
964
972
  const verStr = v?.current ? ` ${dim('v' + v.current)}` : '';
965
- // "↑ vX.Y.Z" when a newer npm version is published; nothing when on latest or
966
- // the check hasn't resolved yet (so the header never falsely implies up-to-date).
967
- const updStr = (v?.hasUpdate && v?.latest) ? ` ${yellow('↑ v' + v.latest)}` : '';
968
- const left = bold(' Maxpool') + verStr + updStr;
973
+ const left = bold(' Maxpool') + verStr;
969
974
  const port = this.config.proxy?.port || 3456;
970
975
  const right = `Port ${port} ${green('▲')} `;
971
976
  lines.push(left + ' '.repeat(Math.max(1, W - vw(left) - vw(right))) + right);
972
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
+ }
973
1001
  const routing = this.am.routingMode === 'preferred'
974
1002
  ? `Manual preference: ${this.am.preferredAccountName} (automatic failover)`
975
1003
  : 'Automatic load balancing';
@@ -1020,11 +1048,11 @@ export class TUI {
1020
1048
  // misaligned second header).
1021
1049
  lines.push(dimUnderline(acctHeader(W)));
1022
1050
  const showBoth = W >= 70;
1023
- // 57/46 = the fixed pre-bar column span (prefix + Account + Provider + Status +
1024
- // gaps); grew by 1 with the Provider column (was 56/45 under the 8-wide Type col).
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).
1025
1053
  const bw = showBoth
1026
- ? Math.max(5, Math.min(20, Math.floor((W - 57) / 2)))
1027
- : Math.max(5, Math.min(20, W - 46));
1054
+ ? Math.max(5, Math.min(20, Math.floor((W - 61) / 2)))
1055
+ : Math.max(5, Math.min(20, W - 50));
1028
1056
 
1029
1057
  // Claude/OAuth accounts first, providers (GLM/Kimi fallback) last — a stable
1030
1058
  // display order over the canonical am.accounts array (which stays untouched so
@@ -1109,7 +1137,10 @@ export class TUI {
1109
1137
 
1110
1138
  // Name (bold if selected)
1111
1139
  const rawName = a.name.slice(0, NAME_W).padEnd(NAME_W);
1112
- 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);
1113
1144
 
1114
1145
  // Provider column — the real vendor (Anthropic / z.ai / Moonshot), padded to
1115
1146
  // PROVIDER_W so "Anthropic" (9 chars) never overflows and shifts the row (incl.
@@ -1147,7 +1178,7 @@ export class TUI {
1147
1178
  case 'probing': status = green('probing'); break;
1148
1179
  case 'waiting': status = yellow('waiting'); break;
1149
1180
  case 'paused': status = yellow('paused'); break;
1150
- case 'disabled': status = gray('disabled'); break;
1181
+ case 'disabled': status = red('✕ disabled'); break;
1151
1182
  case 'throttled': {
1152
1183
  // A transient auto-recovering cooldown — show the remaining time (from
1153
1184
  // rateLimitedUntil) so it reads as "recovering in Ns", not stuck.
@@ -1310,11 +1341,16 @@ export class TUI {
1310
1341
  _renderFooter() {
1311
1342
  switch (this.mode) {
1312
1343
  case 'normal':
1313
- 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`;
1314
1345
  case 'accounts':
1315
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`;
1316
- case 'routing':
1317
- 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
+ }
1318
1354
  case 'select': {
1319
1355
  const act = this.selAction === 'prefer'
1320
1356
  ? 'prefer'