@rikcodes/teamclaude 1.1.20-rik.12 → 1.1.20-rik.13
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 +48 -9
- package/src/terminal-title.js +8 -4
- package/src/tui.js +109 -18
- 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.13",
|
|
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/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/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
|
@@ -287,19 +287,31 @@ export function fitLine(s, w) {
|
|
|
287
287
|
|
|
288
288
|
/** The build label at `max` columns, or '' when nothing legible fits.
|
|
289
289
|
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
290
|
+
* Build metadata is spent before anything is cut. A checkout labels itself
|
|
291
|
+
* `<version>+<sha>`, and of the two it is the version the header is read for;
|
|
292
|
+
* the sha only says which build of it. Shortening the sha instead would be
|
|
293
|
+
* worse than losing it — four hex digits name no commit, and are read as if
|
|
294
|
+
* they did.
|
|
295
|
+
*
|
|
296
|
+
* What is left is cut from the LEFT, unlike every other truncation here,
|
|
297
|
+
* because what tells one build from the next is its tail: `…rik.11` still
|
|
298
|
+
* identifies the build, `1.1.2…` identifies the three before it just as well.
|
|
299
|
+
* Sliced by code unit against a display-width budget — a version or a sha is
|
|
300
|
+
* ASCII, and a label arriving over the wire is measured again by the caller
|
|
301
|
+
* before it is placed, so a wide glyph costs the label its slot rather than
|
|
302
|
+
* the header its width.
|
|
296
303
|
* @param {string} label
|
|
297
304
|
* @param {number} max */
|
|
298
305
|
export function fitHeadLabel(label, max) {
|
|
299
|
-
|
|
300
|
-
|
|
306
|
+
if (max >= vw(label)) return label;
|
|
307
|
+
// Everything after the last `+` is semver build metadata, which is by
|
|
308
|
+
// definition not the identity — so it is what gets spent first. `> 0` keeps a
|
|
309
|
+
// label that is nothing but metadata from being spent down to nothing.
|
|
310
|
+
const plus = label.lastIndexOf('+');
|
|
311
|
+
const bare = plus > 0 ? label.slice(0, plus) : label;
|
|
312
|
+
if (max >= vw(bare)) return bare;
|
|
301
313
|
if (max < HEAD_LABEL_MIN) return '';
|
|
302
|
-
return `…${
|
|
314
|
+
return `…${bare.slice(bare.length - (max - 1))}`;
|
|
303
315
|
}
|
|
304
316
|
|
|
305
317
|
function formatReset(resetTs) {
|
|
@@ -492,6 +504,15 @@ export class TUI {
|
|
|
492
504
|
this.frame = 0;
|
|
493
505
|
this.running = false;
|
|
494
506
|
this.timer = null;
|
|
507
|
+
// Set while THIS PROCESS is draining to be relaunched: when the wait
|
|
508
|
+
// started, what bounds it, and how to ask how much is still running.
|
|
509
|
+
// Deliberately not named `draining`: the header's `drain N` marker and the
|
|
510
|
+
// account manager's drainingCount() are SESSION draining — sessions being
|
|
511
|
+
// moved off an account during a rotation — and the two have nothing to do
|
|
512
|
+
// with each other. One is a request finishing somewhere else; this one is
|
|
513
|
+
// the process going away.
|
|
514
|
+
/** @type {{ startedAt: number, deadlineMs: number, inFlight: () => number }|null} */
|
|
515
|
+
this._restartDrain = null;
|
|
495
516
|
// Injectable so a test can drive the repaint tick by hand instead of
|
|
496
517
|
// sleeping through real 500ms/5s intervals.
|
|
497
518
|
this._setTimeout = setTimeout;
|
|
@@ -570,8 +591,11 @@ export class TUI {
|
|
|
570
591
|
this._scheduleTick();
|
|
571
592
|
}
|
|
572
593
|
|
|
573
|
-
/** Fast while something is animating, slow when there is nothing to animate.
|
|
574
|
-
|
|
594
|
+
/** Fast while something is animating, slow when there is nothing to animate.
|
|
595
|
+
* A restart drain counts as animating: an idle tick is five seconds, and an
|
|
596
|
+
* elapsed counter that moves once every five of them reads as a frozen
|
|
597
|
+
* screen — which is the complaint keeping the display up exists to answer. */
|
|
598
|
+
_tickDelay() { return (this.active.size > 0 || this._restartDrain) ? SPIN_MS : IDLE_TICK_MS; }
|
|
575
599
|
|
|
576
600
|
_scheduleTick() {
|
|
577
601
|
if (!this.running) return;
|
|
@@ -626,6 +650,28 @@ export class TUI {
|
|
|
626
650
|
process.stdin.pause();
|
|
627
651
|
}
|
|
628
652
|
|
|
653
|
+
/**
|
|
654
|
+
* The server has begun draining to be relaunched: its listener is closed and
|
|
655
|
+
* it is waiting out the requests still running before it exits on 75.
|
|
656
|
+
*
|
|
657
|
+
* The display stays up for all of it, and this is what puts the drain on it.
|
|
658
|
+
* The wait is up to 30 seconds the operator asked for by pressing `u`, and
|
|
659
|
+
* the two things they want from it — how long it has run, and what is still
|
|
660
|
+
* holding it — are already here. The dashboard used to come down first, so
|
|
661
|
+
* the answer to both was a blank console for the duration.
|
|
662
|
+
*
|
|
663
|
+
* There is no matching "ended": the process exits at the end of the drain,
|
|
664
|
+
* and stop() is what takes the screen back (index.js, immediately before the
|
|
665
|
+
* exit and on every abrupt way out of one).
|
|
666
|
+
*
|
|
667
|
+
* @param {{ deadlineMs: number, inFlight: () => number }} drain
|
|
668
|
+
*/
|
|
669
|
+
restartDrainStarted({ deadlineMs, inFlight }) {
|
|
670
|
+
this._restartDrain = { startedAt: Date.now(), deadlineMs, inFlight };
|
|
671
|
+
this._retick(); // idle cadence → something to animate again
|
|
672
|
+
if (this.running) this.render();
|
|
673
|
+
}
|
|
674
|
+
|
|
629
675
|
// A title lookup costs a directory scan and a file read, so it stays off the
|
|
630
676
|
// render path: this returns what is cached and schedules the rest.
|
|
631
677
|
_sessionTag(sid) {
|
|
@@ -702,6 +748,17 @@ export class TUI {
|
|
|
702
748
|
_key(k) {
|
|
703
749
|
if (k === 'ctrl-c') { this.stop(); this.onQuit?.(); return; }
|
|
704
750
|
|
|
751
|
+
// Draining to a restart: the listener is closed and this process is on its
|
|
752
|
+
// way out, so switching, disabling, probing, syncing or editing anything
|
|
753
|
+
// would act on state that is about to be discarded, and `u` is already
|
|
754
|
+
// running. Ctrl-c above stays the one key that means something — the escape
|
|
755
|
+
// from the wait — and the footer says so, which is why nothing is logged
|
|
756
|
+
// for the rest: a line per keypress would push the drain's own progress out
|
|
757
|
+
// of the pane. `q` is not an exception on purpose. An unattended restart
|
|
758
|
+
// can begin while the operator is typing into a prompt, and a letter key
|
|
759
|
+
// that quietly became "quit" is a poor way to find that out.
|
|
760
|
+
if (this._restartDrain) return;
|
|
761
|
+
|
|
705
762
|
switch (this.mode) {
|
|
706
763
|
case 'normal': this._keyNormal(k); break;
|
|
707
764
|
case 'select': this._keySelect(k); break;
|
|
@@ -1157,12 +1214,15 @@ export class TUI {
|
|
|
1157
1214
|
|
|
1158
1215
|
// `u`: pick up a new build now instead of waiting for the next lull. The
|
|
1159
1216
|
// server owns what happens next — it drains, then exits asking to be
|
|
1160
|
-
// relaunched — and it
|
|
1161
|
-
//
|
|
1162
|
-
// in
|
|
1217
|
+
// relaunched — and it now keeps this TUI up for the whole drain, which is
|
|
1218
|
+
// where the progress goes (restartDrainStarted, and the footer). Its own
|
|
1219
|
+
// "draining, up to 30s" line lands in the pane beside the counter, so there
|
|
1220
|
+
// is nothing left for this to announce.
|
|
1221
|
+
//
|
|
1222
|
+
// A second `u` is not a second restart: the server guards on its own flag and
|
|
1223
|
+
// would do nothing at all, so a log line here would be a claim that it had.
|
|
1163
1224
|
_doRestart() {
|
|
1164
|
-
if (!this.onRestart) return;
|
|
1165
|
-
this._addLog('Draining before restart...');
|
|
1225
|
+
if (!this.onRestart || this._restartDrain) return;
|
|
1166
1226
|
this.onRestart();
|
|
1167
1227
|
}
|
|
1168
1228
|
|
|
@@ -1712,7 +1772,7 @@ export class TUI {
|
|
|
1712
1772
|
|
|
1713
1773
|
// ── Footer
|
|
1714
1774
|
lines.push(' ' + dim('─'.repeat(W - 2)));
|
|
1715
|
-
lines.push(this._renderFooter());
|
|
1775
|
+
lines.push(this._renderFooter(W));
|
|
1716
1776
|
|
|
1717
1777
|
// Write buffer
|
|
1718
1778
|
let buf = `${ESC}H`;
|
|
@@ -2317,7 +2377,12 @@ export class TUI {
|
|
|
2317
2377
|
}
|
|
2318
2378
|
}
|
|
2319
2379
|
|
|
2320
|
-
|
|
2380
|
+
/** @param {number} [W] columns to compose against; only the drain line uses it */
|
|
2381
|
+
_renderFooter(W = process.stdout.columns || 80) {
|
|
2382
|
+
// A restart drain outranks every mode: the keys this line would otherwise
|
|
2383
|
+
// advertise are all refused while one runs (see _key), and how far the
|
|
2384
|
+
// drain has got is the only thing on screen worth the row.
|
|
2385
|
+
if (this._restartDrain) return this._restartDrainFooter(this._restartDrain, W);
|
|
2321
2386
|
switch (this.mode) {
|
|
2322
2387
|
case 'normal':
|
|
2323
2388
|
return this.remote
|
|
@@ -2354,4 +2419,30 @@ export class TUI {
|
|
|
2354
2419
|
return '';
|
|
2355
2420
|
}
|
|
2356
2421
|
}
|
|
2422
|
+
|
|
2423
|
+
/**
|
|
2424
|
+
* The footer while this process drains to a restart: how long the wait has
|
|
2425
|
+
* run against the bound it cannot exceed, what is still holding it, and the
|
|
2426
|
+
* way out of it.
|
|
2427
|
+
*
|
|
2428
|
+
* Elapsed against the deadline, not a countdown. The drain ends when the last
|
|
2429
|
+
* request does, which is usually long before 30s, so a number counting down
|
|
2430
|
+
* to a moment that will not arrive would be the wrong kind of wrong — worse
|
|
2431
|
+
* than one counting up to a bound that may never be reached.
|
|
2432
|
+
*
|
|
2433
|
+
* Whole seconds: the frame is composed twice a second while a drain runs, and
|
|
2434
|
+
* tenths would buy a full repaint every time for a digit nobody reads.
|
|
2435
|
+
*
|
|
2436
|
+
* Cut by dropping a whole clause rather than by leaving it to fitLine, which
|
|
2437
|
+
* truncates the tail — and at 40 columns the tail is the escape hatch.
|
|
2438
|
+
*
|
|
2439
|
+
* @param {{ startedAt: number, deadlineMs: number, inFlight: () => number }} drain
|
|
2440
|
+
* @param {number} W
|
|
2441
|
+
*/
|
|
2442
|
+
_restartDrainFooter(drain, W) {
|
|
2443
|
+
const secs = Math.max(0, Math.round((Date.now() - drain.startedAt) / 1000));
|
|
2444
|
+
const state = `${yellow('Restarting')} ${secs}s/${Math.round(drain.deadlineMs / 1000)}s ${drain.inFlight()} in flight`;
|
|
2445
|
+
const lines = [` ${state} ${dim('ctrl-c to go now')}`, ` ${state}`];
|
|
2446
|
+
return lines.find(line => vw(line) <= W) ?? lines[lines.length - 1];
|
|
2447
|
+
}
|
|
2357
2448
|
}
|
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.
|