maxpool 1.5.41 → 1.5.42

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.41",
3
+ "version": "1.5.42",
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",
@@ -103,6 +103,18 @@ const DEFAULT_SCHEDULER = {
103
103
  capPenaltyWeight: 10, // steep penalty per unit of in-flight depth past D (throttle safety floor)
104
104
  paceCostWeight: 1.5, // soft de-preference of accounts burning ahead of pace (was the ×6 term)
105
105
  scarcityWeight: 6, // legacy; superseded by paceCostWeight (kept so old configs don't error)
106
+ // Reserve-account OVERFLOW model. A weekly-RESERVE account (util 0.85-0.95) used to
107
+ // sit idle behind a healthy-only first pass; now it's eligible in the first pass but
108
+ // ranked BELOW healthy accounts by _reserveCost (so healthy stays first-pick). Among
109
+ // reserve accounts the SOONEST-to-reset is cheapest (use-it-or-lose-it); the further
110
+ // into the band, the costlier (preserve). Critical (≥0.95)/exhausted stay hard-benched
111
+ // in the second pass, never softened. See _reserveCost + _selectNext's 2-pass ladder.
112
+ reserveFloorCost: 5, // base overflow cost — keeps any reserve account behind an idle healthy one (~2)
113
+ reserveBandWeight: 8, // ×(util-0.85)/0.10: deeper into the reserve band ⇒ costlier ⇒ consumed later
114
+ reserveScarcityWeight: 6, // ×weekly _windowScarcity: reset-timing weight, ON TOP of the global paceCost, so
115
+ // a near-reset reserve account is used freely and a far-from-reset one is used last
116
+ reserveConcurrencyTarget: 2, // tighter in-flight cap for reserve (capPenalty bites at inflight>2 ⇒ load fans out
117
+ // across the fleet before any single reserve account is dogpiled toward a 429)
106
118
  spreadShareWeight: 3, // multiplies an account's share of recent fleet load (0..1)
107
119
  recoveryRampWeight: 4, // decaying penalty applied to a just-recovered account
108
120
  recoveryRampMs: 5 * 60_000, // how long the post-recovery ramp lasts
@@ -121,16 +133,25 @@ const DEFAULT_SCHEDULER = {
121
133
  // may be served by a provider FAMILY other than its home (Claude ↔ GLM ↔ Kimi).
122
134
  // 'never' — strict pin: a Claude session uses Claude only; a GLM session
123
135
  // uses GLM only; a Kimi session uses Kimi only.
124
- // 'when-exhausted'— (default) home family preferred; a Claude session falls back
125
- // to GLM/Kimi only once all Claude accounts are unavailable
126
- // (providers are already lower-priority fallback), and a GLM
127
- // session may fall to Kimi once GLM is exhausted.
136
+ // 'when-exhausted'— home family preferred; a Claude session falls back to GLM/Kimi
137
+ // only once all Claude accounts are unavailable, and a GLM session
138
+ // may fall to Kimi once GLM is exhausted.
128
139
  // 'always' — providers peer with Claude for a Claude/unknown session
129
140
  // (load-balanced, not last-resort).
141
+ // DEFAULT is 'never' (2026-07-24): a Claude Code session stays on Anthropic. Routing
142
+ // Claude→GLM/Kimi proved unreliable (the coding legs 403/429 + ignore the model id),
143
+ // so cross-routing OUT of Claude is OFF by default; re-enable via the TUI routing
144
+ // cycle or config. This governs ONLY the Claude→provider direction.
130
145
  // INVARIANT (all policies): a GLM/Kimi-origin session NEVER routes to an Anthropic
131
146
  // account — Anthropic 400s on a non-`srvtoolu_` server_tool_use id; that direction
132
147
  // is unfixable, not policy-tunable.
133
- crossProviderFallbackPolicy: 'when-exhausted',
148
+ crossProviderFallbackPolicy: 'never',
149
+ // The OTHER cross direction, independent of the policy above: may a provider-origin
150
+ // (GLM/Kimi) session cross to the OTHER provider (GLM↔Kimi)? Default ON — both legs are
151
+ // lenient and accept each other's ids, and it's the reliable direction the user wants
152
+ // kept even while Claude→provider is 'never'. Only has effect under policy:'never' (the
153
+ // 'when-exhausted'/'always' paths never pinned to home). Set false for a strict home-pin.
154
+ providerCrossFallback: true,
134
155
  };
135
156
  const LOAD_EVENT_MAX_AGE_MS = 60 * 60 * 1000;
136
157
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
@@ -1429,14 +1450,16 @@ export class AccountManager {
1429
1450
  // Else fall through to the candidate score loop, which re-homes the session
1430
1451
  // onto the best healthy account via _bindSession on acquire.
1431
1452
 
1432
- // Healthy-first ladder for BOTH bound and unbound sessions. A bound session
1433
- // only reaches this loop once we've decided to LEAVE its account (rebalance
1434
- // fired / a higher-priority account is available / the bound account is down) —
1435
- // the sticky "stay put" path returns above without reaching here — so re-homing
1436
- // must prefer a genuinely-healthy account and fall back to reserve/critical only
1437
- // if none exists, never re-pick the idle reserve account it's leaving.
1453
+ // Two-pass ladder for BOTH bound and unbound sessions. Pass 1 admits healthy AND
1454
+ // reserve accounts together; the reserve accounts carry _reserveCost so a healthy
1455
+ // account is preferred whenever one is comparably loaded — but a reserve account
1456
+ // CAN outrank a badly-slammed healthy one (its capPenalty is unbounded), which is
1457
+ // intended load-spread, not a regression. Pass 2 adds critical only when pass 1 is
1458
+ // empty (the untouched hard fallback). A bound session reaches this loop only once
1459
+ // we've decided to LEAVE its account (the sticky "stay put" path returns above); it
1460
+ // may now re-home onto a lightly-loaded reserve account rather than a slammed
1461
+ // healthy one — deliberate, since the whole point is to USE reserve accounts.
1438
1462
  const weeklyPasses = [
1439
- { allowWeeklyReserve: false, allowWeeklyCritical: false },
1440
1463
  { allowWeeklyReserve: true, allowWeeklyCritical: false },
1441
1464
  { allowWeeklyReserve: true, allowWeeklyCritical: true },
1442
1465
  ];
@@ -1715,9 +1738,13 @@ export class AccountManager {
1715
1738
  // The transcript can't replay to Claude (a foreign server_tool_use id, or
1716
1739
  // content Anthropic rejected on replay) — provider accounts ONLY, regardless
1717
1740
  // of policy. Providers are lenient and accept each other's ids (GLM↔Kimi is
1718
- // fine); 'never' pins to the detected home provider when known.
1741
+ // fine). Under 'never' we keep the session on its home provider ONLY when
1742
+ // providerCrossFallback is explicitly off; by default GLM↔Kimi crossing stays
1743
+ // allowed even under 'never' (that reliable direction is governed separately
1744
+ // from the Claude→provider policy).
1719
1745
  if (account.type !== 'provider') return false;
1720
- if (policy === 'never' && homeProvider && account.provider !== homeProvider) return false;
1746
+ if (policy === 'never' && homeProvider && account.provider !== homeProvider
1747
+ && this.scheduler.providerCrossFallback === false) return false;
1721
1748
  return true;
1722
1749
  }
1723
1750
 
@@ -1843,9 +1870,16 @@ export class AccountManager {
1843
1870
  const concurrency = inflight * this.scheduler.concurrencyWeight;
1844
1871
 
1845
1872
  // Steep soft cap past depth D — the throttle safety floor. No single
1846
- // account absorbs a deep concurrent burst no matter how "cheap" it looks.
1873
+ // account absorbs a deep concurrent burst no matter how "cheap" it looks. A
1874
+ // RESERVE account gets a tighter D (reserveConcurrencyTarget) so its capPenalty
1875
+ // bites sooner — load fans out across the fleet before a low-quota account is
1876
+ // dogpiled toward a 429.
1877
+ const weeklyState = this._weeklyRawState(account);
1878
+ const concTarget = weeklyState === 'reserve'
1879
+ ? this.scheduler.reserveConcurrencyTarget
1880
+ : this.scheduler.perAccountConcurrencyTarget;
1847
1881
  const capPenalty = this.scheduler.capPenaltyWeight
1848
- * Math.max(0, inflight - this.scheduler.perAccountConcurrencyTarget);
1882
+ * Math.max(0, inflight - concTarget);
1849
1883
 
1850
1884
  // Burn-pace COST only (demoted from the old dominant scarcity×6 term): a
1851
1885
  // soft de-preference of accounts burning ahead of an even pace. Never a bench.
@@ -1867,6 +1901,7 @@ export class AccountManager {
1867
1901
  const spread = share * this.scheduler.spreadShareWeight;
1868
1902
 
1869
1903
  const ramp = this._recoveryRamp(account, now);
1904
+ const reserveCost = this._reserveCost(account, now, weeklyState);
1870
1905
  const failurePenalty = account.consecutiveFailures * 5;
1871
1906
  // NO unknown-quota bonus. An account whose quota we cannot see must never be
1872
1907
  // MORE attractive than a known-healthy one — the old -0.5 nudge (safe only
@@ -1876,7 +1911,7 @@ export class AccountManager {
1876
1911
  // default) learns the real number within a cycle. `probing`/requalify still
1877
1912
  // flags a never-seen account for learning — that path is unchanged.
1878
1913
 
1879
- return concurrency + capPenalty + paceCost + scopedPace + spread + ramp + failurePenalty;
1914
+ return concurrency + capPenalty + paceCost + scopedPace + spread + ramp + reserveCost + failurePenalty;
1880
1915
  }
1881
1916
 
1882
1917
  /**
@@ -1929,6 +1964,31 @@ export class AccountManager {
1929
1964
  return Math.max(0, used - elapsedFrac);
1930
1965
  }
1931
1966
 
1967
+ /**
1968
+ * Overflow cost for a weekly-RESERVE account (util 0.85-0.95). 0 for every other
1969
+ * tier — normal/soft aren't softened, and critical/exhausted are hard-benched by the
1970
+ * pass gate (never made cheaper here). This is what lets a reserve account be eligible
1971
+ * in the FIRST selection pass yet still rank below healthy accounts: floor keeps it
1972
+ * strictly behind an idle healthy pick, the band term makes deeper-into-reserve costlier
1973
+ * (consumed later / preserved), and the reset-timing term makes the soonest-to-reset the
1974
+ * cheapest reserve pick (use-it-or-lose-it). A slammed HEALTHY account can still score
1975
+ * above a lightly-loaded reserve one (its capPenalty is unbounded) — that's intended
1976
+ * load-spread, not a violation of "healthy first".
1977
+ */
1978
+ _reserveCost(account, now = Date.now(), weeklyState = this._weeklyRawState(account)) {
1979
+ if (weeklyState !== 'reserve') return 0;
1980
+ const q = account.quota;
1981
+ const util = clamp01(q.unified7d);
1982
+ const band = clamp01(
1983
+ (util - this.scheduler.weeklyReserveThreshold)
1984
+ / Math.max(1e-6, this.scheduler.weeklyCriticalThreshold - this.scheduler.weeklyReserveThreshold),
1985
+ );
1986
+ const pace = this._windowScarcity(q.unified7d, q.unified7dReset, WEEK_MS, now);
1987
+ return this.scheduler.reserveFloorCost
1988
+ + this.scheduler.reserveBandWeight * band
1989
+ + this.scheduler.reserveScarcityWeight * pace;
1990
+ }
1991
+
1932
1992
  /**
1933
1993
  * Decaying penalty applied for `recoveryRampMs` after an account un-parks,
1934
1994
  * so a freshly-recovered account (which has ~0 recent load and may look most
package/src/config.js CHANGED
@@ -92,16 +92,22 @@ export function createDefaultConfig() {
92
92
  weeklyCriticalThreshold: 0.95,
93
93
  weeklyExhaustedThreshold: 0.985,
94
94
  // Cross-PROVIDER fallback policy for 'cc all' (profile=all): whether a session
95
- // may be served by a provider family other than its home (Claude ↔ GLM ↔ Kimi).
96
- // 'never' — strict pin (Claude→Claude, GLM→GLM, Kimi→Kimi).
97
- // 'when-exhausted'— (default) home family preferred; cross only once it's
98
- // exhausted (a Claude session falls to GLM/Kimi when all
99
- // Claude accounts are unavailable).
95
+ // may be served by a provider family other than its home (Claude → GLM/Kimi).
96
+ // 'never' — (DEFAULT) a Claude Code session stays on Anthropic; no
97
+ // GLM/Kimi fallback. Routing Claude→providers proved unreliable
98
+ // (the coding legs 403/429 + ignore the model id), so it's OFF
99
+ // by default — re-enable here or via the TUI 'f' key.
100
+ // 'when-exhausted'— home family preferred; a Claude session falls to GLM/Kimi
101
+ // only once all Claude accounts are unavailable.
100
102
  // 'always' — providers peer with Claude for a Claude/unknown session.
101
103
  // A GLM/Kimi-origin session NEVER routes to Anthropic under ANY policy — that
102
104
  // direction 400s on the non-`srvtoolu_` tool-use id and is unfixable. Toggle
103
105
  // live in the TUI (Routing sub-mode, 'f' key).
104
- crossProviderFallbackPolicy: 'when-exhausted',
106
+ crossProviderFallbackPolicy: 'never',
107
+ // The OTHER cross direction (independent of the policy above): may a provider-origin
108
+ // (GLM/Kimi) session cross to the OTHER provider (GLM↔Kimi)? Default ON — reliable,
109
+ // both legs accept each other's ids. Only has effect under policy:'never'.
110
+ providerCrossFallback: true,
105
111
  },
106
112
  retry: {
107
113
  maxAttemptsPerRequest: 0,
package/src/index.js CHANGED
@@ -966,6 +966,13 @@ async function serverWorkerCommand() {
966
966
  crossProviderFallbackPolicy: config.scheduler.crossProviderFallbackPolicy,
967
967
  };
968
968
  }
969
+ // Persist live-toggled automatic-update flags (the TUI 'u' Updates menu). Same
970
+ // reason as the policy above: without this the toggle takes effect in memory but
971
+ // silently reverts on the next config write / restart. Only write keys defined
972
+ // in-memory so a disk value is never clobbered with undefined.
973
+ for (const key of ['updateCheck', 'autoUpdate', 'autoApply']) {
974
+ if (config[key] !== undefined) diskConfig[key] = config[key];
975
+ }
969
976
  // Write in-memory accounts as the authoritative state, preserving
970
977
  // extra disk-only fields (e.g. importFrom) where the account still exists.
971
978
  // Use live tokens from AccountManager (not the stale config.accounts copy).
@@ -1038,18 +1045,55 @@ async function serverWorkerCommand() {
1038
1045
  restartController.requestRestart();
1039
1046
  }
1040
1047
  };
1048
+ // Manual apply (the TUI 'u' → check & apply now): reload into a freshly-installed
1049
+ // newer version REGARDLESS of autoApply — the user asked for it explicitly, so it must
1050
+ // NOT no-op just because the AUTOMATIC path is off (that no-op is exactly the "relaunch
1051
+ // still shows the old version" pain this menu exists to kill).
1052
+ const applyNow = r => {
1053
+ if (r?.applicable && restartController) {
1054
+ markApplied(r.installedVersion);
1055
+ notifyUpdate('Applying update — seamless reload…');
1056
+ restartController.requestRestart();
1057
+ }
1058
+ };
1059
+ // ONE in-flight latch shared by the periodic timer, the cold-start probe, AND the
1060
+ // manual action, so two `npm i -g` can never race (global-package corruption). hasLease
1061
+ // already blocks CROSS-worker races; this blocks same-worker timer-vs-manual overlap.
1062
+ let updateInFlight = false;
1063
+ const runUpdateCheck = async ({ announce, apply, forceInstall = false }) => {
1064
+ if (updateInFlight || !hasLease || config?.updateCheck === false) return undefined;
1065
+ updateInFlight = true;
1066
+ try {
1067
+ // The MANUAL action forces the install (autoUpdate:true) so pressing 'u' → 'c'
1068
+ // actually downloads + reloads even when AUTOMATIC updates are off. Without this,
1069
+ // maybeCheckForUpdate short-circuits before installing when config.autoUpdate is
1070
+ // false (the default) and just tells the user to run `npm i -g` by hand — the exact
1071
+ // quit/relaunch dance this menu exists to kill. The periodic + cold-start paths
1072
+ // pass the real config so they still respect the user's autoUpdate choice.
1073
+ const cfg = forceInstall ? { ...config, autoUpdate: true } : config;
1074
+ const r = await maybeCheckForUpdate(cfg, notifyUpdate, info => { accountManager.versionInfo = info; }, { announce });
1075
+ apply(r);
1076
+ return r;
1077
+ } catch { return undefined; /* update path is best-effort; never break the proxy */ }
1078
+ finally { updateInFlight = false; }
1079
+ };
1080
+ // 'u' → check & apply now: force an immediate check + install + seamless reload so the
1081
+ // user never has to quit/relaunch to pick up a release. Reports the up-to-date case too.
1082
+ const checkForUpdatesNow = async () => {
1083
+ if (updateInFlight) { notifyUpdate('Update check already running'); return; }
1084
+ if (!hasLease) { notifyUpdate('Updates run on the primary worker only'); return; }
1085
+ if (config?.updateCheck === false) { notifyUpdate('Update checks are disabled in config'); return; }
1086
+ notifyUpdate('Checking for updates…');
1087
+ const r = await runUpdateCheck({ announce: true, apply: applyNow, forceInstall: true });
1088
+ if (r && !r.hasUpdate) notifyUpdate('Already on the latest version');
1089
+ };
1090
+ if (tui) tui.checkNow = checkForUpdatesNow;
1041
1091
  const updateIntervalMs = Math.max(60_000, Number(process.env.MAXPOOL_UPDATE_CHECK_INTERVAL_MS) || 6 * 60 * 60 * 1000);
1042
1092
  updateTimer = setInterval(() => {
1043
- // ONLY the lease-holding primary self-installs — the timer is unconditional across
1044
- // EVERY worker (incl. a headless reload worker), so gate on hasLease here to avoid
1045
- // two `npm i -g` racing during a reload overlap (global-package corruption). A
1046
- // headless reload worker never holds the lease, so it never installs/auto-applies.
1047
- if (config?.updateCheck === false || !hasLease) return;
1048
1093
  // announce:false — the persistent TUI banner is the passive reminder; only real
1049
- // actions (installing / applying) log here, so a pending update never churns the log.
1050
- maybeCheckForUpdate(config, notifyUpdate, info => { accountManager.versionInfo = info; }, { announce: false })
1051
- .then(applyUpdateIfReady)
1052
- .catch(() => {});
1094
+ // actions (installing / applying) log. runUpdateCheck's latch + hasLease gate prevent
1095
+ // racing installs (a headless reload worker never holds the lease, so never installs).
1096
+ runUpdateCheck({ announce: false, apply: applyUpdateIfReady });
1053
1097
  }, updateIntervalMs);
1054
1098
  updateTimer.unref();
1055
1099
 
@@ -1099,11 +1143,10 @@ async function serverWorkerCommand() {
1099
1143
  // A reload-spawned/takeover worker must NEVER re-probe (1x not 2x traffic).
1100
1144
  if (!viaTakeover && !isReloadWorker) {
1101
1145
  if (process.env.MAXPOOL_TEST_LOG_UPDATE_CHECK === '1') console.log('[Maxpool] UPDATE_CHECK_FIRED');
1102
- // Cold-start-behind: download AND (autoApply) self-apply via the SAME helper as the
1103
- // periodic path — so the version is applied, not marked-attempted-then-stranded.
1104
- maybeCheckForUpdate(config, notifyUpdate, info => { accountManager.versionInfo = info; })
1105
- .then(applyUpdateIfReady)
1106
- .catch(() => {});
1146
+ // Cold-start-behind: download AND (autoApply) self-apply via the SAME latched helper
1147
+ // as the periodic path — so the version is applied, not marked-attempted-then-stranded,
1148
+ // and it can't race the periodic timer's install.
1149
+ runUpdateCheck({ announce: true, apply: applyUpdateIfReady });
1107
1150
  } else if (process.env.MAXPOOL_TEST_LOG_UPDATE_CHECK === '1') {
1108
1151
  console.log('[Maxpool] UPDATE_CHECK_SKIPPED (reload)');
1109
1152
  }
package/src/tui.js CHANGED
@@ -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/21/31/45) are pure functions of these widths + the
30
+ // Status/Quota start offsets (4/25/35/49) are pure functions of these widths + the
31
31
  // 4-col row prefix, independent of the quota-bar width.
32
- const NAME_W = 16; // a.name.slice(0, NAME_W).padEnd(NAME_W) — wide enough for a full email
32
+ const NAME_W = 20; // a.name.slice(0, NAME_W).padEnd(NAME_W) — fits a full email like 2solarmax@gmail.com (19)
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
@@ -250,6 +250,9 @@ export class TUI {
250
250
  this.syncAccounts = syncAccounts;
251
251
  this.onQuit = onQuit;
252
252
  this.onRestart = onRestart;
253
+ // Set by index.js after construction (a deferred closure, not a constructor literal —
254
+ // avoids the const TDZ on applyUpdateIfReady, which is defined after `new TUI`).
255
+ this.checkNow = null;
253
256
 
254
257
  this.log = []; // completed activity entries
255
258
  this.active = new Map(); // in-flight requests
@@ -400,6 +403,7 @@ export class TUI {
400
403
  case 'normal': this._keyNormal(k); break;
401
404
  case 'accounts': this._keyAccounts(k); break;
402
405
  case 'routing': this._keyRouting(k); break;
406
+ case 'updates': this._keyUpdates(k); break;
403
407
  case 'select': this._keySelect(k); break;
404
408
  case 'input': this._keyInput(k); break;
405
409
  case 'confirm': this._keyConfirm(k); break;
@@ -435,11 +439,52 @@ export class TUI {
435
439
  'Reload account credentials and newly added accounts from the config file.',
436
440
  () => this._doSync(),
437
441
  );
442
+ } else if (k === 'u') {
443
+ this.mode = 'updates';
438
444
  }
439
445
  // Enable/disable lives ONLY under [a] Accounts now (with rename/delete/login) —
440
446
  // one home for every account mutation, instead of a duplicate top-level toggle.
441
447
  }
442
448
 
449
+ // Automatic updates are "on" only when the whole chain is enabled: check npm →
450
+ // install to disk → seamlessly reload. Any one off means a release won't land hands-free.
451
+ _autoUpdateOn() {
452
+ const c = this.config || {};
453
+ return c.updateCheck !== false && c.autoUpdate === true && c.autoApply === true;
454
+ }
455
+
456
+ _keyUpdates(k) {
457
+ if (k === 'c') {
458
+ // Check & apply now — the dance-killer: pull the latest + seamless-reload in place,
459
+ // no quit/relaunch. index.js wires this.checkNow (applies regardless of autoApply).
460
+ this.mode = 'normal';
461
+ if (this.checkNow) this.checkNow();
462
+ else this._addLog('Update check unavailable on this worker');
463
+ } else if (k === 't') {
464
+ this._toggleAutoUpdate();
465
+ } else if (k === 'esc' || k === 'q') {
466
+ this.mode = 'normal';
467
+ }
468
+ }
469
+
470
+ async _toggleAutoUpdate() {
471
+ const turnOn = !this._autoUpdateOn();
472
+ // ON = the full hands-free chain; OFF = keep checking (banner still shows) but never
473
+ // install/reload without the user. Mutating this.config (=== the object index.js's
474
+ // update timer reads) takes effect live; saveConfig persists it (index.js writes these
475
+ // three keys) so it survives a restart.
476
+ this.config.updateCheck = true;
477
+ this.config.autoUpdate = turnOn;
478
+ this.config.autoApply = turnOn;
479
+ try {
480
+ await this.saveConfig(this.config);
481
+ this._addLog(`Automatic updates ${turnOn ? 'on' : 'off'}`);
482
+ } catch (error) {
483
+ this._addLog(`Could not save update setting: ${error.message}`);
484
+ }
485
+ this.mode = 'normal';
486
+ }
487
+
443
488
  _keyAccounts(k) {
444
489
  if (k === 'k') {
445
490
  this.mode = 'input';
@@ -984,7 +1029,12 @@ export class TUI {
984
1029
  // ── Header
985
1030
  const v = this.am.versionInfo;
986
1031
  const verStr = v?.current ? ` ${dim('v' + v.current)}` : '';
987
- const left = bold(' Maxpool') + verStr;
1032
+ // Auto-update state next to the version, so it's obvious whether a published release
1033
+ // lands hands-free (green "auto-update on") or needs a manual 'u' (dim "off").
1034
+ const auto = this._autoUpdateOn()
1035
+ ? green('· auto-update on')
1036
+ : dim('· auto-update off');
1037
+ const left = bold(' Maxpool') + verStr + ' ' + auto;
988
1038
  const port = this.config.proxy?.port || 3456;
989
1039
  const right = `Port ${port} ${green('▲')} `;
990
1040
  lines.push(left + ' '.repeat(Math.max(1, W - vw(left) - vw(right))) + right);
@@ -1009,8 +1059,10 @@ export class TUI {
1009
1059
  // blank line). The persistent banner IS the reminder; a long-lived session's
1010
1060
  // periodic re-check keeps it current (index.js updateTimer refreshes versionInfo).
1011
1061
  if (v?.hasUpdate && v?.latest) {
1012
- lines.push(' ' + yellow(`↑ Update available: v${v.current} → v${v.latest}`)
1013
- + dim(" · run 'npm i -g maxpool', then press r"));
1062
+ // No more "run npm i -g, then press r" — that manual dance is what the Updates menu
1063
+ // kills. Auto-update ON: it applies itself; either way 'u' pulls + reloads in place.
1064
+ const how = this._autoUpdateOn() ? 'applying automatically · or press u now' : 'press u to update now';
1065
+ lines.push(' ' + yellow(`↑ Update available: v${v.current} → v${v.latest}`) + dim(` · ${how}`));
1014
1066
  }
1015
1067
  const routing = this.am.routingMode === 'preferred'
1016
1068
  ? `Manual preference: ${this.am.preferredAccountName} (automatic failover)`
@@ -1062,11 +1114,11 @@ export class TUI {
1062
1114
  // misaligned second header).
1063
1115
  lines.push(dimUnderline(acctHeader(W)));
1064
1116
  const showBoth = W >= 70;
1065
- // 61/50 = the fixed pre-bar column span (prefix + Account + Provider + Status +
1066
- // gaps); grew by +4 with the wider 16-col Account column (was 57/46 at NAME_W 12).
1117
+ // 65/54 = the fixed pre-bar column span (prefix + Account + Provider + Status +
1118
+ // gaps); grew by +4 with the wider 20-col Account column (was 61/50 at NAME_W 16).
1067
1119
  const bw = showBoth
1068
- ? Math.max(5, Math.min(20, Math.floor((W - 61) / 2)))
1069
- : Math.max(5, Math.min(20, W - 50));
1120
+ ? Math.max(5, Math.min(20, Math.floor((W - 65) / 2)))
1121
+ : Math.max(5, Math.min(20, W - 54));
1070
1122
 
1071
1123
  // Claude/OAuth accounts first, providers (GLM/Kimi fallback) last — a stable
1072
1124
  // display order over the canonical am.accounts array (which stays untouched so
@@ -1355,7 +1407,11 @@ export class TUI {
1355
1407
  _renderFooter() {
1356
1408
  switch (this.mode) {
1357
1409
  case 'normal':
1358
- return ` ${bold('a')} Accounts ${bold('m')} Routing ${bold('s')} Sync ${bold('r')} Restart ${bold('q')} Stop`;
1410
+ return ` ${bold('a')} Accounts ${bold('m')} Routing ${bold('s')} Sync ${bold('u')} Updates ${bold('r')} Restart ${bold('q')} Stop`;
1411
+ case 'updates': {
1412
+ const state = this._autoUpdateOn() ? green('on') : dim('off');
1413
+ return ` ${bold('c')} Check & apply now ${bold('t')} Automatic updates: ${state} ↻ ${bold('Esc')} Back`;
1414
+ }
1359
1415
  case 'accounts':
1360
1416
  return ` ${bold('l')} Login/re-auth (browser) ${bold('k')} API key ${bold('n')} Rename ${bold('t')} Enable/disable ${bold('d')} Delete ${bold('Esc')} Back`;
1361
1417
  case 'routing': {