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.
- package/package.json +1 -1
- package/src/index.js +32 -0
- package/src/tui.js +196 -142
package/package.json
CHANGED
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 '
|
|
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 === '
|
|
447
|
-
this.
|
|
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 === '
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
this.
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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 = '
|
|
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.
|
|
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 = '
|
|
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 = '
|
|
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 = '
|
|
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
|
-
|
|
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
|
|
925
|
-
//
|
|
926
|
-
|
|
927
|
-
|
|
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
|
|
1045
|
-
if (
|
|
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
|
|
1048
|
-
this.config
|
|
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
|
|
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
|
|
1254
|
-
if (
|
|
1255
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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('
|
|
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('
|
|
1754
|
-
case '
|
|
1755
|
-
return ` ${bold('
|
|
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
|