maxpool 1.5.78 → 1.5.79

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/tui.js +87 -140
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.5.78",
3
+ "version": "1.5.79",
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/tui.js CHANGED
@@ -408,7 +408,6 @@ export class TUI {
408
408
  case 'accounts': this._keyAccounts(k); break;
409
409
  case 'routing': this._keyRouting(k); break;
410
410
  case 'updates': this._keyUpdates(k); break;
411
- case 'providers': this._keyProviders(k); break;
412
411
  case 'addtype': this._keyAddType(k); break;
413
412
  case 'select': this._keySelect(k); break;
414
413
  case 'input': this._keyInput(k); break;
@@ -447,8 +446,6 @@ export class TUI {
447
446
  );
448
447
  } else if (k === 'u') {
449
448
  this.mode = 'updates';
450
- } else if (k === 'p') {
451
- this.mode = 'providers';
452
449
  } else if (k === 'h') {
453
450
  this.hideDisabled = !this.hideDisabled;
454
451
  this._addLog(this.hideDisabled ? 'Hiding disabled accounts' : 'Showing all accounts');
@@ -592,10 +589,10 @@ export class TUI {
592
589
  // Four account types, two credential sources. Every path lands here so there is
593
590
  // exactly ONE thing to learn: press `a`, pick a type, supply a credential.
594
591
  static ADD_TYPES = [
595
- { key: '1', id: 'anthropic-oauth', label: 'Anthropic subscription (browser login)' },
596
- { key: '2', id: 'anthropic-key', label: 'Anthropic API key' },
597
- { key: '3', id: 'zai', label: 'GLM / z.ai API key' },
598
- { key: '4', id: 'kimi', label: 'Kimi / Moonshot API key' },
592
+ { key: '1', id: 'anthropic-oauth', label: 'Anthropic subscription', hint: 'log in with a browser (Max / Pro plan)' },
593
+ { key: '2', id: 'anthropic-key', label: 'Anthropic API key', hint: 'pay-per-token key from console.anthropic.com' },
594
+ { key: '3', id: 'zai', label: 'GLM (z.ai) API key', hint: '' },
595
+ { key: '4', id: 'kimi', label: 'Kimi (Moonshot) API key', hint: '' },
599
596
  ];
600
597
 
601
598
  _keyAddType(k) {
@@ -660,15 +657,23 @@ export class TUI {
660
657
  }
661
658
 
662
659
  _renderAddType(buf) {
663
- buf.push(`${bold('Add an account')} ${dim('— pick a type')}`);
660
+ buf.push(`${bold('Add an account')} ${dim('pick what you have')}`);
664
661
  buf.push('');
665
662
  for (const t of TUI.ADD_TYPES) {
666
- buf.push(` ${bold(t.key)} ${t.label}`);
663
+ buf.push(` ${bold(t.key)} ${t.label.padEnd(26)}${dim(t.hint || '')}`);
667
664
  }
668
665
  buf.push('');
669
- buf.push(dim(' API keys: paste the key, OR type a GCP Secret Manager name'));
670
- buf.push(dim(' (e.g. RESTRICTED_MAX_MAXPOOL_ZAI_PLUS1) to keep it out of your config.'));
671
- buf.push(dim(' GCP needs: gcloud auth application-default login'));
666
+ buf.push(dim(' Fixing an account that says "reauth"? Pick 1 and log in as that account.'));
667
+ buf.push(dim(' It repairs the existing row, it does not add a second one.'));
668
+ buf.push('');
669
+ // Kept verbatim from the deleted Providers panel — the only place that explained
670
+ // BOTH credential sources and the gcloud command a non-owner needs.
671
+ buf.push(dim(' For 2-4, two ways to supply the key:'));
672
+ buf.push(dim(' A) Paste it — stored in your config (0600). No cloud setup.'));
673
+ buf.push(dim(' B) Type a GCP Secret Manager name — the key never touches disk.'));
674
+ buf.push(dim(' Store it: ') + 'gcloud secrets create MY_KEY --data-file=-');
675
+ buf.push(dim(' Maxpool reads it as YOU: ') + 'gcloud auth application-default login');
676
+ buf.push(dim(' Delete the secret and the account stops working everywhere.'));
672
677
  }
673
678
 
674
679
  // Derive a human-readable account name from a GCP secret name.
@@ -683,17 +688,6 @@ export class TUI {
683
688
  return user ? `${provLabel} ${user}` : `${provLabel} primary`;
684
689
  }
685
690
 
686
- _keyProviders(k) {
687
- if (k === 'a') {
688
- this._providerAddStep('type');
689
- } else if (k === 'd') {
690
- this._startProviderSelection('delete');
691
- } else if (k === 't') {
692
- this._startProviderSelection('toggle');
693
- } else if (k === 'esc' || k === 'q') {
694
- this.mode = 'normal';
695
- }
696
- }
697
691
 
698
692
  // Multi-step input for adding a provider. Steps: type → secret/key → name.
699
693
  // Name LAST so it can be pre-filled from the secret (RESTRICTED_AL_MAXPOOL_ZAI →
@@ -707,17 +701,17 @@ export class TUI {
707
701
  this.inputSensitive = false;
708
702
  this.inputCb = value => {
709
703
  const provider = String(value || '').trim().toLowerCase();
710
- if (provider !== 'zai' && provider !== 'kimi') { this._addLog('Type must be zai or kimi'); this.mode = 'providers'; return; }
704
+ if (provider !== 'zai' && provider !== 'kimi') { this._addLog('Type must be zai or kimi'); this.mode = 'accounts'; return; }
711
705
  this._providerAddStep('secret', { ...prev, provider });
712
706
  };
713
707
  } else if (step === 'secret') {
714
708
  this.mode = 'input';
715
- this.inputPrompt = `${prev.name}: GCP secret name OR paste API key directly`;
709
+ this.inputPrompt = `${prev.provider === 'kimi' ? 'Kimi' : 'GLM'} key — paste it, or type a GCP secret name`;
716
710
  this.inputBuf = '';
717
711
  this.inputSensitive = false;
718
712
  this.inputCb = async value => {
719
713
  const input = String(value || '').trim();
720
- if (!input) { this.mode = 'providers'; return; }
714
+ if (!input) { this.mode = 'accounts'; return; }
721
715
  // Heuristic: a GCP secret name is uppercase/dashes/underscores and short.
722
716
  // An API key is long and contains dots/mixed-case/alphanumeric.
723
717
  const looksLikeSecretName = /^[A-Z][A-Z0-9_-]{2,60}$/.test(input) && !input.includes('.');
@@ -735,7 +729,7 @@ export class TUI {
735
729
  this.inputCb = async value => {
736
730
  const name = String(value || '').trim() || TUI.deriveProviderName(prev.provider, prev.secretName || '');
737
731
  if (this.am.accounts.some(a => a.name === name)) {
738
- this._addLog(`Account "${name}" already exists`); this.mode = 'providers'; return;
732
+ this._addLog(`Account "${name}" already exists`); this.mode = 'accounts'; return;
739
733
  }
740
734
  await this._doAddProvider({ ...prev, name });
741
735
  };
@@ -743,7 +737,7 @@ export class TUI {
743
737
  }
744
738
 
745
739
  async _doAddProvider({ name, provider, secretName, apiKey }) {
746
- this.mode = 'providers';
740
+ this.mode = 'accounts';
747
741
  if (secretName) this._addLog(`Resolving secret "${secretName}" from GCP…`);
748
742
  else this._addLog(`Adding "${name}" with direct API key…`);
749
743
  this.render();
@@ -778,50 +772,7 @@ export class TUI {
778
772
  }
779
773
  }
780
774
 
781
- _startProviderSelection(action) {
782
- const providers = this.am.accounts.filter(a => a.type === 'provider');
783
- if (!providers.length) { this._addLog('No providers to manage'); return; }
784
- this._selOptions = providers.map(a => a.index);
785
- this._selLabels = providers.map(a => {
786
- const enabled = a.enabled !== false;
787
- const tag = a.configSourced ? ' (GCP)' : ' (header)';
788
- const secret = a.secretName ? ` [${a.secretName}]` : '';
789
- return `${a.name}${tag}${secret}${enabled ? '' : ' ✕'}`;
790
- });
791
- this.selAction = action;
792
- this.mode = 'select';
793
- }
794
775
 
795
- _renderProviders(buf, _width) {
796
- const providers = this.am.accounts.filter(a => a.type === 'provider');
797
- buf.push(`${bold('Providers (GLM / Kimi)')} ${dim('— managed via GCP Secret Manager')}`);
798
- buf.push('');
799
- if (!providers.length) {
800
- buf.push(dim(' No providers configured.'));
801
- buf.push('');
802
- buf.push(dim(' Press ') + bold('a') + dim(' to add one. Two ways to supply the key:'));
803
- buf.push('');
804
- buf.push(dim(' A) Paste the API key directly — stored in your config (0600).'));
805
- buf.push(dim(' Simplest; works with no cloud setup.'));
806
- buf.push('');
807
- buf.push(dim(' B) Point at a GCP Secret Manager name — the key never touches disk.'));
808
- buf.push(dim(' Store it: ') + 'gcloud secrets create MY_KEY --data-file=-');
809
- buf.push(dim(' Maxpool resolves it via YOUR gcloud login:'));
810
- buf.push(dim(' ') + 'gcloud auth application-default login');
811
- buf.push(dim(' Delete the secret and the provider stops working everywhere.'));
812
- return;
813
- }
814
- for (const a of providers) {
815
- const enabled = a.enabled !== false;
816
- const tag = a.configSourced ? dim(' (GCP)') : dim(' (header)');
817
- const secret = a.secretName ? dim(` [${a.secretName}]`) : '';
818
- const status = a.status === 'error' ? red(a.lastError || 'error')
819
- : enabled ? green('active') : red('✕');
820
- const q = a.quota;
821
- const ses = q?.providerSes != null ? ` Ses ${Math.round(q.providerSes * 100)}%` : '';
822
- buf.push(` ${enabled ? '' : dim('')} ${bold(a.name)} ${a.provider}${tag}${secret} ${status}${ses}`);
823
- }
824
- }
825
776
 
826
777
  _keyRouting(k) {
827
778
  if (k === 'a') {
@@ -882,51 +833,6 @@ export class TUI {
882
833
  }
883
834
 
884
835
  _keySelect(k) {
885
- // Provider selection (from the providers screen) uses its own option list.
886
- if (this._selOptions && (this.selAction === 'delete' || this.selAction === 'toggle')
887
- && this.mode === 'select' && this.am.accounts[this._selOptions[0]]?.type === 'provider') {
888
- const opts = this._selOptions;
889
- const position = Math.max(0, opts.indexOf(this.selIdx));
890
- if (k === 'up' || k === 'k') this.selIdx = opts[Math.max(0, position - 1)] ?? this.selIdx;
891
- else if (k === 'down' || k === 'j') this.selIdx = opts[Math.min(opts.length - 1, position + 1)] ?? this.selIdx;
892
- else if (k === 'enter') {
893
- const account = this.am.accounts[this.selIdx];
894
- if (!account) { this.mode = 'providers'; return; }
895
- if (this.selAction === 'toggle') {
896
- const enable = !account.enabled;
897
- this._confirm(
898
- `${enable ? 'Enable' : 'Disable'} "${account.name}"?`,
899
- enable ? 'Allow this provider to receive requests again.' : 'Stop routing to it. Active requests continue.',
900
- () => { this._doToggle(this.selIdx, enable); this.mode = 'providers'; },
901
- );
902
- } else if (this.selAction === 'delete') {
903
- this._confirm(
904
- `Delete provider "${account.name}"?`,
905
- account.configSourced
906
- ? 'Removes it from config and GCP reference. The GCP secret itself stays — delete it separately if needed.'
907
- : 'Removes the runtime provider. It returns on the next request that sends its token.',
908
- async () => {
909
- if (account.configSourced) {
910
- try {
911
- const { atomicConfigUpdate } = await import('../config.js');
912
- await atomicConfigUpdate(cfg => {
913
- if (Array.isArray(cfg.providers)) {
914
- cfg.providers = cfg.providers.filter(p => p.name !== account.name);
915
- }
916
- });
917
- } catch (err) { this._addLog(`Config update failed: ${err.message}`); }
918
- }
919
- this.am.removeAccount(this.selIdx);
920
- this._addLog(`Deleted provider "${account.name}"`);
921
- this.mode = 'providers';
922
- },
923
- );
924
- }
925
- }
926
- else if (k === 'esc' || k === 'q') { this.mode = 'providers'; }
927
- return;
928
- }
929
-
930
836
  const selectable = this._selectableIndexes(this.selAction);
931
837
  const position = Math.max(0, selectable.indexOf(this.selIdx));
932
838
  if (k === 'up' || k === 'k') this.selIdx = selectable[Math.max(0, position - 1)] ?? this.selIdx;
@@ -961,9 +867,14 @@ export class TUI {
961
867
  () => this._doToggle(this.selIdx, enable),
962
868
  );
963
869
  } else if (this.selAction === 'delete') {
870
+ // A GCP-backed row: say what deleting does NOT do, so nobody thinks the key
871
+ // is gone from the cloud.
872
+ const secret = account.secretName
873
+ ? ` The GCP secret "${account.secretName}" is left alone — delete it separately if you want the key gone.`
874
+ : '';
964
875
  this._confirm(
965
876
  `Delete "${account.name}"?`,
966
- 'Permanently remove it from Maxpool config. Deletion is blocked while it has active requests.',
877
+ `Permanently remove it from Maxpool config. Deletion is blocked while it has active requests.${secret}`,
967
878
  () => this._doDelete(this.selIdx),
968
879
  );
969
880
  } else if (this.selAction === 'rename') {
@@ -1013,10 +924,16 @@ export class TUI {
1013
924
  .map(index => ({ account: this.am.accounts[index], index }))
1014
925
  .filter(({ account }) => {
1015
926
  if (action === 'prefer') return account.type !== 'provider' && account.enabled;
1016
- // Enable/disable also works on runtime providers (GLM/Kimi) — a session-only
1017
- // toggle, since they're not in config. Rename/delete stay config-account-only.
1018
- if (action === 'toggle') return this._configAccountIndex(account) >= 0 || account.type === 'provider';
1019
- return this._configAccountIndex(account) >= 0;
927
+ // Enable/disable works on EVERY row — including a session-created provider,
928
+ // where it is the DURABLE action (exportRuntimeProviders persists `enabled`,
929
+ // and the header path never re-enables a row the user benched).
930
+ if (action === 'toggle') return true;
931
+ // Rename/delete need a config entry to act on. That now includes config
932
+ // PROVIDERS (via _isConfigBacked), which the old accounts-only lookup excluded.
933
+ // A SESSION row is still barred: renaming it forks a duplicate (upsertRuntime
934
+ // Account matches by NAME, so the next header request recreates the original
935
+ // and the same key lands on two accounts), and deleting it undoes itself.
936
+ return this._isConfigBacked(account);
1020
937
  })
1021
938
  .map(({ index }) => index);
1022
939
  }
@@ -1133,16 +1050,24 @@ export class TUI {
1133
1050
  if (this.am.accounts.some((a, i) => i !== idx && a.name === newName)) {
1134
1051
  this._addLog(`An account named "${newName}" already exists`); return;
1135
1052
  }
1136
- const cfgIdx = this._configAccountIndex(account);
1137
- if (cfgIdx < 0) { this._addLog(`Cannot rename "${account.name}" (not in config)`); return; }
1053
+ const loc = this._configLocation(account);
1054
+ if (!loc) {
1055
+ // Renaming a SESSION row forks it: upsertRuntimeAccount matches by NAME, so the
1056
+ // next `cc all` request recreates the original and the same key ends up on two
1057
+ // accounts — double-counted quota and routing weight.
1058
+ this._addLog(`Cannot rename "${account.name}" — it comes from a running cc session, not your config`);
1059
+ return;
1060
+ }
1061
+ const cfgIdx = loc.index;
1062
+ const cfgArray = loc.array;
1138
1063
  const old = account.name;
1139
- const prev = this.config.accounts[cfgIdx].name;
1140
- this.config.accounts[cfgIdx].name = newName;
1064
+ const prev = this.config[cfgArray][cfgIdx].name;
1065
+ this.config[cfgArray][cfgIdx].name = newName;
1141
1066
  if (this.config.routing?.preferredAccount === old) this.config.routing.preferredAccount = newName;
1142
1067
  try {
1143
1068
  await this.saveConfig(this.config);
1144
1069
  } catch (error) {
1145
- this.config.accounts[cfgIdx].name = prev;
1070
+ this.config[cfgArray][cfgIdx].name = prev;
1146
1071
  throw error;
1147
1072
  }
1148
1073
  account.name = newName; // update the running account manager
@@ -1297,6 +1222,30 @@ export class TUI {
1297
1222
  return this.config.accounts.findIndex(candidate => candidate.name === account.name);
1298
1223
  }
1299
1224
 
1225
+ /** Where does this account live in config — `accounts` or `providers`?
1226
+ * A config PROVIDER (GLM/Kimi from config.providers) is just as durable as an OAuth
1227
+ * account, but _configAccountIndex only ever searched config.accounts, so providers
1228
+ * reported -1 and were excluded from rename/delete. With the Providers screen gone,
1229
+ * that would strand them in config forever. Returns { array, index } or null. */
1230
+ _configLocation(account) {
1231
+ if (!account) return null;
1232
+ const accIdx = this._configAccountIndex(account);
1233
+ if (accIdx >= 0) return { array: 'accounts', index: accIdx };
1234
+ const provs = this.config.providers;
1235
+ if (Array.isArray(provs)) {
1236
+ const pIdx = provs.findIndex(p => p.name === account.name);
1237
+ if (pIdx >= 0) return { array: 'providers', index: pIdx };
1238
+ }
1239
+ return null;
1240
+ }
1241
+
1242
+ /** True when this row can be permanently removed/renamed — i.e. it is backed by a
1243
+ * config entry. A SESSION row (created from `cc all` headers, not in config) is not:
1244
+ * deleting it is a lie because the next request recreates it. */
1245
+ _isConfigBacked(account) {
1246
+ return this._configLocation(account) !== null;
1247
+ }
1248
+
1300
1249
  async _doToggle(idx, enabled) {
1301
1250
  const account = this.am.accounts[idx];
1302
1251
  if (!account) return;
@@ -1342,11 +1291,16 @@ export class TUI {
1342
1291
  this._addLog(`Cannot delete "${name}" while ${account.inFlight} request(s) are active; disable it and retry when idle`);
1343
1292
  return;
1344
1293
  }
1345
- const configIndex = this._configAccountIndex(account);
1346
- if (configIndex < 0) {
1347
- this._addLog(`Cannot permanently delete runtime provider "${name}" from the TUI`);
1294
+ const loc = this._configLocation(account);
1295
+ if (!loc) {
1296
+ // A SESSION row: a running `cc all` is handing maxpool this key, so deleting it
1297
+ // would be undone by the next request. Disabling IS durable here — the header
1298
+ // path never re-enables a benched row — so point the user at the action that works.
1299
+ this._addLog(`"${name}" comes from a running cc session, so deleting it would not stick — press t to switch it off instead (that survives restarts)`);
1348
1300
  return;
1349
1301
  }
1302
+ const configIndex = loc.index;
1303
+ const configArray = loc.array;
1350
1304
  const wasEnabled = account.enabled;
1351
1305
  this.am.setAccountEnabled(idx, false);
1352
1306
  if (account.inFlight > 0) {
@@ -1355,7 +1309,7 @@ export class TUI {
1355
1309
  return;
1356
1310
  }
1357
1311
 
1358
- const [removedConfig] = this.config.accounts.splice(configIndex, 1);
1312
+ const [removedConfig] = this.config[configArray].splice(configIndex, 1);
1359
1313
  const previousRouting = this.config.routing;
1360
1314
  if (this.config.routing?.preferredAccount === name) {
1361
1315
  this.config.routing = { mode: 'automatic', preferredAccount: null };
@@ -1363,13 +1317,13 @@ export class TUI {
1363
1317
  try {
1364
1318
  await this.saveConfig(this.config);
1365
1319
  } catch (error) {
1366
- this.config.accounts.splice(configIndex, 0, removedConfig);
1320
+ this.config[configArray].splice(configIndex, 0, removedConfig);
1367
1321
  this.config.routing = previousRouting;
1368
1322
  this.am.setAccountEnabled(idx, wasEnabled);
1369
1323
  throw error;
1370
1324
  }
1371
1325
  if (!this.am.removeAccount(idx)) {
1372
- this.config.accounts.splice(configIndex, 0, removedConfig);
1326
+ this.config[configArray].splice(configIndex, 0, removedConfig);
1373
1327
  this.config.routing = previousRouting;
1374
1328
  this.am.setAccountEnabled(idx, wasEnabled);
1375
1329
  await this.saveConfig(this.config);
@@ -1565,11 +1519,6 @@ export class TUI {
1565
1519
  // to be pushed after the pad-to-full-height loop, so every line fell past the
1566
1520
  // bottom edge and the screen rendered only its header (reported 2026-08-10:
1567
1521
  // "when I click on providers I just see the title — how is that helpful?").
1568
- if (this.mode === 'providers') {
1569
- const pLines = [];
1570
- this._renderProviders(pLines, W);
1571
- lines.push('', ...pLines);
1572
- }
1573
1522
  if (this.mode === 'addtype') {
1574
1523
  const aLines = [];
1575
1524
  this._renderAddType(aLines);
@@ -1849,7 +1798,7 @@ export class TUI {
1849
1798
  _renderFooter() {
1850
1799
  switch (this.mode) {
1851
1800
  case 'normal':
1852
- return ` ${bold('a')} Accounts ${bold('p')} Providers ${bold('m')} Routing ${bold('h')} Hide disabled ${bold('s')} Sync ${bold('u')} Updates ${bold('r')} Restart ${bold('q')} Stop`;
1801
+ return ` ${bold('a')} Accounts ${bold('m')} Routing ${bold('h')} Hide disabled ${dim('│')} ${bold('u')} Updates ${bold('r')} Restart ${bold('q')} Stop server`;
1853
1802
  case 'updates': {
1854
1803
  const state = this._autoUpdateOn() ? green('on') : dim('off');
1855
1804
  return ` ${bold('c')} Check & apply now ${bold('t')} Automatic updates: ${state} ↻ ${bold('Esc')} Back`;
@@ -1858,8 +1807,6 @@ export class TUI {
1858
1807
  return ` ${bold('a')} Add account ${bold('l')} Re-auth (browser) ${bold('n')} Rename ${bold('t')} Enable/disable ${bold('d')} Delete ${bold('Esc')} Back`;
1859
1808
  case 'addtype':
1860
1809
  return ` ${bold('1')}-${bold('4')} pick a type ${bold('Esc')} Back`;
1861
- case 'providers':
1862
- return ` ${bold('a')} Add provider ${bold('d')} Delete ${bold('t')} Enable/disable ${bold('Esc')} Back`;
1863
1810
  case 'routing': {
1864
1811
  // Show the CURRENT cross-provider policy inline so pressing f visibly changes it
1865
1812
  // right here at the footer (the policy also renders in the header, far from the