maxpool 1.5.77 → 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 (3) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +32 -0
  3. package/src/tui.js +196 -142
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.5.77",
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/index.js CHANGED
@@ -2060,6 +2060,38 @@ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
2060
2060
  if (memConfig.routing.mode === 'preferred' && !preferredApplied) {
2061
2061
  memConfig.routing = { mode: 'automatic', preferredAccount: null };
2062
2062
  }
2063
+
2064
+ // Config PROVIDERS (GLM/Kimi) sync too. Without this a provider added to config —
2065
+ // by the TUI or by hand — stayed invisible until a full restart, because this
2066
+ // function only ever walked `accounts`. Reported 2026-08-10: a new GLM key was in
2067
+ // GCP and in config and served ZERO requests.
2068
+ if (Array.isArray(diskConfig.providers)) {
2069
+ const before = JSON.stringify(memConfig.providers || []);
2070
+ const after = JSON.stringify(diskConfig.providers);
2071
+ if (before !== after) {
2072
+ try {
2073
+ const { resolveSecrets } = await import('./secret-resolver.js');
2074
+ const names = diskConfig.providers.filter(p => p.secretName).map(p => p.secretName);
2075
+ const resolved = await resolveSecrets(names);
2076
+ const entries = diskConfig.providers.map(p => ({
2077
+ ...p,
2078
+ token: p.secretName ? (resolved[p.secretName] || null) : (p.apiKey || null),
2079
+ }));
2080
+ accountManager.loadConfigProviders(entries);
2081
+ for (const p of diskConfig.providers) {
2082
+ const a = accountManager.accounts.find(a => a.name === p.name);
2083
+ if (a && p.secretName) a.secretName = p.secretName;
2084
+ }
2085
+ memConfig.providers = diskConfig.providers;
2086
+ const ok = entries.filter(e => e.token).length;
2087
+ console.log(`[Maxpool] Config providers re-synced from disk: ${ok} active`);
2088
+ added += Math.max(0, entries.length - JSON.parse(before).length);
2089
+ } catch (err) {
2090
+ console.error(`[Maxpool] Provider re-sync failed: ${err.message}`);
2091
+ }
2092
+ }
2093
+ }
2094
+
2063
2095
  return added;
2064
2096
  }
2065
2097
 
