maxpool 1.0.0

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/src/tui.js ADDED
@@ -0,0 +1,958 @@
1
+ import { importCredentials, fetchProfile } from './oauth.js';
2
+
3
+ // ── ANSI helpers ─────────────────────────────────────────────
4
+
5
+ const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'.split('');
6
+ const ESC = '\x1b[';
7
+ const RESET = `${ESC}0m`;
8
+ const BOLD = `${ESC}1m`;
9
+ const DIM = `${ESC}2m`;
10
+
11
+ const bold = s => `${BOLD}${s}${RESET}`;
12
+ const dim = s => `${DIM}${s}${RESET}`;
13
+ const fg = (c, s) => `${ESC}${c}m${s}${RESET}`;
14
+ const green = s => fg(32, s);
15
+ const yellow = s => fg(33, s);
16
+ const red = s => fg(31, s);
17
+ const cyan = s => fg(36, s);
18
+ const gray = s => fg(90, s);
19
+
20
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
21
+ const strip = s => s.replace(ANSI_RE, '');
22
+ const vw = s => strip(s).length;
23
+
24
+ function rpad(s, w) {
25
+ const gap = w - vw(s);
26
+ return gap > 0 ? s + ' '.repeat(gap) : s;
27
+ }
28
+
29
+ /** Truncate a string with ANSI codes to exactly w visible characters, then reset. */
30
+ function truncate(s, w) {
31
+ let visible = 0;
32
+ let out = '';
33
+ let i = 0;
34
+ while (i < s.length && visible < w) {
35
+ if (s[i] === '\x1b') {
36
+ const end = s.indexOf('m', i);
37
+ if (end >= 0) { out += s.slice(i, end + 1); i = end + 1; continue; }
38
+ }
39
+ out += s[i];
40
+ visible++;
41
+ i++;
42
+ }
43
+ return out + RESET;
44
+ }
45
+
46
+ /** Fit a line to exactly w columns: truncate if too long, pad if too short. */
47
+ function fitLine(s, w) {
48
+ const v = vw(s);
49
+ if (v > w) return truncate(s, w);
50
+ if (v < w) return s + ' '.repeat(w - v);
51
+ return s;
52
+ }
53
+
54
+ function formatReset(resetTs) {
55
+ if (!resetTs) return '';
56
+ const ms = resetTs - Date.now();
57
+ if (ms <= 0) return '';
58
+ const mins = Math.ceil(ms / 60000);
59
+ if (mins < 60) return `${mins}m`;
60
+ const hrs = Math.floor(mins / 60);
61
+ const rm = mins % 60;
62
+ if (hrs < 24) return rm > 0 ? `${hrs}h${rm}m` : `${hrs}h`;
63
+ const days = Math.floor(hrs / 24);
64
+ const rh = hrs % 24;
65
+ return rh > 0 ? `${days}d${rh}h` : `${days}d`;
66
+ }
67
+
68
+ function quotaLabel(ratio, resetTs, width) {
69
+ const rst = formatReset(resetTs);
70
+ if (ratio == null || isNaN(ratio)) return (rst || '-').slice(0, width);
71
+ const pct = `${Math.max(0, Math.min(100, ratio * 100)).toFixed(0)}%`;
72
+ const full = rst ? `${pct} ${rst}` : pct;
73
+ if (full.length <= width) return full;
74
+ return (rst || pct).slice(0, width);
75
+ }
76
+
77
+ function formatMs(ms) {
78
+ if (ms == null || isNaN(ms)) return '-';
79
+ if (ms < 1000) return `${Math.round(ms)}ms`;
80
+ const sec = ms / 1000;
81
+ if (sec < 60) return `${sec.toFixed(1)}s`;
82
+ const min = Math.floor(sec / 60);
83
+ const rem = Math.round(sec % 60);
84
+ return `${min}m${String(rem).padStart(2, '0')}s`;
85
+ }
86
+
87
+ function statusColor(status) {
88
+ if (status == null) return '-';
89
+ if (status >= 200 && status < 300) return green(String(status));
90
+ if (status === 429) return yellow(String(status));
91
+ if (status >= 500) return red(String(status));
92
+ return String(status);
93
+ }
94
+
95
+ function loadText(load) {
96
+ const cur = load?.current || {};
97
+ const m15 = load?.last15m || {};
98
+ const h1 = load?.last1h || {};
99
+ const current = `${cur.inFlight || 0}/${cur.activeWeight || 0}`;
100
+ const recent = `${m15.requests || 0}r`;
101
+ const recentAvg = m15.avgMs != null ? ` ${formatMs(m15.avgMs)}` : '';
102
+ const hour = `${h1.requests || 0}r`;
103
+ const fails = (m15.failed || 0) > 0 ? ` ${red(`${m15.failed}f`)}` : '';
104
+ return `Load ${current} 15m ${recent}${recentAvg}${fails} 1h ${hour}`;
105
+ }
106
+
107
+ function weeklyPolicyText(am, account) {
108
+ if (!am?._weeklyState || !account || account.type === 'provider') return '';
109
+ const state = am._weeklyState(account);
110
+ if (!state || state === 'unknown' || state === 'normal') return '';
111
+ const rawState = am._weeklyRawState?.(account) || state;
112
+ const used = Number(account.quota?.unified7d);
113
+ const pct = Number.isFinite(used)
114
+ ? ` ${Math.max(0, Math.min(100, used * 100)).toFixed(0)}%`
115
+ : '';
116
+ const paceOnly = state !== rawState && rawState !== 'exhausted';
117
+ const label = paceOnly ? `Pace ${state}` : `Wk ${state}`;
118
+ const text = paceOnly ? label : `${label}${pct}`;
119
+ if (state === 'critical' || state === 'exhausted') return state !== rawState ? yellow(text) : red(text);
120
+ if (state === 'reserve') return yellow(text);
121
+ return cyan(text);
122
+ }
123
+
124
+ /**
125
+ * Render a progress bar using background colors with text overlaid.
126
+ * The label (e.g. "Ses 2h30m" or "45%") is drawn on top of the bar.
127
+ */
128
+ function bar(ratio, w = 10, resetTs) {
129
+ if (ratio == null || isNaN(ratio)) {
130
+ // No data — dim background, show label or dash
131
+ const label = quotaLabel(ratio, resetTs, w);
132
+ const text = label.slice(0, w);
133
+ const pad = w - text.length;
134
+ const lp = Math.floor(pad / 2);
135
+ const rp = pad - lp;
136
+ return `${ESC}100m${' '.repeat(lp)}${text}${' '.repeat(rp)}${RESET}`;
137
+ }
138
+
139
+ ratio = Math.max(0, Math.min(1, ratio));
140
+ const f = Math.round(ratio * w);
141
+ // Background colors: 42=green, 43=yellow, 41=red; 100=bright black (gray) for empty
142
+ const bg = ratio < 0.7 ? 42 : ratio < 0.9 ? 43 : 41;
143
+
144
+ // Build the label to overlay: show both usage and reset when it fits.
145
+ const label = quotaLabel(ratio, resetTs, w);
146
+ const text = label.slice(0, w);
147
+ const pad = w - text.length;
148
+ const lp = Math.floor(pad / 2);
149
+ const rp = pad - lp;
150
+ const chars = (' '.repeat(lp) + text + ' '.repeat(rp));
151
+
152
+ // Split chars into filled (colored bg) and empty (gray bg) portions
153
+ const filled = chars.slice(0, f);
154
+ const empty = chars.slice(f);
155
+
156
+ let out = '';
157
+ if (filled) out += `${ESC}${bg};97m${filled}`;
158
+ if (empty) out += `${ESC}100;37m${empty}`;
159
+ out += RESET;
160
+ return out;
161
+ }
162
+
163
+ export const __tuiTest = { formatReset, quotaLabel, bar, strip };
164
+
165
+ function timestamp() {
166
+ return new Date().toLocaleTimeString('en-US', { hour12: false });
167
+ }
168
+
169
+ // ── TUI class ────────────────────────────────────────────────
170
+
171
+ export class TUI {
172
+ constructor({ accountManager, config, saveConfig, syncAccounts, onQuit, onRestart }) {
173
+ this.am = accountManager;
174
+ this.config = config;
175
+ this.saveConfig = saveConfig;
176
+ this.syncAccounts = syncAccounts;
177
+ this.onQuit = onQuit;
178
+ this.onRestart = onRestart;
179
+
180
+ this.log = []; // completed activity entries
181
+ this.active = new Map(); // in-flight requests
182
+ this.mode = 'normal'; // normal | accounts | routing | select | input | confirm
183
+ this.selAction = null; // prefer | toggle | delete
184
+ this.selIdx = 0;
185
+ this.inputPrompt = '';
186
+ this.inputBuf = '';
187
+ this.inputCb = null;
188
+ this.inputSensitive = false;
189
+ this.confirmTitle = '';
190
+ this.confirmDetail = '';
191
+ this.confirmCb = null;
192
+ this.frame = 0;
193
+ this.running = false;
194
+ this.timer = null;
195
+ this._origLog = null;
196
+ this._origErr = null;
197
+ }
198
+
199
+ // ── lifecycle ──────────────────────────────────────
200
+
201
+ start() {
202
+ try {
203
+ process.stdin.setRawMode(true);
204
+ } catch (err) {
205
+ process.stderr.write(`[Maxpool] TUI unavailable (${err.code || err.message}); continuing with plain logs.\n`);
206
+ return false;
207
+ }
208
+
209
+ this.running = true;
210
+ process.stdout.write(`${ESC}?1049h${ESC}?25l`);
211
+ process.stdin.resume();
212
+ process.stdin.setEncoding('utf8');
213
+ this._dataHandler = d => this._onData(d);
214
+ this._resizeHandler = () => this.render();
215
+ process.stdin.on('data', this._dataHandler);
216
+ process.stdout.on('resize', this._resizeHandler);
217
+
218
+ // Redirect console to activity log
219
+ this._origLog = console.log;
220
+ this._origErr = console.error;
221
+ console.log = (...a) => this._addLog(a.join(' '));
222
+ console.error = (...a) => this._addLog(a.join(' '));
223
+
224
+ this.render();
225
+ this.timer = setInterval(() => {
226
+ this.frame = (this.frame + 1) % SPINNER.length;
227
+ this.render();
228
+ }, 500);
229
+ return true;
230
+ }
231
+
232
+ stop() {
233
+ this.running = false;
234
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
235
+ if (this._origLog) { console.log = this._origLog; console.error = this._origErr; }
236
+ process.stdin.removeListener('data', this._dataHandler);
237
+ process.stdout.removeListener('resize', this._resizeHandler);
238
+ process.stdout.write(`${ESC}?25h${ESC}?1049l`);
239
+ try { process.stdin.setRawMode(false); } catch {}
240
+ process.stdin.pause();
241
+ }
242
+
243
+ // ── server hooks ───────────────────────────────────
244
+
245
+ onRequestStart(id, info) {
246
+ this.active.set(id, { ...info, t: timestamp(), started: Date.now(), account: null });
247
+ this.render();
248
+ }
249
+
250
+ onRequestRouted(id, info) {
251
+ const r = this.active.get(id);
252
+ if (r) r.account = info.account;
253
+ }
254
+
255
+ onRequestEnd(id, info) {
256
+ const r = this.active.get(id);
257
+ this.active.delete(id);
258
+ const dur = r ? ((Date.now() - r.started) / 1000).toFixed(1) : '?';
259
+ const acct = info.account || r?.account || '?';
260
+ this._addLog(`${info.method} ${info.path} → ${acct} (${info.status}, ${dur}s)`);
261
+ }
262
+
263
+ _addLog(msg) {
264
+ msg = msg.replace(/^\[Maxpool\]\s*/, '');
265
+ this.log.unshift({ t: timestamp(), msg });
266
+ if (this.log.length > 200) this.log.length = 200;
267
+ if (this.running) this.render();
268
+ }
269
+
270
+ // ── input handling ─────────────────────────────────
271
+
272
+ _onData(d) {
273
+ if (d === '\x1b[A') return this._key('up');
274
+ if (d === '\x1b[B') return this._key('down');
275
+ if (d === '\x1b') return this._key('esc');
276
+ if (d === '\x03') return this._key('ctrl-c');
277
+ if (d === '\x7f' || d === '\x08') return this._key('bs');
278
+
279
+ // Input mode accepts typed AND pasted text. A terminal delivers a paste as
280
+ // one multi-char chunk — often wrapped in bracketed-paste markers and/or
281
+ // ending in a newline. Sanitize the chunk instead of all-or-nothing
282
+ // rejecting it (which silently dropped pasted API keys): strip the paste
283
+ // markers, take the text up to the first newline, drop control chars, append
284
+ // it, and treat an embedded newline as Enter (submit).
285
+ if (this.mode === 'input') {
286
+ // Prepend any partial escape held from the previous chunk so a bracketed-
287
+ // paste marker split across two stdin reads (e.g. "\x1b[20" then "0~key")
288
+ // is recognized rather than appended as literal text.
289
+ let chunk = (this._pendingPaste || '') + d;
290
+ this._pendingPaste = '';
291
+ chunk = chunk.replace(/\x1b\[20[01]~/g, '');
292
+ // Hold back a trailing fragment that could be the start of a paste marker.
293
+ const partial = chunk.match(/\x1b(?:\[(?:2(?:0[01]?)?)?)?$/);
294
+ if (partial) {
295
+ this._pendingPaste = partial[0];
296
+ chunk = chunk.slice(0, chunk.length - partial[0].length);
297
+ }
298
+ const nlIdx = chunk.search(/[\r\n]/);
299
+ const typed = (nlIdx === -1 ? chunk : chunk.slice(0, nlIdx))
300
+ .split('')
301
+ .filter(c => c >= ' ' && c !== '\x7f')
302
+ .join('');
303
+ if (typed) { this.inputBuf += typed; this.render(); }
304
+ if (nlIdx !== -1) { this._pendingPaste = ''; return this._key('enter'); }
305
+ return;
306
+ }
307
+
308
+ if (d === '\r' || d === '\n') return this._key('enter');
309
+ if (d.length === 1 && d >= ' ') return this._key(d);
310
+ }
311
+
312
+ _key(k) {
313
+ if (k === 'ctrl-c') { this.stop(); this.onQuit?.(); return; }
314
+
315
+ switch (this.mode) {
316
+ case 'normal': this._keyNormal(k); break;
317
+ case 'accounts': this._keyAccounts(k); break;
318
+ case 'routing': this._keyRouting(k); break;
319
+ case 'select': this._keySelect(k); break;
320
+ case 'input': this._keyInput(k); break;
321
+ case 'confirm': this._keyConfirm(k); break;
322
+ }
323
+ this.render();
324
+ }
325
+
326
+ _keyNormal(k) {
327
+ if (k === 'q') {
328
+ this._confirm(
329
+ 'Stop Maxpool?',
330
+ 'New requests will stop; active requests will drain before the server exits.',
331
+ () => { this.stop(); this.onQuit?.(); },
332
+ );
333
+ } else if (k === 'r') {
334
+ this._confirm(
335
+ 'Restart Maxpool?',
336
+ 'Pause new requests, drain active work, then start the updated server.',
337
+ () => { this.stop(); this.onRestart?.(); },
338
+ );
339
+ } else if (k === 'a') {
340
+ this.mode = 'accounts';
341
+ } else if (k === 'm') {
342
+ this.mode = 'routing';
343
+ } else if (k === 's') {
344
+ this._confirm(
345
+ 'Sync accounts now?',
346
+ 'Reload account credentials and newly added accounts from the config file.',
347
+ () => this._doSync(),
348
+ );
349
+ }
350
+ }
351
+
352
+ _keyAccounts(k) {
353
+ if (k === 'i') {
354
+ this._confirm(
355
+ 'Import current Claude login?',
356
+ 'Add or update the account currently logged into Claude Code.',
357
+ () => this._doImport(),
358
+ );
359
+ } else if (k === 'k') {
360
+ this.mode = 'input';
361
+ this.inputPrompt = 'Anthropic API key';
362
+ this.inputBuf = '';
363
+ this.inputSensitive = true;
364
+ this.inputCb = value => {
365
+ if (!value) return;
366
+ this._confirm(
367
+ 'Add this API key?',
368
+ 'Store it in Maxpool config as a new Anthropic API account.',
369
+ () => this._doAddKey(value),
370
+ );
371
+ };
372
+ } else if (k === 't' && this.am.accounts.length > 0) {
373
+ this._startSelection('toggle');
374
+ } else if (k === 'd' && this.am.accounts.length > 0) {
375
+ this._startSelection('delete');
376
+ } else if (k === 'esc' || k === 'q') {
377
+ this.mode = 'normal';
378
+ }
379
+ }
380
+
381
+ _keyRouting(k) {
382
+ if (k === 'a') {
383
+ this._confirm(
384
+ 'Use automatic routing?',
385
+ 'Spread new requests across healthy accounts using load, quota, and recent errors.',
386
+ () => this._setAutomaticRouting(),
387
+ );
388
+ } else if (k === 'p' && this.am.accounts.some(account => account.type !== 'provider')) {
389
+ this._startSelection('prefer');
390
+ } else if (k === 'esc' || k === 'q') {
391
+ this.mode = 'normal';
392
+ }
393
+ }
394
+
395
+ _keySelect(k) {
396
+ const selectable = this._selectableIndexes(this.selAction);
397
+ const position = Math.max(0, selectable.indexOf(this.selIdx));
398
+ if (k === 'up' || k === 'k') this.selIdx = selectable[Math.max(0, position - 1)] ?? this.selIdx;
399
+ else if (k === 'down' || k === 'j') this.selIdx = selectable[Math.min(selectable.length - 1, position + 1)] ?? this.selIdx;
400
+ else if (k === 'enter') {
401
+ const account = this.am.accounts[this.selIdx];
402
+ if (!account) {
403
+ this.mode = 'normal';
404
+ return;
405
+ }
406
+ if (this.selAction === 'prefer') {
407
+ if (account.type === 'provider') {
408
+ this._addLog('Manual preference is available only for Claude accounts');
409
+ return;
410
+ }
411
+ if (!account.enabled) {
412
+ this._addLog(`Enable "${account.name}" before selecting it as the manual preference`);
413
+ return;
414
+ }
415
+ this._confirm(
416
+ `Use manual preference for "${account.name}"?`,
417
+ 'Move idle sessions on their next request; fail over and return automatically.',
418
+ () => this._setPreferredRouting(account.name),
419
+ );
420
+ } else if (this.selAction === 'toggle') {
421
+ const enable = !account.enabled;
422
+ this._confirm(
423
+ `${enable ? 'Enable' : 'Disable'} "${account.name}"?`,
424
+ enable
425
+ ? 'Allow this account to receive new requests again.'
426
+ : 'Stop assigning new requests to it. Active requests will continue.',
427
+ () => this._doToggle(this.selIdx, enable),
428
+ );
429
+ } else if (this.selAction === 'delete') {
430
+ this._confirm(
431
+ `Delete "${account.name}"?`,
432
+ 'Permanently remove it from Maxpool config. Deletion is blocked while it has active requests.',
433
+ () => this._doDelete(this.selIdx),
434
+ );
435
+ }
436
+ }
437
+ else if (k === 'esc' || k === 'q') { this.mode = 'normal'; }
438
+ }
439
+
440
+ _startSelection(action) {
441
+ const selectable = this._selectableIndexes(action);
442
+ if (!selectable.length) {
443
+ this._addLog(action === 'prefer'
444
+ ? 'No enabled Claude account is available for manual preference'
445
+ : 'No configurable account is available for this action');
446
+ return;
447
+ }
448
+ this.mode = 'select';
449
+ this.selAction = action;
450
+ this.selIdx = selectable.includes(this.am.currentIndex) ? this.am.currentIndex : selectable[0];
451
+ }
452
+
453
+ _selectableIndexes(action) {
454
+ return this.am.accounts
455
+ .map((account, index) => ({ account, index }))
456
+ .filter(({ account }) => {
457
+ if (action === 'prefer') return account.type !== 'provider' && account.enabled;
458
+ return this._configAccountIndex(account) >= 0;
459
+ })
460
+ .map(({ index }) => index);
461
+ }
462
+
463
+ _keyInput(k) {
464
+ if (k === 'enter') {
465
+ const cb = this.inputCb;
466
+ const v = this.inputBuf;
467
+ this.mode = 'normal'; this.inputCb = null; this.inputBuf = ''; this.inputSensitive = false; this._pendingPaste = '';
468
+ cb?.(v);
469
+ }
470
+ else if (k === 'esc') {
471
+ this.mode = 'normal'; this.inputCb = null; this.inputBuf = ''; this.inputSensitive = false; this._pendingPaste = '';
472
+ }
473
+ else if (k === 'bs') { this.inputBuf = this.inputBuf.slice(0, -1); }
474
+ else if (k.length === 1) { this.inputBuf += k; }
475
+ }
476
+
477
+ _keyConfirm(k) {
478
+ if (k === 'y') {
479
+ const cb = this.confirmCb;
480
+ this._clearConfirm();
481
+ Promise.resolve(cb?.()).catch(error => {
482
+ this._addLog(`Action failed: ${error.message}`);
483
+ });
484
+ } else if (k === 'n' || k === 'esc' || k === 'q') {
485
+ this._clearConfirm();
486
+ }
487
+ }
488
+
489
+ _confirm(title, detail, cb) {
490
+ this.mode = 'confirm';
491
+ this.confirmTitle = title;
492
+ this.confirmDetail = detail;
493
+ this.confirmCb = cb;
494
+ }
495
+
496
+ _clearConfirm() {
497
+ this.mode = 'normal';
498
+ this.confirmTitle = '';
499
+ this.confirmDetail = '';
500
+ this.confirmCb = null;
501
+ }
502
+
503
+ // ── account operations ─────────────────────────────
504
+
505
+ async _doSync() {
506
+ try {
507
+ this._addLog('Reloading config...');
508
+ const count = await this.syncAccounts();
509
+ if (count > 0) {
510
+ this._addLog(`Synced ${count} new account(s) from config`);
511
+ } else {
512
+ this._addLog('Config reloaded, credentials refreshed');
513
+ }
514
+ } catch (e) {
515
+ this._addLog(`Sync failed: ${e.message}`);
516
+ }
517
+ }
518
+
519
+ async _doImport() {
520
+ try {
521
+ this._addLog('Importing credentials...');
522
+ const creds = await importCredentials('~/.claude/.credentials.json');
523
+ const profile = await fetchProfile(creds.accessToken);
524
+ const profileOk = profile && !profile.error;
525
+
526
+ if (!profileOk) {
527
+ this._addLog(`Warning: could not fetch profile — ${profile?.error || 'no token'}`);
528
+ }
529
+
530
+ let name;
531
+ if (profile?.email) {
532
+ name = profile.email;
533
+ const tier = profile.hasClaudeMax ? 'Max' : profile.hasClaudePro ? 'Pro' : null;
534
+ if (tier) this._addLog(`Detected Claude ${tier}: ${name}`);
535
+ } else {
536
+ const n = this.config.accounts.filter(a => a.name.startsWith('account-')).length + 1;
537
+ name = `account-${n}`;
538
+ }
539
+
540
+ const entry = {
541
+ name, type: 'oauth', source: 'import',
542
+ accountUuid: profile?.accountUuid || null,
543
+ accessToken: creds.accessToken,
544
+ refreshToken: creds.refreshToken,
545
+ expiresAt: creds.expiresAt,
546
+ };
547
+
548
+ // Deduplicate: match by UUID first, then by name
549
+ let idx = profile?.accountUuid
550
+ ? this.config.accounts.findIndex(a => a.accountUuid === profile.accountUuid)
551
+ : -1;
552
+ if (idx < 0) idx = this.config.accounts.findIndex(a => a.name === name);
553
+
554
+ if (idx >= 0) {
555
+ const previous = this.config.accounts[idx];
556
+ entry.enabled = this.config.accounts[idx].enabled;
557
+ this.config.accounts[idx] = entry;
558
+ try {
559
+ await this.saveConfig(this.config);
560
+ } catch (error) {
561
+ this.config.accounts[idx] = previous;
562
+ throw error;
563
+ }
564
+ // Update the running account manager entry
565
+ const amAcct = this.am.accounts.find(account =>
566
+ (entry.accountUuid && account.accountUuid === entry.accountUuid) || account.name === name
567
+ );
568
+ if (amAcct) {
569
+ amAcct.credential = creds.accessToken;
570
+ amAcct.refreshToken = creds.refreshToken;
571
+ amAcct.expiresAt = creds.expiresAt;
572
+ amAcct.accountUuid = entry.accountUuid;
573
+ amAcct.name = name;
574
+ if (amAcct.status === 'error') amAcct.status = 'active';
575
+ }
576
+ this._addLog(`Updated account "${name}"`);
577
+ } else {
578
+ this.config.accounts.push(entry);
579
+ try {
580
+ await this.saveConfig(this.config);
581
+ } catch (error) {
582
+ this.config.accounts.pop();
583
+ throw error;
584
+ }
585
+ this.am.addAccount(entry);
586
+ this._addLog(`Imported account "${name}"`);
587
+ }
588
+ } catch (e) {
589
+ this._addLog(`Import failed: ${e.message}`);
590
+ }
591
+ }
592
+
593
+ async _doAddKey(apiKey) {
594
+ const key = String(apiKey || '').trim();
595
+ if (!key) { this._addLog('No API key entered'); return; }
596
+ const n = this.config.accounts.filter(a => a.name.startsWith('api-')).length + 1;
597
+ const name = `api-${n}`;
598
+ const entry = { name, type: 'apikey', apiKey: key };
599
+ this.config.accounts.push(entry);
600
+ try {
601
+ await this.saveConfig(this.config);
602
+ } catch (error) {
603
+ this.config.accounts.pop();
604
+ throw error;
605
+ }
606
+ this.am.addAccount(entry);
607
+ this._addLog(`Added API key account "${name}"`);
608
+ }
609
+
610
+ async _setAutomaticRouting() {
611
+ const previous = this.config.routing;
612
+ this.config.routing = { mode: 'automatic', preferredAccount: null };
613
+ try {
614
+ await this.saveConfig(this.config);
615
+ } catch (error) {
616
+ this.config.routing = previous;
617
+ throw error;
618
+ }
619
+ this.am.setRoutingMode('automatic');
620
+ this._addLog('Routing set to automatic load balancing');
621
+ }
622
+
623
+ async _setPreferredRouting(name) {
624
+ const account = this.am.accounts.find(candidate => candidate.name === name);
625
+ if (!account?.enabled || account.type === 'provider') {
626
+ throw new Error(`Claude account "${name}" must be enabled before it can be preferred`);
627
+ }
628
+ const previous = this.config.routing;
629
+ this.config.routing = { mode: 'preferred', preferredAccount: name };
630
+ try {
631
+ await this.saveConfig(this.config);
632
+ } catch (error) {
633
+ this.config.routing = previous;
634
+ throw error;
635
+ }
636
+ this.am.setRoutingMode('preferred', name);
637
+ this._addLog(`Routing now prefers "${name}" with automatic failover`);
638
+ }
639
+
640
+ _configAccountIndex(account) {
641
+ if (!account) return -1;
642
+ if (account.accountUuid) {
643
+ const byUuid = this.config.accounts.findIndex(candidate => candidate.accountUuid === account.accountUuid);
644
+ if (byUuid >= 0) return byUuid;
645
+ }
646
+ return this.config.accounts.findIndex(candidate => candidate.name === account.name);
647
+ }
648
+
649
+ async _doToggle(idx, enabled) {
650
+ const account = this.am.accounts[idx];
651
+ if (!account) return;
652
+ const configIndex = this._configAccountIndex(account);
653
+ if (configIndex < 0) {
654
+ this._addLog(`Cannot ${enabled ? 'enable' : 'disable'} runtime provider "${account.name}" here`);
655
+ return;
656
+ }
657
+ const previous = this.config.accounts[configIndex].enabled;
658
+ this.config.accounts[configIndex].enabled = enabled;
659
+ const previousRouting = this.config.routing;
660
+ const resetsRouting = !enabled && this.config.routing?.preferredAccount === account.name;
661
+ if (resetsRouting) {
662
+ this.config.routing = { mode: 'automatic', preferredAccount: null };
663
+ }
664
+ try {
665
+ await this.saveConfig(this.config);
666
+ } catch (error) {
667
+ this.config.accounts[configIndex].enabled = previous;
668
+ this.config.routing = previousRouting;
669
+ throw error;
670
+ }
671
+ this.am.setAccountEnabled(idx, enabled);
672
+ this._addLog(`${enabled ? 'Enabled' : 'Disabled'} account "${account.name}"`);
673
+ }
674
+
675
+ async _doDelete(idx) {
676
+ if (idx < 0 || idx >= this.am.accounts.length) return;
677
+ const account = this.am.accounts[idx];
678
+ const name = account.name;
679
+ if (account.inFlight > 0) {
680
+ this._addLog(`Cannot delete "${name}" while ${account.inFlight} request(s) are active; disable it and retry when idle`);
681
+ return;
682
+ }
683
+ const configIndex = this._configAccountIndex(account);
684
+ if (configIndex < 0) {
685
+ this._addLog(`Cannot permanently delete runtime provider "${name}" from the TUI`);
686
+ return;
687
+ }
688
+ const wasEnabled = account.enabled;
689
+ this.am.setAccountEnabled(idx, false);
690
+ if (account.inFlight > 0) {
691
+ this.am.setAccountEnabled(idx, wasEnabled);
692
+ this._addLog(`Cannot delete "${name}" because a request started; the account was not changed`);
693
+ return;
694
+ }
695
+
696
+ const [removedConfig] = this.config.accounts.splice(configIndex, 1);
697
+ const previousRouting = this.config.routing;
698
+ if (this.config.routing?.preferredAccount === name) {
699
+ this.config.routing = { mode: 'automatic', preferredAccount: null };
700
+ }
701
+ try {
702
+ await this.saveConfig(this.config);
703
+ } catch (error) {
704
+ this.config.accounts.splice(configIndex, 0, removedConfig);
705
+ this.config.routing = previousRouting;
706
+ this.am.setAccountEnabled(idx, wasEnabled);
707
+ throw error;
708
+ }
709
+ if (!this.am.removeAccount(idx)) {
710
+ this.config.accounts.splice(configIndex, 0, removedConfig);
711
+ this.config.routing = previousRouting;
712
+ this.am.setAccountEnabled(idx, wasEnabled);
713
+ await this.saveConfig(this.config);
714
+ throw new Error(`Account "${name}" became active before deletion completed`);
715
+ }
716
+ if (this.selIdx >= this.am.accounts.length) this.selIdx = Math.max(0, this.am.accounts.length - 1);
717
+ this._addLog(`Deleted account "${name}"`);
718
+ }
719
+
720
+ // ── rendering ──────────────────────────────────────
721
+
722
+ render() {
723
+ if (!this.running) return;
724
+ // Guard against re-entry: clearing an expired quota logs, and _addLog calls
725
+ // render() again — without this the nested call would render twice.
726
+ if (this._rendering) return;
727
+ this._rendering = true;
728
+ try {
729
+ this._render();
730
+ } finally {
731
+ this._rendering = false;
732
+ }
733
+ }
734
+
735
+ _render() {
736
+ // Reset the display the instant a quota window (e.g. 5-hour session) expires,
737
+ // instead of waiting for the next request to clear it.
738
+ this.am.refreshExpiredQuotas();
739
+ const W = process.stdout.columns || 80;
740
+ const H = process.stdout.rows || 24;
741
+
742
+ if (W < 40 || H < 8) {
743
+ process.stdout.write(`${ESC}H${ESC}2JTerminal too small (need 40x8+)\r\n`);
744
+ return;
745
+ }
746
+
747
+ const lines = [];
748
+
749
+ // ── Header
750
+ const left = bold(' Maxpool');
751
+ const port = this.config.proxy?.port || 3456;
752
+ const right = `Port ${port} ${green('▲')} `;
753
+ lines.push(left + ' '.repeat(Math.max(1, W - vw(left) - vw(right))) + right);
754
+ lines.push(' ' + dim('─'.repeat(W - 2)));
755
+ const routing = this.am.routingMode === 'preferred'
756
+ ? `Manual preference: ${this.am.preferredAccountName} (automatic failover)`
757
+ : 'Automatic load balancing';
758
+ lines.push(` Routing ${cyan(routing)}`);
759
+ const queuedCount = this.am.queueState?.waiting?.length || 0;
760
+ if (this.am._isUpstreamThrottleBlocking?.() || queuedCount) {
761
+ const throttle = this.am.upstreamThrottle;
762
+ const remaining = throttle.until ? Math.max(0, Math.ceil((throttle.until - Date.now()) / 1000)) : 0;
763
+ const state = this.am._isUpstreamThrottleBlocking?.()
764
+ ? throttle.probeInFlight ? 'probing recovery' : `retry in ${remaining}s`
765
+ : 'recovering';
766
+ const queued = queuedCount;
767
+ const oldest = queued ? Math.max(0, Date.now() - this.am.queueState.waiting[0].queuedAt) : 0;
768
+ const queueText = queued ? ` queued ${queued} oldest ${formatMs(oldest)}` : '';
769
+ lines.push(` ${yellow(' Anthropic upstream throttled')} ${dim(state + queueText)}`);
770
+ }
771
+
772
+ // ── Accounts
773
+ if (this.am.accounts.length === 0) {
774
+ lines.push('');
775
+ lines.push(yellow(' No accounts configured. Press [a] to add one.'));
776
+ } else {
777
+ lines.push('');
778
+ const showBoth = W >= 70;
779
+ const bw = showBoth
780
+ ? Math.max(5, Math.min(20, Math.floor((W - 56) / 2)))
781
+ : Math.max(5, Math.min(20, W - 45));
782
+
783
+ for (let i = 0; i < this.am.accounts.length; i++) {
784
+ lines.push(this._renderAcct(i, bw, showBoth));
785
+ }
786
+ }
787
+
788
+ // ── Activity header
789
+ lines.push('');
790
+ const ac = this.active.size;
791
+ const acTag = ac > 0 ? ` ${cyan(ac + ' active')}` : '';
792
+ const aHdr = ` Activity${acTag} `;
793
+ lines.push(aHdr + dim('─'.repeat(Math.max(1, W - vw(aHdr)))));
794
+
795
+ // Active requests
796
+ const now = Date.now();
797
+ for (const [, r] of this.active) {
798
+ const el = ((now - r.started) / 1000).toFixed(1);
799
+ const sp = cyan(SPINNER[this.frame]);
800
+ const a = r.account ? ` → ${r.account}` : '';
801
+ lines.push(` ${sp} ${gray(r.t)} ${r.method} ${r.path}${a} ${dim(`(${el}s...)`)}`);
802
+ }
803
+
804
+ // Completed log
805
+ const footerH = this.mode === 'confirm' ? 3 : 2;
806
+ const space = Math.max(0, H - lines.length - footerH);
807
+ for (let i = 0; i < space && i < this.log.length; i++) {
808
+ lines.push(` ${gray(this.log[i].t)} ${this.log[i].msg}`);
809
+ }
810
+
811
+ // Pad to fill
812
+ while (lines.length < H - footerH) lines.push('');
813
+
814
+ // ── Footer
815
+ lines.push(' ' + dim('─'.repeat(W - 2)));
816
+ if (this.mode === 'confirm') lines.push(` ${this.confirmDetail}`);
817
+ lines.push(this._renderFooter());
818
+
819
+ // Write buffer
820
+ let buf = `${ESC}H`;
821
+ for (let i = 0; i < H; i++) {
822
+ buf += fitLine(lines[i] || '', W);
823
+ if (i < H - 1) buf += '\r\n';
824
+ }
825
+ // Show cursor only in input mode
826
+ buf += this.mode === 'input' ? `${ESC}?25h` : `${ESC}?25l`;
827
+ process.stdout.write(buf);
828
+ }
829
+
830
+ _renderAcct(idx, bw, showBoth) {
831
+ const a = this.am.accounts[idx];
832
+ const isCur = this.am.routingMode === 'preferred' && a.name === this.am.preferredAccountName;
833
+ const isSel = this.mode === 'select' && idx === this.selIdx;
834
+
835
+ // Prefix: selection marker + current marker
836
+ const sel = isSel ? cyan('>') : ' ';
837
+ const cur = isCur ? green('►') : ' ';
838
+
839
+ // Name (bold if selected)
840
+ const rawName = a.name.slice(0, 12).padEnd(12);
841
+ const name = isSel ? bold(rawName) : rawName;
842
+
843
+ // Type
844
+ const type = gray(a.type.padEnd(7));
845
+
846
+ // Status
847
+ let status;
848
+ // A shared upstream throttle holds the whole Claude pool, not any one
849
+ // account. Don't mislabel healthy accounts as per-account "paused": show
850
+ // the one running the recovery probe as "probing" and the rest "waiting"
851
+ // (the pool-wide throttle banner above conveys the cause).
852
+ const upstreamBlocking = a.type !== 'provider' && this.am._isUpstreamThrottleBlocking?.();
853
+ let effectiveStatus = a.enabled === false ? 'disabled' : a.status;
854
+ if (a.enabled !== false && upstreamBlocking && a.status === 'active') {
855
+ effectiveStatus = a.inFlight > 0 ? 'probing' : 'waiting';
856
+ }
857
+ switch (effectiveStatus) {
858
+ case 'active': status = isCur ? green('active') : 'active'; break;
859
+ case 'probing': status = green('probing'); break;
860
+ case 'waiting': status = yellow('waiting'); break;
861
+ case 'paused': status = yellow('paused'); break;
862
+ case 'disabled': status = gray('disabled'); break;
863
+ case 'throttled': status = yellow('throttled'); break;
864
+ case 'exhausted': status = red('exhausted'); break;
865
+ case 'error': status = red('error'); break;
866
+ default: status = a.status || 'ready';
867
+ }
868
+ status = rpad(status, 10);
869
+
870
+ if (a.type === 'provider') {
871
+ return this._renderProviderAcct(sel, cur, name, type, status, a);
872
+ }
873
+
874
+ // Quota ratios — prefer unified (Claude Max), fall back to standard (API key)
875
+ const q = a.quota;
876
+ let r1 = null, r2 = null, l1 = 'Ses', l2 = 'Wk ', t1 = null, t2 = null;
877
+
878
+ if (q.unified5h != null || q.unified7d != null) {
879
+ r1 = q.unified5h;
880
+ r2 = q.unified7d;
881
+ t1 = q.unified5hReset;
882
+ t2 = q.unified7dReset;
883
+ } else {
884
+ l1 = 'Tok';
885
+ l2 = 'Req';
886
+ r1 = (q.tokensLimit != null && q.tokensRemaining != null)
887
+ ? 1 - q.tokensRemaining / q.tokensLimit : null;
888
+ r2 = (q.requestsLimit != null && q.requestsRemaining != null)
889
+ ? 1 - q.requestsRemaining / q.requestsLimit : null;
890
+ t1 = q.resetsAt ? new Date(q.resetsAt).getTime() : null;
891
+ t2 = t1;
892
+ }
893
+
894
+ let line = ` ${sel}${cur} ${name} ${type} ${status} ${l1} ${bar(r1, bw, t1)}`;
895
+ if (showBoth) {
896
+ line += ` ${l2} ${bar(r2, bw, t2)}`;
897
+ }
898
+ const weekly = weeklyPolicyText(this.am, a);
899
+ if (weekly) line += ` ${weekly}`;
900
+ line += ` ${dim(loadText(this._accountLoad(a)))}`;
901
+ return line;
902
+ }
903
+
904
+ _renderProviderAcct(sel, cur, name, type, status, a) {
905
+ const completed = a.completedRequests || 0;
906
+ const failed = a.failedRequests || 0;
907
+ const active = a.inFlight || 0;
908
+ const last = a.lastStatus ? `${statusColor(a.lastStatus)} ${formatMs(a.lastResponseMs)}` : '-';
909
+ const q = a.quota || {};
910
+ let limit = '';
911
+ if (q.genericLimit != null && q.genericRemaining != null) {
912
+ const used = q.genericLimit - q.genericRemaining;
913
+ const reset = formatReset(q.genericReset);
914
+ limit = ` Lim ${used}/${q.genericLimit}${reset ? ` ${reset}` : ''}`;
915
+ }
916
+ const err = a.lastError ? ` Err ${String(a.lastError).slice(0, 18)}` : '';
917
+ return ` ${sel}${cur} ${name} ${type} ${status} Act ${String(active).padStart(2)} OK ${String(completed).padStart(3)} Fail ${String(failed).padStart(2)} Last ${last} ${dim(loadText(this._accountLoad(a)))}${limit}${err}`;
918
+ }
919
+
920
+ _accountLoad(account) {
921
+ if (account.load) return account.load;
922
+ if (!this.am?._loadSummary) return null;
923
+ const now = Date.now();
924
+ return {
925
+ current: {
926
+ inFlight: account.inFlight,
927
+ activeWeight: account.activeWeight,
928
+ },
929
+ last15m: this.am._loadSummary(account, 15 * 60 * 1000, now),
930
+ last1h: this.am._loadSummary(account, 60 * 60 * 1000, now),
931
+ };
932
+ }
933
+
934
+ _renderFooter() {
935
+ switch (this.mode) {
936
+ case 'normal':
937
+ return ` ${bold('a')} Accounts ${bold('m')} Routing ${bold('s')} Sync ${bold('r')} Restart ${bold('q')} Stop`;
938
+ case 'accounts':
939
+ return ` ${bold('i')} Import Claude login ${bold('k')} API key ${bold('t')} Enable/disable ${bold('d')} Delete ${bold('Esc')} Back`;
940
+ case 'routing':
941
+ return ` ${bold('a')} Automatic ${bold('p')} Manual preference ${bold('Esc')} Back`;
942
+ case 'select': {
943
+ const act = this.selAction === 'prefer'
944
+ ? 'prefer'
945
+ : this.selAction === 'toggle'
946
+ ? 'enable/disable'
947
+ : 'delete';
948
+ return ` ${dim('↑↓')} select ${bold('Enter')} ${act} ${bold('Esc')} cancel`;
949
+ }
950
+ case 'input':
951
+ return ` ${this.inputPrompt}: ${this.inputSensitive ? '•'.repeat(this.inputBuf.length) : this.inputBuf}█`;
952
+ case 'confirm':
953
+ return ` ${bold(this.confirmTitle)} ${bold('y')} Yes ${bold('n')} No`;
954
+ default:
955
+ return '';
956
+ }
957
+ }
958
+ }