@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rikcodes/teamclaude",
3
- "version": "1.1.20-rik.12",
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
- async function shutdown() {
779
- if (shuttingDown) process.exit(0); // second ctrl-c: stop waiting, just go
780
- shuttingDown = true;
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
- try { tui?.stop(); } catch { /* terminal already restored */ }
821
- stopTitle();
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 display came down and the listener
844
- // closed: there is no serving state left to return to, so say what broke
845
- // and let the relaunch be the recovery.
846
- console.error(`[TeamClaude] Drain failed: ${err.message}`);
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
 
@@ -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.11"), so a backgrounded or tabbed
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, for the same reason: a tab strip gives a
36
- // title a few dozen columns at best, and neither string was chosen here.
37
- const build = version ? ` ${truncate(version, 20)}` : '';
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
- * Cut from the LEFT, unlike every other truncation here, because what tells
291
- * one build from the next is its tail: `…rik.11` still identifies the build,
292
- * `1.1.2…` identifies the three before it just as well. Sliced by code unit
293
- * against a display-width budget — a version or a sha is ASCII, and a label
294
- * arriving over the wire is measured again by the caller before it is placed,
295
- * so a wide glyph costs the label its slot rather than the header its width.
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
- const w = vw(label);
300
- if (max >= w) return label;
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 `…${label.slice(label.length - (max - 1))}`;
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
- _tickDelay() { return this.active.size > 0 ? SPIN_MS : IDLE_TICK_MS; }
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 stops this TUI first, so the line below is on screen
1161
- // only for the instant before the display goes and the drain reports itself
1162
- // in plain text.
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
- _renderFooter() {
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 short sha, else the shipped package.json
48
- * version, else the literal `local`. `git` reports a checkout — npm cannot
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
- const probes = [['describe', '--tags', '--exact-match', 'HEAD'], ['rev-parse', '--short', 'HEAD']];
66
- for (const args of probes) {
79
+ /** @param {string[]} args */
80
+ const ask = async (args) => {
67
81
  try {
68
- const label = safeLine((await exec('git', args, opts)).stdout, LABEL_MAX);
69
- if (label) return { label, git };
70
- } catch { /* not on a tag, a shallow or broken checkout, or no git binary */ }
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(currentVersion(root) || 'local', LABEL_MAX), git };
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.