package/src/tui.js CHANGED
@@ -257,7 +257,10 @@ export class TUI {
257
257
 
258
258
  this.log = []; // completed activity entries
259
259
  this.active = new Map(); // in-flight requests
260
- this.mode = 'normal'; // normal | accounts | routing | select | input | confirm
260
+ this.mode = 'normal'; // normal | accounts | routing | select | input | confirm | providers | addtype
261
+ // Hide disabled accounts from the table. With 8 dead/disabled accounts the live
262
+ // ones scroll off the top; `h` collapses them to a one-line summary.
263
+ this.hideDisabled = false;
261
264
  this.selAction = null; // prefer | toggle | delete
262
265
  this.selIdx = 0;
263
266
  this.inputPrompt = '';
@@ -405,7 +408,7 @@ export class TUI {
405
408
  case 'accounts': this._keyAccounts(k); break;
406
409
  case 'routing': this._keyRouting(k); break;
407
410
  case 'updates': this._keyUpdates(k); break;
408
- case 'providers': this._keyProviders(k); break;
411
+ case 'addtype': this._keyAddType(k); break;
409
412
  case 'select': this._keySelect(k); break;
410
413
  case 'input': this._keyInput(k); break;
411
414
  case 'confirm': this._keyConfirm(k); break;
@@ -443,8 +446,9 @@ export class TUI {
443
446
  );
444
447
  } else if (k === 'u') {
445
448
  this.mode = 'updates';
446
- } else if (k === 'p') {
447
- this.mode = 'providers';
449
+ } else if (k === 'h') {
450
+ this.hideDisabled = !this.hideDisabled;
451
+ this._addLog(this.hideDisabled ? 'Hiding disabled accounts' : 'Showing all accounts');
448
452
  }
449
453
  // Enable/disable lives ONLY under [a] Accounts now (with rename/delete/login) —
450
454
  // one home for every account mutation, instead of a duplicate top-level toggle.
@@ -555,19 +559,15 @@ export class TUI {
555
559
  }
556
560
 
557
561
  _keyAccounts(k) {
558
- if (k === 'k') {
559
- this.mode = 'input';
560
- this.inputPrompt = 'Anthropic API key';
561
- this.inputBuf = '';
562
- this.inputSensitive = true;
563
- this.inputCb = value => {
564
- if (!value) return;
565
- this._confirm(
566
- 'Add this API key?',
567
- 'Store it in Maxpool config as a new Anthropic API account.',
568
- () => this._doAddKey(value),
569
- );
570
- };
562
+ if (k === 'a') {
563
+ // ONE entry point for every account type. Previously `l` (browser) and `k`
564
+ // (Anthropic API key) were the only options and GLM/Kimi had no path at all —
565
+ // the provider screen existed but nothing in the Accounts UI led to it
566
+ // (reported 2026-08-10: "it only offers anthropic accounts via browser").
567
+ this.mode = 'addtype';
568
+ } else if (k === 'k') {
569
+ // Kept as a shortcut; the `a` flow reaches the same place.
570
+ this._addAccountOfType('anthropic-key');
571
571
  } else if (k === 'l') {
572
572
  this._confirm(
573
573
  'Log in / re-authenticate via browser?',
@@ -585,6 +585,97 @@ export class TUI {
585
585
  }
586
586
  }
587
587
 
588
+ // ── unified add-account flow ────────────────────────────────────────────────
589
+ // Four account types, two credential sources. Every path lands here so there is
590
+ // exactly ONE thing to learn: press `a`, pick a type, supply a credential.
591
+ static ADD_TYPES = [
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: '' },
596
+ ];
597
+
598
+ _keyAddType(k) {
599
+ if (k === 'esc' || k === 'q') { this.mode = 'accounts'; return; }
600
+ const pick = TUI.ADD_TYPES.find(t => t.key === k);
601
+ if (pick) this._addAccountOfType(pick.id);
602
+ }
603
+
604
+ _addAccountOfType(typeId) {
605
+ if (typeId === 'anthropic-oauth') {
606
+ this.mode = 'accounts';
607
+ this._confirm(
608
+ 'Log in / re-authenticate via browser?',
609
+ 'Opens a browser. Logging into an account that is already added RE-AUTHENTICATES it in place — no duplicate.',
610
+ () => this._doLogin(),
611
+ );
612
+ return;
613
+ }
614
+ if (typeId === 'anthropic-key') {
615
+ this.mode = 'input';
616
+ this.inputPrompt = 'Anthropic API key — paste it, or type a GCP secret name';
617
+ this.inputBuf = '';
618
+ this.inputSensitive = false; // a GCP NAME is not a secret; the key is masked on save
619
+ this.inputCb = async value => {
620
+ const input = String(value || '').trim();
621
+ if (!input) { this.mode = 'accounts'; return; }
622
+ const key = await this._resolveCredential(input);
623
+ if (!key) return;
624
+ this.mode = 'accounts';
625
+ this._confirm('Add this API key?', 'Store it in Maxpool config as a new Anthropic API account.',
626
+ () => this._doAddKey(key));
627
+ };
628
+ return;
629
+ }
630
+ // GLM / Kimi — the provider path. Same two credential sources.
631
+ this._providerAddStep('secret', { provider: typeId === 'kimi' ? 'kimi' : 'zai' });
632
+ }
633
+
634
+ /** Accept EITHER a pasted API key or a GCP Secret Manager name, and return the key.
635
+ * A GCP name is UPPER_SNAKE with no dots; real keys carry dots/mixed case. Resolving
636
+ * here (not at each call site) is what makes "type it or point at GCP" one concept. */
637
+ async _resolveCredential(input) {
638
+ const looksLikeSecretName = /^[A-Z][A-Z0-9_-]{2,60}$/.test(input) && !input.includes('.');
639
+ if (!looksLikeSecretName) return input; // a pasted key
640
+ this._addLog(`Resolving GCP secret "${input}"…`);
641
+ this.render();
642
+ try {
643
+ const { resolveSecret } = await import('../secret-resolver.js');
644
+ const v = await resolveSecret(input);
645
+ if (!v) {
646
+ this._addLog(`✗ GCP secret "${input}" not found (or gcloud is not authenticated). ` +
647
+ 'Run: gcloud auth application-default login');
648
+ this.mode = 'accounts';
649
+ return null;
650
+ }
651
+ return v;
652
+ } catch (err) {
653
+ this._addLog(`✗ Could not reach GCP: ${err.message}`);
654
+ this.mode = 'accounts';
655
+ return null;
656
+ }
657
+ }
658
+
659
+ _renderAddType(buf) {
660
+ buf.push(`${bold('Add an account')} ${dim('pick what you have')}`);
661
+ buf.push('');
662
+ for (const t of TUI.ADD_TYPES) {
663
+ buf.push(` ${bold(t.key)} ${t.label.padEnd(26)}${dim(t.hint || '')}`);
664
+ }
665
+ buf.push('');
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.'));
677
+ }
678
+
588
679
  // Derive a human-readable account name from a GCP secret name.
589
680
  // RESTRICTED_AL_MAXPOOL_ZAI → glm al | ZAI_API_KEY → glm primary
590
681
  // RESTRICTED_MAX_KIMI_API_KEY → kimi max | KIMI_API_KEY → kimi primary
@@ -597,17 +688,6 @@ export class TUI {
597
688
  return user ? `${provLabel} ${user}` : `${provLabel} primary`;
598
689
  }
599
690
 
600
- _keyProviders(k) {
601
- if (k === 'a') {
602
- this._providerAddStep('type');
603
- } else if (k === 'd') {
604
- this._startProviderSelection('delete');
605
- } else if (k === 't') {
606
- this._startProviderSelection('toggle');
607
- } else if (k === 'esc' || k === 'q') {
608
- this.mode = 'normal';
609
- }
610
- }
611
691
 
612
692
  // Multi-step input for adding a provider. Steps: type → secret/key → name.
613
693
  // Name LAST so it can be pre-filled from the secret (RESTRICTED_AL_MAXPOOL_ZAI →
@@ -621,17 +701,17 @@ export class TUI {
621
701
  this.inputSensitive = false;
622
702
  this.inputCb = value => {
623
703
  const provider = String(value || '').trim().toLowerCase();
624
- 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; }
625
705
  this._providerAddStep('secret', { ...prev, provider });
626
706
  };
627
707
  } else if (step === 'secret') {
628
708
  this.mode = 'input';
629
- 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`;
630
710
  this.inputBuf = '';
631
711
  this.inputSensitive = false;
632
712
  this.inputCb = async value => {
633
713
  const input = String(value || '').trim();
634
- if (!input) { this.mode = 'providers'; return; }
714
+ if (!input) { this.mode = 'accounts'; return; }
635
715
  // Heuristic: a GCP secret name is uppercase/dashes/underscores and short.
636
716
  // An API key is long and contains dots/mixed-case/alphanumeric.
637
717
  const looksLikeSecretName = /^[A-Z][A-Z0-9_-]{2,60}$/.test(input) && !input.includes('.');
@@ -649,7 +729,7 @@ export class TUI {
649
729
  this.inputCb = async value => {
650
730
  const name = String(value || '').trim() || TUI.deriveProviderName(prev.provider, prev.secretName || '');
651
731
  if (this.am.accounts.some(a => a.name === name)) {
652
- this._addLog(`Account "${name}" already exists`); this.mode = 'providers'; return;
732
+ this._addLog(`Account "${name}" already exists`); this.mode = 'accounts'; return;
653
733
  }
654
734
  await this._doAddProvider({ ...prev, name });
655
735
  };
@@ -657,7 +737,7 @@ export class TUI {
657
737
  }
658
738
 
659
739
  async _doAddProvider({ name, provider, secretName, apiKey }) {
660
- this.mode = 'providers';
740
+ this.mode = 'accounts';
661
741
  if (secretName) this._addLog(`Resolving secret "${secretName}" from GCP…`);
662
742
  else this._addLog(`Adding "${name}" with direct API key…`);
663
743
  this.render();
@@ -692,44 +772,7 @@ export class TUI {
692
772
  }
693
773
  }
694
774
 
695
- _startProviderSelection(action) {
696
- const providers = this.am.accounts.filter(a => a.type === 'provider');
697
- if (!providers.length) { this._addLog('No providers to manage'); return; }
698
- this._selOptions = providers.map(a => a.index);
699
- this._selLabels = providers.map(a => {
700
- const enabled = a.enabled !== false;
701
- const tag = a.configSourced ? ' (GCP)' : ' (header)';
702
- const secret = a.secretName ? ` [${a.secretName}]` : '';
703
- return `${a.name}${tag}${secret}${enabled ? '' : ' ✕'}`;
704
- });
705
- this.selAction = action;
706
- this.mode = 'select';
707
- }
708
775
 
709
- _renderProviders(buf, _width) {
710
- const providers = this.am.accounts.filter(a => a.type === 'provider');
711
- buf.push(`${bold('Providers (GLM / Kimi)')} ${dim('— managed via GCP Secret Manager')}`);
712
- buf.push('');
713
- if (!providers.length) {
714
- buf.push(dim(' No providers configured.'));
715
- buf.push('');
716
- buf.push(dim(' Press ') + bold('a') + dim(' to add one. You\'ll need:'));
717
- buf.push(dim(' 1. An API key from z.ai (GLM) or Moonshot (Kimi)'));
718
- buf.push(dim(' 2. The key stored in GCP: ') + 'gcloud secrets create <name> --data-file=-');
719
- buf.push(dim(' 3. The GCP secret name (e.g. RESTRICTED_MAXPOOL_ZAI_NEW)'));
720
- return;
721
- }
722
- for (const a of providers) {
723
- const enabled = a.enabled !== false;
724
- const tag = a.configSourced ? dim(' (GCP)') : dim(' (header)');
725
- const secret = a.secretName ? dim(` [${a.secretName}]`) : '';
726
- const status = a.status === 'error' ? red(a.lastError || 'error')
727
- : enabled ? green('active') : red('✕');
728
- const q = a.quota;
729
- const ses = q?.providerSes != null ? ` Ses ${Math.round(q.providerSes * 100)}%` : '';
730
- buf.push(` ${enabled ? '' : dim('')} ${bold(a.name)} ${a.provider}${tag}${secret} ${status}${ses}`);
731
- }
732
- }
733
776
 
734
777
  _keyRouting(k) {
735
778
  if (k === 'a') {
@@ -790,51 +833,6 @@ export class TUI {
790
833
  }
791
834
 
792
835
  _keySelect(k) {
793
- // Provider selection (from the providers screen) uses its own option list.
794
- if (this._selOptions && (this.selAction === 'delete' || this.selAction === 'toggle')
795
- && this.mode === 'select' && this.am.accounts[this._selOptions[0]]?.type === 'provider') {
796
- const opts = this._selOptions;
797
- const position = Math.max(0, opts.indexOf(this.selIdx));
798
- if (k === 'up' || k === 'k') this.selIdx = opts[Math.max(0, position - 1)] ?? this.selIdx;
799
- else if (k === 'down' || k === 'j') this.selIdx = opts[Math.min(opts.length - 1, position + 1)] ?? this.selIdx;
800
- else if (k === 'enter') {
801
- const account = this.am.accounts[this.selIdx];
802
- if (!account) { this.mode = 'providers'; return; }
803
- if (this.selAction === 'toggle') {
804
- const enable = !account.enabled;
805
- this._confirm(
806
- `${enable ? 'Enable' : 'Disable'} "${account.name}"?`,
807
- enable ? 'Allow this provider to receive requests again.' : 'Stop routing to it. Active requests continue.',
808
- () => { this._doToggle(this.selIdx, enable); this.mode = 'providers'; },
809
- );
810
- } else if (this.selAction === 'delete') {
811
- this._confirm(
812
- `Delete provider "${account.name}"?`,
813
- account.configSourced
814
- ? 'Removes it from config and GCP reference. The GCP secret itself stays — delete it separately if needed.'
815
- : 'Removes the runtime provider. It returns on the next request that sends its token.',
816
- async () => {
817
- if (account.configSourced) {
818
- try {
819
- const { atomicConfigUpdate } = await import('../config.js');
820
- await atomicConfigUpdate(cfg => {
821
- if (Array.isArray(cfg.providers)) {
822
- cfg.providers = cfg.providers.filter(p => p.name !== account.name);
823
- }
824
- });
825
- } catch (err) { this._addLog(`Config update failed: ${err.message}`); }
826
- }
827
- this.am.removeAccount(this.selIdx);
828
- this._addLog(`Deleted provider "${account.name}"`);
829
- this.mode = 'providers';
830
- },
831
- );
832
- }
833
- }
834
- else if (k === 'esc' || k === 'q') { this.mode = 'providers'; }
835
- return;
836
- }
837
-
838
836
  const selectable = this._selectableIndexes(this.selAction);
839
837
  const position = Math.max(0, selectable.indexOf(this.selIdx));
840
838
  if (k === 'up' || k === 'k') this.selIdx = selectable[Math.max(0, position - 1)] ?? this.selIdx;
@@ -869,9 +867,14 @@ export class TUI {
869
867
  () => this._doToggle(this.selIdx, enable),
870
868
  );
871
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
+ : '';
872
875
  this._confirm(
873
876
  `Delete "${account.name}"?`,
874
- '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}`,
875
878
  () => this._doDelete(this.selIdx),
876
879
  );
877
880
  } else if (this.selAction === 'rename') {
@@ -921,10 +924,16 @@ export class TUI {
921
924
  .map(index => ({ account: this.am.accounts[index], index }))
922
925
  .filter(({ account }) => {
923
926
  if (action === 'prefer') return account.type !== 'provider' && account.enabled;
924
- // Enable/disable also works on runtime providers (GLM/Kimi) — a session-only
925
- // toggle, since they're not in config. Rename/delete stay config-account-only.
926
- if (action === 'toggle') return this._configAccountIndex(account) >= 0 || account.type === 'provider';
927
- 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);
928
937
  })
929
938
  .map(({ index }) => index);
930
939
  }
@@ -1041,16 +1050,24 @@ export class TUI {
1041
1050
  if (this.am.accounts.some((a, i) => i !== idx && a.name === newName)) {
1042
1051
  this._addLog(`An account named "${newName}" already exists`); return;
1043
1052
  }
1044
- const cfgIdx = this._configAccountIndex(account);
1045
- 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;
1046
1063
  const old = account.name;
1047
- const prev = this.config.accounts[cfgIdx].name;
1048
- this.config.accounts[cfgIdx].name = newName;
1064
+ const prev = this.config[cfgArray][cfgIdx].name;
1065
+ this.config[cfgArray][cfgIdx].name = newName;
1049
1066
  if (this.config.routing?.preferredAccount === old) this.config.routing.preferredAccount = newName;
1050
1067
  try {
1051
1068
  await this.saveConfig(this.config);
1052
1069
  } catch (error) {
1053
- this.config.accounts[cfgIdx].name = prev;
1070
+ this.config[cfgArray][cfgIdx].name = prev;
1054
1071
  throw error;
1055
1072
  }
1056
1073
  account.name = newName; // update the running account manager
@@ -1205,6 +1222,30 @@ export class TUI {
1205
1222
  return this.config.accounts.findIndex(candidate => candidate.name === account.name);
1206
1223
  }
1207
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
+
1208
1249
  async _doToggle(idx, enabled) {
1209
1250
  const account = this.am.accounts[idx];
1210
1251
  if (!account) return;
@@ -1250,11 +1291,16 @@ export class TUI {
1250
1291
  this._addLog(`Cannot delete "${name}" while ${account.inFlight} request(s) are active; disable it and retry when idle`);
1251
1292
  return;
1252
1293
  }
1253
- const configIndex = this._configAccountIndex(account);
1254
- if (configIndex < 0) {
1255
- 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)`);
1256
1300
  return;
1257
1301
  }
1302
+ const configIndex = loc.index;
1303
+ const configArray = loc.array;
1258
1304
  const wasEnabled = account.enabled;
1259
1305
  this.am.setAccountEnabled(idx, false);
1260
1306
  if (account.inFlight > 0) {
@@ -1263,7 +1309,7 @@ export class TUI {
1263
1309
  return;
1264
1310
  }
1265
1311
 
1266
- const [removedConfig] = this.config.accounts.splice(configIndex, 1);
1312
+ const [removedConfig] = this.config[configArray].splice(configIndex, 1);
1267
1313
  const previousRouting = this.config.routing;
1268
1314
  if (this.config.routing?.preferredAccount === name) {
1269
1315
  this.config.routing = { mode: 'automatic', preferredAccount: null };
@@ -1271,13 +1317,13 @@ export class TUI {
1271
1317
  try {
1272
1318
  await this.saveConfig(this.config);
1273
1319
  } catch (error) {
1274
- this.config.accounts.splice(configIndex, 0, removedConfig);
1320
+ this.config[configArray].splice(configIndex, 0, removedConfig);
1275
1321
  this.config.routing = previousRouting;
1276
1322
  this.am.setAccountEnabled(idx, wasEnabled);
1277
1323
  throw error;
1278
1324
  }
1279
1325
  if (!this.am.removeAccount(idx)) {
1280
- this.config.accounts.splice(configIndex, 0, removedConfig);
1326
+ this.config[configArray].splice(configIndex, 0, removedConfig);
1281
1327
  this.config.routing = previousRouting;
1282
1328
  this.am.setAccountEnabled(idx, wasEnabled);
1283
1329
  await this.saveConfig(this.config);
@@ -1427,9 +1473,14 @@ export class TUI {
1427
1473
  // display order over the canonical am.accounts array (which stays untouched so
1428
1474
  // routing/index-keyed actions are unaffected). Selection navigation shares the
1429
1475
  // SAME order via _selectableIndexes → _displayOrder.
1476
+ let hidden = 0;
1430
1477
  for (const i of this._displayOrder()) {
1478
+ if (this.hideDisabled && this.am.accounts[i]?.enabled === false) { hidden++; continue; }
1431
1479
  lines.push(this._renderAcct(i, bw, showBoth));
1432
1480
  }
1481
+ if (hidden > 0) {
1482
+ lines.push(` ${dim(`… ${hidden} disabled account${hidden === 1 ? '' : 's'} hidden — press h to show`)}`);
1483
+ }
1433
1484
  // Glossary FOOTER (expands the abbreviations the header + inline labels can't
1434
1485
  // spell out). Below the rows so it never breaks the header↔column alignment.
1435
1486
  if (W >= 88) {
@@ -1464,6 +1515,16 @@ export class TUI {
1464
1515
  lines.push(` ${sp} ${gray(r.t)} ${r.method} ${r.path}${a} ${dim(`(${el}s...)`)}`);
1465
1516
  }
1466
1517
 
1518
+ // Providers panel — BEFORE the log so it lands inside the visible region. It used
1519
+ // to be pushed after the pad-to-full-height loop, so every line fell past the
1520
+ // bottom edge and the screen rendered only its header (reported 2026-08-10:
1521
+ // "when I click on providers I just see the title — how is that helpful?").
1522
+ if (this.mode === 'addtype') {
1523
+ const aLines = [];
1524
+ this._renderAddType(aLines);
1525
+ lines.push('', ...aLines);
1526
+ }
1527
+
1467
1528
  // Completed log
1468
1529
  // 2 = separator + footer; confirm adds its detail line; updates adds its detail block.
1469
1530
  const footerH = this.mode === 'confirm' ? 3
@@ -1477,13 +1538,6 @@ export class TUI {
1477
1538
  // Pad to fill
1478
1539
  while (lines.length < H - footerH) lines.push('');
1479
1540
 
1480
- // Providers panel — shown when the user is on the providers screen
1481
- if (this.mode === 'providers' || this.mode === 'select') {
1482
- const pLines = [];
1483
- this._renderProviders(pLines, W);
1484
- lines.push(...pLines);
1485
- }
1486
-
1487
1541
  // ── Footer
1488
1542
  lines.push(' ' + dim('─'.repeat(W - 2)));
1489
1543
  if (this.mode === 'confirm') lines.push(` ${this.confirmDetail}`);
@@ -1744,15 +1798,15 @@ export class TUI {
1744
1798
  _renderFooter() {
1745
1799
  switch (this.mode) {
1746
1800
  case 'normal':
1747
- return ` ${bold('a')} Accounts ${bold('p')} Providers ${bold('m')} Routing ${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`;
1748
1802
  case 'updates': {
1749
1803
  const state = this._autoUpdateOn() ? green('on') : dim('off');
1750
1804
  return ` ${bold('c')} Check & apply now ${bold('t')} Automatic updates: ${state} ↻ ${bold('Esc')} Back`;
1751
1805
  }
1752
1806
  case 'accounts':
1753
- return ` ${bold('l')} Login/re-auth (browser) ${bold('k')} API key ${bold('n')} Rename ${bold('t')} Enable/disable ${bold('d')} Delete ${bold('Esc')} Back`;
1754
- case 'providers':
1755
- return ` ${bold('a')} Add provider ${bold('d')} Delete ${bold('t')} Enable/disable ${bold('Esc')} Back`;
1807
+ return ` ${bold('a')} Add account ${bold('l')} Re-auth (browser) ${bold('n')} Rename ${bold('t')} Enable/disable ${bold('d')} Delete ${bold('Esc')} Back`;
1808
+ case 'addtype':
1809
+ return ` ${bold('1')}-${bold('4')} pick a type ${bold('Esc')} Back`;
1756
1810
  case 'routing': {
1757
1811
  // Show the CURRENT cross-provider policy inline so pressing f visibly changes it
1758
1812
  // right here at the footer (the policy also renders in the header, far from the