@rikcodes/teamclaude 1.1.20-rik.12 → 1.1.20-rik.14
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/account-manager.js +7 -0
- package/src/index.js +48 -9
- package/src/sync-accounts.js +5 -0
- package/src/terminal-title.js +8 -4
- package/src/tui.js +285 -64
- package/src/updater.js +27 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rikcodes/teamclaude",
|
|
3
|
-
"version": "1.1.20-rik.
|
|
3
|
+
"version": "1.1.20-rik.14",
|
|
4
4
|
"description": "Multi-account proxy for Claude Code and Codex: pools Claude Max, ChatGPT/Codex, API-key and third-party backend accounts, and rotates on quota",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/account-manager.js
CHANGED
|
@@ -214,6 +214,13 @@ function makeAccount(acct, index) {
|
|
|
214
214
|
hasClaudeMax: acct.hasClaudeMax ?? null,
|
|
215
215
|
hasClaudePro: acct.hasClaudePro ?? null,
|
|
216
216
|
priority: acct.priority || 0,
|
|
217
|
+
// Where this account sits in the list the TUI draws, arranged by the
|
|
218
|
+
// operator from the settings screen. Presentation only: nothing but
|
|
219
|
+
// _displayOrder in tui.js reads it, and it is emphatically NOT `priority`
|
|
220
|
+
// above — that one decides which account rotation spends next, and the two
|
|
221
|
+
// answer different questions about the same fleet. `null` means never
|
|
222
|
+
// placed, which sorts after every account that has been.
|
|
223
|
+
displayOrder: Number.isFinite(acct.displayOrder) ? acct.displayOrder : null,
|
|
217
224
|
disabled: acct.disabled || false,
|
|
218
225
|
maxUsage: acct.maxUsage ?? null,
|
|
219
226
|
upstream: acct.upstream || null,
|
package/src/index.js
CHANGED
|
@@ -775,11 +775,27 @@ async function serverCommand() {
|
|
|
775
775
|
// instead of re-running teardown, which would re-arm server.close() and leak a
|
|
776
776
|
// 'close' listener on the server each time (MaxListenersExceededWarning).
|
|
777
777
|
let shuttingDown = false;
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
778
|
+
// Everything this process borrowed from the terminal, given back: at most
|
|
779
|
+
// once, never throwing, and on every path that leaves. shutdown() does it
|
|
780
|
+
// first, as it always has. A restart drain now keeps the display up to the
|
|
781
|
+
// last moment and does it last — which makes the guard below, the "stop
|
|
782
|
+
// waiting, just go" path, the one that would otherwise walk out of a drain
|
|
783
|
+
// leaving the operator on the alternate screen with raw mode still on and a
|
|
784
|
+
// terminal they cannot type into.
|
|
785
|
+
const restoreTerminal = () => {
|
|
781
786
|
try { tui?.stop(); } catch { /* terminal already restored */ }
|
|
787
|
+
// Stopping the title belongs here rather than at the top of a drain: while
|
|
788
|
+
// one runs the title still names the build that is running, which is the
|
|
789
|
+
// whole reason it carries the build at all.
|
|
782
790
|
stopTitle();
|
|
791
|
+
};
|
|
792
|
+
async function shutdown() {
|
|
793
|
+
// Second ctrl-c, or a signal arriving during a restart drain: stop waiting,
|
|
794
|
+
// just go — but hand the terminal back first, because nothing after this
|
|
795
|
+
// line runs and on the drain path the display is still up.
|
|
796
|
+
if (shuttingDown) { restoreTerminal(); process.exit(0); }
|
|
797
|
+
shuttingDown = true;
|
|
798
|
+
restoreTerminal();
|
|
783
799
|
if (!tui) console.log('\n[TeamClaude] Shutting down...');
|
|
784
800
|
prober?.stop();
|
|
785
801
|
warmer?.stop();
|
|
@@ -810,6 +826,12 @@ async function serverCommand() {
|
|
|
810
826
|
// stops accepting while the connections already open keep serving; then the
|
|
811
827
|
// bounded wait; then the sidecar, whose replacement the next process spawns;
|
|
812
828
|
// then quota state, exactly as shutdown() persists it. Exit 75 is the ask.
|
|
829
|
+
//
|
|
830
|
+
// The display is the other difference. shutdown() tears it down first; this
|
|
831
|
+
// keeps it to the end. A drain is up to thirty seconds of waiting the
|
|
832
|
+
// operator asked for, and taking the dashboard away at the start of it left
|
|
833
|
+
// them watching plain console lines with no way to tell how far it had got or
|
|
834
|
+
// what was holding it.
|
|
813
835
|
let draining = false;
|
|
814
836
|
hooks.isDraining = () => draining;
|
|
815
837
|
/** @param {string} why what put the restart in motion, for the line on the way out */
|
|
@@ -817,12 +839,19 @@ async function serverCommand() {
|
|
|
817
839
|
if (shuttingDown) return; // ctrl-c beat us here, or a second trigger did
|
|
818
840
|
shuttingDown = true;
|
|
819
841
|
draining = true;
|
|
820
|
-
|
|
821
|
-
|
|
842
|
+
tui?.restartDrainStarted({
|
|
843
|
+
deadlineMs: DRAIN_DEADLINE_MS,
|
|
844
|
+
inFlight: () => accountManager.inFlightRequests(),
|
|
845
|
+
});
|
|
846
|
+
// Under the TUI console.log IS the log pane (tui.js), so this line and the
|
|
847
|
+
// two the drain ends with report themselves on screen beside the live
|
|
848
|
+
// counter, and nothing extra has to be written for them.
|
|
822
849
|
console.log(`\n[TeamClaude] ${why} — draining, up to ${Math.round(DRAIN_DEADLINE_MS / 1000)}s for requests in flight.`);
|
|
823
850
|
prober?.stop();
|
|
824
851
|
warmer?.stop();
|
|
825
852
|
eventLoopMonitor.stop();
|
|
853
|
+
/** @type {string|null} */
|
|
854
|
+
let failure = null;
|
|
826
855
|
// Nothing below may throw its way out. Neither caller awaits this — the TUI
|
|
827
856
|
// key returns to its handler and the watcher fires from a timer — so an
|
|
828
857
|
// escaping rejection would be an unhandled one, and crash-log.js turns that
|
|
@@ -840,11 +869,21 @@ async function serverCommand() {
|
|
|
840
869
|
if (quotaSaveInterval) clearInterval(quotaSaveInterval);
|
|
841
870
|
await persistQuotaState();
|
|
842
871
|
} catch (err) {
|
|
843
|
-
// Committed from the moment the
|
|
844
|
-
//
|
|
845
|
-
//
|
|
846
|
-
|
|
872
|
+
// Committed from the moment the listener closed: there is no serving
|
|
873
|
+
// state left to return to, so record what broke and let the relaunch be
|
|
874
|
+
// the recovery. Read off `err` defensively because throwing HERE is the
|
|
875
|
+
// one thing the try above cannot absorb, and an unhandled rejection out
|
|
876
|
+
// of this function is exit 1 — a crash, on the path whose whole purpose
|
|
877
|
+
// is to come back.
|
|
878
|
+
failure = /** @type {any} */ (err)?.message || String(err);
|
|
879
|
+
} finally {
|
|
880
|
+
// The last thing before the exit, and in a finally because the catch is
|
|
881
|
+
// not the only way out of the block above.
|
|
882
|
+
restoreTerminal();
|
|
847
883
|
}
|
|
884
|
+
// After the restore, so it lands on the screen the relaunch comes back to
|
|
885
|
+
// rather than in a log pane that goes with the alternate screen.
|
|
886
|
+
if (failure) console.error(`[TeamClaude] Drain failed: ${failure}`);
|
|
848
887
|
process.exit(RESTART_EXIT_CODE);
|
|
849
888
|
}
|
|
850
889
|
|
package/src/sync-accounts.js
CHANGED
|
@@ -94,6 +94,11 @@ export async function syncAccountsFromDisk(diskConfig, memConfig, accountManager
|
|
|
94
94
|
}
|
|
95
95
|
if (diskAcct.name && mgr.name !== diskAcct.name) mgr.name = diskAcct.name;
|
|
96
96
|
if (diskAcct.priority != null && mgr.priority !== diskAcct.priority) mgr.priority = diskAcct.priority;
|
|
97
|
+
// A list position edited on disk applies on reload for the same reason a
|
|
98
|
+
// priority edit does, and `null` rather than `??` so deleting the field
|
|
99
|
+
// puts the account back among the unplaced instead of leaving it stuck at
|
|
100
|
+
// the position it last held. Display only — see makeAccount.
|
|
101
|
+
mgr.displayOrder = Number.isFinite(diskAcct.displayOrder) ? diskAcct.displayOrder : null;
|
|
97
102
|
// A cap edit applies live for the same reason priority does: it is an
|
|
98
103
|
// operator decision about a running fleet, and waiting for a restart to
|
|
99
104
|
// honour a budget defeats the budget.
|
package/src/terminal-title.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Reflect the active account and the running build in the terminal title (e.g.
|
|
2
|
-
// "teamclaude 2/4 work 1.1.20-rik.
|
|
2
|
+
// "teamclaude 2/4 work 1.1.20-rik.12+acc19b3"), so a backgrounded or tabbed
|
|
3
3
|
// `teamclaude server` is glanceable without switching to it. Pure/side-effect-
|
|
4
4
|
// free here so it can be unit-tested; the caller owns the TTY gate and the
|
|
5
5
|
// interval.
|
|
@@ -32,9 +32,13 @@ function truncate(s, max) {
|
|
|
32
32
|
export function formatTerminalTitle({ index = 0, total = 0, name = null, version = null } = {}) {
|
|
33
33
|
const pos = total > 0 ? `${index + 1}/${total}` : '0/0';
|
|
34
34
|
const who = name ? ` ${truncate(name, 24)}` : '';
|
|
35
|
-
// Bounded like the account name,
|
|
36
|
-
// title a few dozen columns at best, and neither string was
|
|
37
|
-
|
|
35
|
+
// Bounded like the account name, at the same width and for the same reason: a
|
|
36
|
+
// tab strip gives a title a few dozen columns at best, and neither string was
|
|
37
|
+
// chosen here. 24 rather than 20 because a checkout names both halves of what
|
|
38
|
+
// it runs — `1.1.20-rik.12+acc19b3`, 21 columns — and cutting that at 20 ends
|
|
39
|
+
// the title in half a sha, which names no commit while reading like it does.
|
|
40
|
+
// A label still long enough to be cut at 24 was never one of ours.
|
|
41
|
+
const build = version ? ` ${truncate(version, 24)}` : '';
|
|
38
42
|
return `teamclaude ${pos}${who}${build}`;
|
|
39
43
|
}
|
|
40
44
|
|
package/src/tui.js
CHANGED
|
@@ -213,15 +213,27 @@ const BAR_MAX = 20;
|
|
|
213
213
|
// terminal lays the table out exactly as it did before the column could grow.
|
|
214
214
|
const NAME_MIN = 12;
|
|
215
215
|
|
|
216
|
-
// Clear space the version label needs on each side before it is drawn at all.
|
|
217
|
-
// Below that it reads as a collision with the title or the port block.
|
|
218
|
-
const HEAD_GAP = 2;
|
|
219
|
-
|
|
220
216
|
// Narrowest label still worth drawing: an ellipsis and three columns of build.
|
|
221
|
-
// Under that the
|
|
217
|
+
// Under that the footer goes back to naming no build at all, which at those
|
|
222
218
|
// widths is the honest answer.
|
|
223
219
|
const HEAD_LABEL_MIN = 4;
|
|
224
220
|
|
|
221
|
+
// Clear space the footer keeps between the last key hint and the build label.
|
|
222
|
+
// Any closer and the label reads as one more hint, which is the one thing it
|
|
223
|
+
// must not look like — there is no key that does it.
|
|
224
|
+
const FOOT_GAP = 2;
|
|
225
|
+
|
|
226
|
+
// Where an account sits in the list the operator arranged — the sort key behind
|
|
227
|
+
// _displayOrder, written by _doMoveAccount and by nothing else.
|
|
228
|
+
//
|
|
229
|
+
// An account with no `displayOrder` has never been placed: every account on a
|
|
230
|
+
// config that predates the field, and every account added since the last
|
|
231
|
+
// arrangement. Those sort after every account that has one, which is where a
|
|
232
|
+
// new account already appeared back when this list was raw array order — so
|
|
233
|
+
// the answer to "where does the one I just logged in with go" does not change
|
|
234
|
+
// with the feature, and there is nothing to migrate.
|
|
235
|
+
const listRank = (/** @type {any} */ a) => (Number.isFinite(a?.displayOrder) ? a.displayOrder : Infinity);
|
|
236
|
+
|
|
225
237
|
// Which pair of bars a row draws: the subscription buckets (Ses/Wk, plus the
|
|
226
238
|
// S7/F7 family bars) when any unified reading exists, else the metered Tok/Req
|
|
227
239
|
// pair an API-key account reports. The account row budget is drawn per
|
|
@@ -287,19 +299,31 @@ export function fitLine(s, w) {
|
|
|
287
299
|
|
|
288
300
|
/** The build label at `max` columns, or '' when nothing legible fits.
|
|
289
301
|
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
302
|
+
* Build metadata is spent before anything is cut. A checkout labels itself
|
|
303
|
+
* `<version>+<sha>`, and of the two it is the version the footer is read for;
|
|
304
|
+
* the sha only says which build of it. Shortening the sha instead would be
|
|
305
|
+
* worse than losing it — four hex digits name no commit, and are read as if
|
|
306
|
+
* they did.
|
|
307
|
+
*
|
|
308
|
+
* What is left is cut from the LEFT, unlike every other truncation here,
|
|
309
|
+
* because what tells one build from the next is its tail: `…rik.11` still
|
|
310
|
+
* identifies the build, `1.1.2…` identifies the three before it just as well.
|
|
311
|
+
* Sliced by code unit against a display-width budget — a version or a sha is
|
|
312
|
+
* ASCII, and a label arriving over the wire is measured again by the caller
|
|
313
|
+
* before it is placed, so a wide glyph costs the label its slot rather than
|
|
314
|
+
* the footer its width.
|
|
296
315
|
* @param {string} label
|
|
297
316
|
* @param {number} max */
|
|
298
317
|
export function fitHeadLabel(label, max) {
|
|
299
|
-
|
|
300
|
-
|
|
318
|
+
if (max >= vw(label)) return label;
|
|
319
|
+
// Everything after the last `+` is semver build metadata, which is by
|
|
320
|
+
// definition not the identity — so it is what gets spent first. `> 0` keeps a
|
|
321
|
+
// label that is nothing but metadata from being spent down to nothing.
|
|
322
|
+
const plus = label.lastIndexOf('+');
|
|
323
|
+
const bare = plus > 0 ? label.slice(0, plus) : label;
|
|
324
|
+
if (max >= vw(bare)) return bare;
|
|
301
325
|
if (max < HEAD_LABEL_MIN) return '';
|
|
302
|
-
return `…${
|
|
326
|
+
return `…${bare.slice(bare.length - (max - 1))}`;
|
|
303
327
|
}
|
|
304
328
|
|
|
305
329
|
function formatReset(resetTs) {
|
|
@@ -478,7 +502,7 @@ export class TUI {
|
|
|
478
502
|
this.mode = 'normal'; // normal | select | add | input | settings | pick
|
|
479
503
|
this.pick = null; // active list picker (routes editor accounts/bucket/color)
|
|
480
504
|
this.pickReturn = 'routes'; // mode to fall back to when the picker closes
|
|
481
|
-
this.selAction = null; // switch | remove | toggle
|
|
505
|
+
this.selAction = null; // switch | remove | toggle | reorder
|
|
482
506
|
this.selIdx = 0;
|
|
483
507
|
this.selRoute = null; // in switch mode: null = global default, else a getRoutes() entry to pin
|
|
484
508
|
this.selReturn = 'normal'; // mode to fall back to when select mode closes
|
|
@@ -492,6 +516,15 @@ export class TUI {
|
|
|
492
516
|
this.frame = 0;
|
|
493
517
|
this.running = false;
|
|
494
518
|
this.timer = null;
|
|
519
|
+
// Set while THIS PROCESS is draining to be relaunched: when the wait
|
|
520
|
+
// started, what bounds it, and how to ask how much is still running.
|
|
521
|
+
// Deliberately not named `draining`: the header's `drain N` marker and the
|
|
522
|
+
// account manager's drainingCount() are SESSION draining — sessions being
|
|
523
|
+
// moved off an account during a rotation — and the two have nothing to do
|
|
524
|
+
// with each other. One is a request finishing somewhere else; this one is
|
|
525
|
+
// the process going away.
|
|
526
|
+
/** @type {{ startedAt: number, deadlineMs: number, inFlight: () => number }|null} */
|
|
527
|
+
this._restartDrain = null;
|
|
495
528
|
// Injectable so a test can drive the repaint tick by hand instead of
|
|
496
529
|
// sleeping through real 500ms/5s intervals.
|
|
497
530
|
this._setTimeout = setTimeout;
|
|
@@ -570,8 +603,11 @@ export class TUI {
|
|
|
570
603
|
this._scheduleTick();
|
|
571
604
|
}
|
|
572
605
|
|
|
573
|
-
/** Fast while something is animating, slow when there is nothing to animate.
|
|
574
|
-
|
|
606
|
+
/** Fast while something is animating, slow when there is nothing to animate.
|
|
607
|
+
* A restart drain counts as animating: an idle tick is five seconds, and an
|
|
608
|
+
* elapsed counter that moves once every five of them reads as a frozen
|
|
609
|
+
* screen — which is the complaint keeping the display up exists to answer. */
|
|
610
|
+
_tickDelay() { return (this.active.size > 0 || this._restartDrain) ? SPIN_MS : IDLE_TICK_MS; }
|
|
575
611
|
|
|
576
612
|
_scheduleTick() {
|
|
577
613
|
if (!this.running) return;
|
|
@@ -626,6 +662,28 @@ export class TUI {
|
|
|
626
662
|
process.stdin.pause();
|
|
627
663
|
}
|
|
628
664
|
|
|
665
|
+
/**
|
|
666
|
+
* The server has begun draining to be relaunched: its listener is closed and
|
|
667
|
+
* it is waiting out the requests still running before it exits on 75.
|
|
668
|
+
*
|
|
669
|
+
* The display stays up for all of it, and this is what puts the drain on it.
|
|
670
|
+
* The wait is up to 30 seconds the operator asked for by pressing `u`, and
|
|
671
|
+
* the two things they want from it — how long it has run, and what is still
|
|
672
|
+
* holding it — are already here. The dashboard used to come down first, so
|
|
673
|
+
* the answer to both was a blank console for the duration.
|
|
674
|
+
*
|
|
675
|
+
* There is no matching "ended": the process exits at the end of the drain,
|
|
676
|
+
* and stop() is what takes the screen back (index.js, immediately before the
|
|
677
|
+
* exit and on every abrupt way out of one).
|
|
678
|
+
*
|
|
679
|
+
* @param {{ deadlineMs: number, inFlight: () => number }} drain
|
|
680
|
+
*/
|
|
681
|
+
restartDrainStarted({ deadlineMs, inFlight }) {
|
|
682
|
+
this._restartDrain = { startedAt: Date.now(), deadlineMs, inFlight };
|
|
683
|
+
this._retick(); // idle cadence → something to animate again
|
|
684
|
+
if (this.running) this.render();
|
|
685
|
+
}
|
|
686
|
+
|
|
629
687
|
// A title lookup costs a directory scan and a file read, so it stays off the
|
|
630
688
|
// render path: this returns what is cached and schedules the rest.
|
|
631
689
|
_sessionTag(sid) {
|
|
@@ -702,6 +760,17 @@ export class TUI {
|
|
|
702
760
|
_key(k) {
|
|
703
761
|
if (k === 'ctrl-c') { this.stop(); this.onQuit?.(); return; }
|
|
704
762
|
|
|
763
|
+
// Draining to a restart: the listener is closed and this process is on its
|
|
764
|
+
// way out, so switching, disabling, probing, syncing or editing anything
|
|
765
|
+
// would act on state that is about to be discarded, and `u` is already
|
|
766
|
+
// running. Ctrl-c above stays the one key that means something — the escape
|
|
767
|
+
// from the wait — and the footer says so, which is why nothing is logged
|
|
768
|
+
// for the rest: a line per keypress would push the drain's own progress out
|
|
769
|
+
// of the pane. `q` is not an exception on purpose. An unattended restart
|
|
770
|
+
// can begin while the operator is typing into a prompt, and a letter key
|
|
771
|
+
// that quietly became "quit" is a poor way to find that out.
|
|
772
|
+
if (this._restartDrain) return;
|
|
773
|
+
|
|
705
774
|
switch (this.mode) {
|
|
706
775
|
case 'normal': this._keyNormal(k); break;
|
|
707
776
|
case 'select': this._keySelect(k); break;
|
|
@@ -843,6 +912,18 @@ export class TUI {
|
|
|
843
912
|
});
|
|
844
913
|
}
|
|
845
914
|
|
|
915
|
+
// Two rows is the least that can be arranged. Below that the row would open
|
|
916
|
+
// a screen on which no key does anything.
|
|
917
|
+
if (this._displayOrder().length > 1) {
|
|
918
|
+
fields.push({
|
|
919
|
+
id: 'orderAccounts',
|
|
920
|
+
label: 'Reorder accounts',
|
|
921
|
+
hint: 'Enter to arrange',
|
|
922
|
+
value: () => dim('—'),
|
|
923
|
+
enter: () => { this.mode = 'select'; this.selAction = 'reorder'; this.selIdx = this._displayOrder()[0] ?? 0; this.selReturn = 'settings'; },
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
|
|
846
927
|
fields.push({
|
|
847
928
|
id: 'upstreamProxy',
|
|
848
929
|
label: 'Upstream proxy',
|
|
@@ -1006,11 +1087,22 @@ export class TUI {
|
|
|
1006
1087
|
// can only ask for the default account — leaves these keys alone.
|
|
1007
1088
|
else if ((k === 'tab' || k === 'right') && this.selAction === 'switch' && !this.remote) this._cycleSelRoute(+1);
|
|
1008
1089
|
else if (k === 'left' && this.selAction === 'switch' && !this.remote) this._cycleSelRoute(-1);
|
|
1090
|
+
// ←→ in reorder mode move the ACCOUNT, not the cursor. ↑↓ already walk the
|
|
1091
|
+
// rows, so this is the pair left over, and ←→ is what "change the thing the
|
|
1092
|
+
// cursor is on" already means on the settings screen this is opened from.
|
|
1093
|
+
// The cursor does not move with the key: `selIdx` names the account being
|
|
1094
|
+
// dragged, and the row it marks travels with it.
|
|
1095
|
+
else if ((k === 'left' || k === 'h') && this.selAction === 'reorder') this._doMoveAccount(-1);
|
|
1096
|
+
else if ((k === 'right' || k === 'l') && this.selAction === 'reorder') this._doMoveAccount(+1);
|
|
1009
1097
|
else if (k === 'enter') {
|
|
1010
1098
|
if (this.selAction === 'switch') {
|
|
1011
1099
|
this._doSwitchSelection();
|
|
1012
1100
|
} else if (this.selAction === 'toggle') {
|
|
1013
1101
|
this._doToggleDisabled(this.selIdx);
|
|
1102
|
+
} else if (this.selAction === 'reorder') {
|
|
1103
|
+
// Every move has already been saved, so Enter only means "done" — and
|
|
1104
|
+
// it has to be caught here, ahead of the remove branch below, which is
|
|
1105
|
+
// what an unlisted action falls into.
|
|
1014
1106
|
} else {
|
|
1015
1107
|
this._doRemove(this.selIdx);
|
|
1016
1108
|
}
|
|
@@ -1157,12 +1249,15 @@ export class TUI {
|
|
|
1157
1249
|
|
|
1158
1250
|
// `u`: pick up a new build now instead of waiting for the next lull. The
|
|
1159
1251
|
// server owns what happens next — it drains, then exits asking to be
|
|
1160
|
-
// relaunched — and it
|
|
1161
|
-
//
|
|
1162
|
-
// in
|
|
1252
|
+
// relaunched — and it now keeps this TUI up for the whole drain, which is
|
|
1253
|
+
// where the progress goes (restartDrainStarted, and the footer). Its own
|
|
1254
|
+
// "draining, up to 30s" line lands in the pane beside the counter, so there
|
|
1255
|
+
// is nothing left for this to announce.
|
|
1256
|
+
//
|
|
1257
|
+
// A second `u` is not a second restart: the server guards on its own flag and
|
|
1258
|
+
// would do nothing at all, so a log line here would be a claim that it had.
|
|
1163
1259
|
_doRestart() {
|
|
1164
|
-
if (!this.onRestart) return;
|
|
1165
|
-
this._addLog('Draining before restart...');
|
|
1260
|
+
if (!this.onRestart || this._restartDrain) return;
|
|
1166
1261
|
this.onRestart();
|
|
1167
1262
|
}
|
|
1168
1263
|
|
|
@@ -1421,6 +1516,50 @@ export class TUI {
|
|
|
1421
1516
|
this._addLog(`${next ? 'Disabled' : 'Enabled'} account "${acct.name}"`);
|
|
1422
1517
|
}
|
|
1423
1518
|
|
|
1519
|
+
/** Move the selected account `delta` rows through the list as drawn.
|
|
1520
|
+
*
|
|
1521
|
+
* The array is NOT permuted. `selIdx` is a manager index, and so are route
|
|
1522
|
+
* pins, session pins, `currentIndex`, `TC_ACCT` and the disable/switch CLI
|
|
1523
|
+
* paths — reordering `am.accounts` would silently repoint every one of them
|
|
1524
|
+
* at a different account. So the rows are sorted by a field instead and each
|
|
1525
|
+
* account keeps the slot it has held since startup.
|
|
1526
|
+
*
|
|
1527
|
+
* It is not `priority` either. That field is rotation preference and only
|
|
1528
|
+
* one account in a fleet usually carries a non-default value — a
|
|
1529
|
+
* deliberately deprioritised local backend, say. Deriving it from where a
|
|
1530
|
+
* row sits on screen would re-rank the fleet as a side effect of tidying the
|
|
1531
|
+
* display, which is a routing change nobody asked for.
|
|
1532
|
+
*
|
|
1533
|
+
* Every displayed account is renumbered from its new position rather than
|
|
1534
|
+
* only the two that moved: before the first move there are no numbers to
|
|
1535
|
+
* insert between, and a dense 0..n-1 is the form that reads in a hand-edited
|
|
1536
|
+
* config. Local backends are excluded throughout — _displayOrder filters
|
|
1537
|
+
* them out before this ever sees them, so they hold no position and their
|
|
1538
|
+
* array slots are simply skipped over.
|
|
1539
|
+
*
|
|
1540
|
+
* @param {number} delta rows to travel: -1 up the list, +1 down it
|
|
1541
|
+
*/
|
|
1542
|
+
async _doMoveAccount(delta) {
|
|
1543
|
+
const order = this._displayOrder();
|
|
1544
|
+
const from = order.indexOf(this.selIdx);
|
|
1545
|
+
const to = from + delta;
|
|
1546
|
+
if (from < 0 || to < 0 || to >= order.length) return; // already at the end it was pushed against
|
|
1547
|
+
order.splice(to, 0, ...order.splice(from, 1));
|
|
1548
|
+
order.forEach((/** @type {number} */ mgrIdx, /** @type {number} */ pos) => {
|
|
1549
|
+
this.am.accounts[mgrIdx].displayOrder = pos;
|
|
1550
|
+
// Onto this account's own entry: a manager index is not a config index
|
|
1551
|
+
// (account-pairing.js), and an account whose entry the config no longer
|
|
1552
|
+
// holds keeps its position on screen with nothing to persist.
|
|
1553
|
+
const cfgIdx = configIndexFor(this.config.accounts, this.am.accounts, mgrIdx);
|
|
1554
|
+
if (cfgIdx >= 0) this.config.accounts[cfgIdx].displayOrder = pos;
|
|
1555
|
+
});
|
|
1556
|
+
// Nothing is logged on success. The row visibly moves, which is the whole
|
|
1557
|
+
// feedback a drag needs, and a held arrow key would otherwise push the
|
|
1558
|
+
// activity pane out from under the list being arranged.
|
|
1559
|
+
try { await this.saveConfig(this.config); }
|
|
1560
|
+
catch (/** @type {any} */ e) { this._addLog(`Failed to save: ${e.message}`); }
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1424
1563
|
// ── rendering ──────────────────────────────────────
|
|
1425
1564
|
|
|
1426
1565
|
render({ force = false } = {}) {
|
|
@@ -1505,44 +1644,15 @@ export class TUI {
|
|
|
1505
1644
|
// mode): what is on screen is the last snapshot, not the current state.
|
|
1506
1645
|
const live = this.am.connected === false ? red('▼') : green('▲');
|
|
1507
1646
|
const right = `${sessStr}Port ${port} ${live} `;
|
|
1508
|
-
//
|
|
1509
|
-
//
|
|
1510
|
-
//
|
|
1511
|
-
//
|
|
1512
|
-
//
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
// The marker is budgeted before the label is cut, so a shortened label and
|
|
1518
|
-
// its marker still fit the room they were measured against.
|
|
1519
|
-
const room = W - lw - rw;
|
|
1520
|
-
const markerW = upd ? 2 : 0;
|
|
1521
|
-
const text = label ? fitHeadLabel(label, room - 2 * HEAD_GAP - markerW) : '';
|
|
1522
|
-
const mid = text ? dim(text) + (upd ? ` ${green('▲')}` : '') : '';
|
|
1523
|
-
const mw = vw(mid);
|
|
1524
|
-
// Centred on the line, not in the gap between the two blocks, so the label
|
|
1525
|
-
// holds still as the session segment comes and goes.
|
|
1526
|
-
const start = Math.floor((W - mw) / 2);
|
|
1527
|
-
// Load-bearing, not cosmetic: both padding runs below would be negative
|
|
1528
|
-
// without it, and ' '.repeat(-1) throws. Satisfying it also means the mid
|
|
1529
|
-
// branch can never produce the over-wide line the other branch can, so the
|
|
1530
|
-
// two are not interchangeable.
|
|
1531
|
-
const midFits = mw > 0 && start - lw >= HEAD_GAP && (W - rw) - (start + mw) >= HEAD_GAP;
|
|
1532
|
-
// Line-centring is a position, not a fit. The two blocks are different
|
|
1533
|
-
// widths, so a label small enough for the gap can still be pushed inside
|
|
1534
|
-
// one of them by where the centre of the LINE falls — and that, not width,
|
|
1535
|
-
// is what used to drop the label every time the session segment grew,
|
|
1536
|
-
// leaving a header that silently stopped naming the build it exists to
|
|
1537
|
-
// name. Centre it in the GAP instead, which is exact by construction: the
|
|
1538
|
-
// two runs below sum to `room`. The label moves when sessions come and go,
|
|
1539
|
-
// which is the price; being able to read it is what that buys.
|
|
1540
|
-
const gapPad = mw > 0 && room - mw >= 2 * HEAD_GAP ? Math.floor((room - mw) / 2) : -1;
|
|
1541
|
-
lines.push(midFits
|
|
1542
|
-
? left + ' '.repeat(start - lw) + mid + ' '.repeat(W - rw - start - mw) + right
|
|
1543
|
-
: gapPad >= 0
|
|
1544
|
-
? left + ' '.repeat(gapPad) + mid + ' '.repeat(room - mw - gapPad) + right
|
|
1545
|
-
: left + ' '.repeat(Math.max(1, W - lw - rw)) + right);
|
|
1647
|
+
// Title, padding, port block — and nothing between them. The build this
|
|
1648
|
+
// checkout runs is named once, in the corner of the footer.
|
|
1649
|
+
//
|
|
1650
|
+
// That padding run is the whole of the line's arithmetic now, and it is
|
|
1651
|
+
// floored rather than trusted: a wide session segment on a narrow terminal
|
|
1652
|
+
// can leave the two blocks alone wider than the line, and ' '.repeat(-1)
|
|
1653
|
+
// throws. There the header overruns and fitLine takes its tail, as it has
|
|
1654
|
+
// since long before there was a label to lose.
|
|
1655
|
+
lines.push(left + ' '.repeat(Math.max(1, W - vw(left) - vw(right))) + right);
|
|
1546
1656
|
lines.push(' ' + dim('─'.repeat(W - 2)));
|
|
1547
1657
|
|
|
1548
1658
|
const footerH = 2;
|
|
@@ -1712,7 +1822,7 @@ export class TUI {
|
|
|
1712
1822
|
|
|
1713
1823
|
// ── Footer
|
|
1714
1824
|
lines.push(' ' + dim('─'.repeat(W - 2)));
|
|
1715
|
-
lines.push(this._renderFooter());
|
|
1825
|
+
lines.push(this._renderFooter(W));
|
|
1716
1826
|
|
|
1717
1827
|
// Write buffer
|
|
1718
1828
|
let buf = `${ESC}H`;
|
|
@@ -1738,11 +1848,21 @@ export class TUI {
|
|
|
1738
1848
|
* Display only. `selIdx`, `currentIndex`, session pins and route entries all
|
|
1739
1849
|
* stay manager indices, so nothing about selection or routing moves with the
|
|
1740
1850
|
* rows — see _keySelect, which walks this order but still stores an index.
|
|
1851
|
+
*
|
|
1852
|
+
* Which is also why the operator's arrangement is a sort key and not a
|
|
1853
|
+
* permutation of `am.accounts`: see _doMoveAccount.
|
|
1741
1854
|
*/
|
|
1742
1855
|
_displayOrder() {
|
|
1743
1856
|
return this.am.accounts
|
|
1744
1857
|
.map((/** @type {any} */ _, /** @type {number} */ i) => i)
|
|
1745
|
-
.filter(i => !isLocalUpstream(this.am.accounts[i]))
|
|
1858
|
+
.filter(i => !isLocalUpstream(this.am.accounts[i]))
|
|
1859
|
+
.sort((/** @type {number} */ a, /** @type {number} */ b) => {
|
|
1860
|
+
const ra = listRank(this.am.accounts[a]);
|
|
1861
|
+
const rb = listRank(this.am.accounts[b]);
|
|
1862
|
+
// Infinity !== Infinity is false, so two unplaced accounts fall through
|
|
1863
|
+
// to the index rather than subtracting to NaN.
|
|
1864
|
+
return ra === rb ? a - b : ra - rb;
|
|
1865
|
+
});
|
|
1746
1866
|
}
|
|
1747
1867
|
|
|
1748
1868
|
/** Manager indices of the local backends, in config order. */
|
|
@@ -1992,9 +2112,10 @@ export class TUI {
|
|
|
1992
2112
|
lines.push(row(byId('blocklist')));
|
|
1993
2113
|
lines.push('');
|
|
1994
2114
|
// ── Accounts
|
|
1995
|
-
lines.push(bold(' Accounts') + dim(' — add (import / API key) or
|
|
2115
|
+
lines.push(bold(' Accounts') + dim(' — add (import / API key), remove, or set the order they list in'));
|
|
1996
2116
|
lines.push(row(byId('addAccount')));
|
|
1997
2117
|
if (byId('removeAccount')) lines.push(row(byId('removeAccount')));
|
|
2118
|
+
if (byId('orderAccounts')) lines.push(row(byId('orderAccounts')));
|
|
1998
2119
|
lines.push('');
|
|
1999
2120
|
// ── Network
|
|
2000
2121
|
// Drawn before the sx.org block, which returns early when sx is unavailable:
|
|
@@ -2317,7 +2438,26 @@ export class TUI {
|
|
|
2317
2438
|
}
|
|
2318
2439
|
}
|
|
2319
2440
|
|
|
2320
|
-
|
|
2441
|
+
/** The footer line: the mode's key hints from the left, the build label set
|
|
2442
|
+
* against the right edge.
|
|
2443
|
+
* @param {number} [W] columns to compose against */
|
|
2444
|
+
_renderFooter(W = process.stdout.columns || 80) {
|
|
2445
|
+
// A restart drain outranks every mode: the keys this line would otherwise
|
|
2446
|
+
// advertise are all refused while one runs (see _key), and how far the
|
|
2447
|
+
// drain has got is the only thing on screen worth the row.
|
|
2448
|
+
//
|
|
2449
|
+
// It is also the one footer drawn without the build label or its update
|
|
2450
|
+
// marker. The line lives for at most the deadline, the build cannot change
|
|
2451
|
+
// under it, and when the restart is an update the label names the build
|
|
2452
|
+
// being replaced and the marker points at the thing already happening — so
|
|
2453
|
+
// those columns go to the escape hatch, which is what the line is read for.
|
|
2454
|
+
if (this._restartDrain) return this._restartDrainFooter(this._restartDrain, W);
|
|
2455
|
+
return this._footerWithVersion(this._footerHints(), W);
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
/** What the keyboard does on the screen the operator is looking at. Composed
|
|
2459
|
+
* without regard to width: fitting it to the line is _renderFooter's job. */
|
|
2460
|
+
_footerHints() {
|
|
2321
2461
|
switch (this.mode) {
|
|
2322
2462
|
case 'normal':
|
|
2323
2463
|
return this.remote
|
|
@@ -2343,6 +2483,12 @@ export class TUI {
|
|
|
2343
2483
|
: 'default';
|
|
2344
2484
|
return ` ${dim('↑↓')} select ${dim('←→')} target: ${target} ${bold('Enter')} pin ${bold('Esc')} cancel`;
|
|
2345
2485
|
}
|
|
2486
|
+
// Both keys leave, because each move has already been saved: there is
|
|
2487
|
+
// no pending change for one to commit and the other to throw away, and
|
|
2488
|
+
// offering "cancel" would promise an undo this screen does not have.
|
|
2489
|
+
if (this.selAction === 'reorder') {
|
|
2490
|
+
return ` ${dim('↑↓')} select ${dim('←→')} move ${bold('Enter')}/${bold('Esc')} done`;
|
|
2491
|
+
}
|
|
2346
2492
|
const act = this.selAction === 'toggle' ? 'enable/disable' : 'remove';
|
|
2347
2493
|
return ` ${dim('↑↓')} select ${bold('Enter')} ${act} ${bold('Esc')} cancel`;
|
|
2348
2494
|
}
|
|
@@ -2354,4 +2500,79 @@ export class TUI {
|
|
|
2354
2500
|
return '';
|
|
2355
2501
|
}
|
|
2356
2502
|
}
|
|
2503
|
+
|
|
2504
|
+
/**
|
|
2505
|
+
* `hints` with the build label — and, when one is waiting, an update marker —
|
|
2506
|
+
* set against the right edge of a W-column line. This is the only place the
|
|
2507
|
+
* display names either: the header carries its title and the port block and
|
|
2508
|
+
* nothing else.
|
|
2509
|
+
*
|
|
2510
|
+
* Composed to exactly W, never less. The paint loop pads every short line out
|
|
2511
|
+
* to the terminal width and truncates the tail of every long one (fitLine), so
|
|
2512
|
+
* a label merely appended is pushed out of the corner it was put in, and a
|
|
2513
|
+
* line built past W has that same corner eaten. Both failures are silent, and
|
|
2514
|
+
* both look like the label was never drawn at all.
|
|
2515
|
+
*
|
|
2516
|
+
* The hints are never cut to make room. They are the only thing on this line
|
|
2517
|
+
* that says what the keyboard does: a build the operator cannot read costs
|
|
2518
|
+
* them nothing, a key they cannot read costs them the screen. So the label is
|
|
2519
|
+
* fitted to whatever the hints leave over — fitHeadLabel spends the build sha
|
|
2520
|
+
* first and then the head of the version — and it is dropped whole when what
|
|
2521
|
+
* is left will not carry it.
|
|
2522
|
+
*
|
|
2523
|
+
* @param {string} hints
|
|
2524
|
+
* @param {number} W
|
|
2525
|
+
*/
|
|
2526
|
+
_footerWithVersion(hints, W) {
|
|
2527
|
+
// In attach mode the dashboard names the server's build, not this
|
|
2528
|
+
// process's, so the account manager's answer wins for both. It arrives
|
|
2529
|
+
// sanitized (applyStatus) and starts empty, which keeps the corner blank
|
|
2530
|
+
// until the first poll rather than briefly naming the local checkout as if
|
|
2531
|
+
// it were the server's. A local AccountManager has neither property.
|
|
2532
|
+
// `??` on purpose: '' is that deliberate blank, not a miss.
|
|
2533
|
+
const label = this.am.versionLabel ?? this.versionLabel;
|
|
2534
|
+
const upd = this.am.updateAvailable ?? this.updateAvailable;
|
|
2535
|
+
// The marker rides with the label and is never drawn without it. Alone in
|
|
2536
|
+
// the corner a bare glyph names nothing it could be an update TO, and reads
|
|
2537
|
+
// as one more key hint — the one thing this end of the line must not look
|
|
2538
|
+
// like. It would also be the wrong answer for an attached dashboard that
|
|
2539
|
+
// has not polled yet: no server label, but this process's update flag.
|
|
2540
|
+
if (!label) return hints;
|
|
2541
|
+
const hw = vw(hints);
|
|
2542
|
+
// Budgeted before the label is cut, so a shortened label and its marker
|
|
2543
|
+
// still fit the room they were measured against.
|
|
2544
|
+
const markerW = upd ? 2 : 0;
|
|
2545
|
+
// One column of margin at the edge — what the rule under the header and
|
|
2546
|
+
// the header's port block both leave.
|
|
2547
|
+
const text = fitHeadLabel(label, W - hw - FOOT_GAP - markerW - 1);
|
|
2548
|
+
if (!text) return hints;
|
|
2549
|
+
const marker = upd ? ` ${green('▲')}` : '';
|
|
2550
|
+
return `${hints}${' '.repeat(W - hw - vw(text) - markerW - 1)}${dim(text)}${marker} `;
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
/**
|
|
2554
|
+
* The footer while this process drains to a restart: how long the wait has
|
|
2555
|
+
* run against the bound it cannot exceed, what is still holding it, and the
|
|
2556
|
+
* way out of it.
|
|
2557
|
+
*
|
|
2558
|
+
* Elapsed against the deadline, not a countdown. The drain ends when the last
|
|
2559
|
+
* request does, which is usually long before 30s, so a number counting down
|
|
2560
|
+
* to a moment that will not arrive would be the wrong kind of wrong — worse
|
|
2561
|
+
* than one counting up to a bound that may never be reached.
|
|
2562
|
+
*
|
|
2563
|
+
* Whole seconds: the frame is composed twice a second while a drain runs, and
|
|
2564
|
+
* tenths would buy a full repaint every time for a digit nobody reads.
|
|
2565
|
+
*
|
|
2566
|
+
* Cut by dropping a whole clause rather than by leaving it to fitLine, which
|
|
2567
|
+
* truncates the tail — and at 40 columns the tail is the escape hatch.
|
|
2568
|
+
*
|
|
2569
|
+
* @param {{ startedAt: number, deadlineMs: number, inFlight: () => number }} drain
|
|
2570
|
+
* @param {number} W
|
|
2571
|
+
*/
|
|
2572
|
+
_restartDrainFooter(drain, W) {
|
|
2573
|
+
const secs = Math.max(0, Math.round((Date.now() - drain.startedAt) / 1000));
|
|
2574
|
+
const state = `${yellow('Restarting')} ${secs}s/${Math.round(drain.deadlineMs / 1000)}s ${drain.inFlight()} in flight`;
|
|
2575
|
+
const lines = [` ${state} ${dim('ctrl-c to go now')}`, ` ${state}`];
|
|
2576
|
+
return lines.find(line => vw(line) <= W) ?? lines[lines.length - 1];
|
|
2577
|
+
}
|
|
2357
2578
|
}
|
package/src/updater.js
CHANGED
|
@@ -44,9 +44,22 @@ export function currentVersion(root = packageRoot()) {
|
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* How the running copy identifies itself, for display: the exact tag when the
|
|
47
|
-
* checkout sits on one, else the
|
|
48
|
-
*
|
|
49
|
-
* update one, so nothing should offer to.
|
|
47
|
+
* checkout sits on one, else the package.json version joined to the short sha,
|
|
48
|
+
* else whichever of those two is readable, else the literal `local`. `git`
|
|
49
|
+
* reports a checkout — npm cannot update one, so nothing should offer to.
|
|
50
|
+
*
|
|
51
|
+
* A checkout needs both halves. The sha is here because package.json alone
|
|
52
|
+
* cannot tell a published release from a local tarball built out of it, so the
|
|
53
|
+
* sha is what marks a checkout at a glance. But it answers "which commit", and
|
|
54
|
+
* the question asked after a restart is "which build" — which a sha never
|
|
55
|
+
* answers, and which is the whole reason this label exists. The two are joined
|
|
56
|
+
* with `+`, semver's build metadata, because that is precisely what the sha is:
|
|
57
|
+
* the same version, pinned to the build that produced it. It also tells a
|
|
58
|
+
* caller short of columns which end to spend, build metadata being by
|
|
59
|
+
* definition the part that is not the identity.
|
|
60
|
+
*
|
|
61
|
+
* A tag still wins outright, and alone: it names a release and a commit at
|
|
62
|
+
* once, so appending the sha to it would say the same thing twice.
|
|
50
63
|
*
|
|
51
64
|
* The git calls are pinned to the package root. `teamclaude server` is started
|
|
52
65
|
* from the operator's own project directory, and resolving against the process
|
|
@@ -59,18 +72,22 @@ export function currentVersion(root = packageRoot()) {
|
|
|
59
72
|
*/
|
|
60
73
|
export async function resolveVersionLabel({ root = packageRoot(), exec = pexec } = {}) {
|
|
61
74
|
const git = existsSync(join(root, '.git'));
|
|
75
|
+
const version = currentVersion(root);
|
|
62
76
|
if (git) {
|
|
63
77
|
/** @type {{ cwd: string, encoding: 'utf8', timeout: number }} */
|
|
64
78
|
const opts = { cwd: root, encoding: 'utf8', timeout: 2000 };
|
|
65
|
-
|
|
66
|
-
|
|
79
|
+
/** @param {string[]} args */
|
|
80
|
+
const ask = async (args) => {
|
|
67
81
|
try {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
82
|
+
return safeLine((await exec('git', args, opts)).stdout, LABEL_MAX);
|
|
83
|
+
} catch { return ''; /* not on a tag, a shallow or broken checkout, or no git binary */ }
|
|
84
|
+
};
|
|
85
|
+
const tag = await ask(['describe', '--tags', '--exact-match', 'HEAD']);
|
|
86
|
+
if (tag) return { label: tag, git };
|
|
87
|
+
const sha = await ask(['rev-parse', '--short', 'HEAD']);
|
|
88
|
+
if (sha) return { label: safeLine(version ? `${version}+${sha}` : sha, LABEL_MAX), git };
|
|
72
89
|
}
|
|
73
|
-
return { label: safeLine(
|
|
90
|
+
return { label: safeLine(version || 'local', LABEL_MAX), git };
|
|
74
91
|
}
|
|
75
92
|
|
|
76
93
|
/** Numeric compare of x.y.z, then the pre-release tail. >0 if a is newer.
|