@rikcodes/teamclaude 1.1.20-rik.11 → 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.11",
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",
@@ -1437,6 +1437,26 @@ export class AccountManager {
1437
1437
  return { ...this.sessionTracker.stats(), mode: this.distributionMode, draining: this.drainingCount() };
1438
1438
  }
1439
1439
 
1440
+ /**
1441
+ * Client requests running right now, fleet-wide. What a drain waits on.
1442
+ *
1443
+ * Two counters, because neither one spans a whole request. A session's hold
1444
+ * is taken across the ENTIRE client request, a multi-minute stream included
1445
+ * (see beginSession), which is precisely what a restart must not cut — but it
1446
+ * is only taken for a request that carries a session id. An account's
1447
+ * `inFlight` covers the rest, though only as far as the response headers:
1448
+ * storm control releases that slot there so streaming bodies do not tie up
1449
+ * concurrency. Summing them double-counts a session's request while it is
1450
+ * upstream-bound, which can only make a drain wait longer than it strictly
1451
+ * must — and the drain deadline bounds that.
1452
+ */
1453
+ inFlightRequests() {
1454
+ // The tracker is a plain object to the checker here (see the constructor),
1455
+ // so the cast is how its counter is reached — not a claim about the value.
1456
+ const sessions = /** @type {any} */ (this.sessionTracker).inFlightCount();
1457
+ return sessions + this.accounts.reduce((n, a) => n + (a.inFlight || 0), 0);
1458
+ }
1459
+
1440
1460
  /**
1441
1461
  * Like getActiveAccount, but if the selected account's OAuth token has ALREADY
1442
1462
  * expired it blocks on a refresh before returning — so a caller that injects
package/src/config.js CHANGED
@@ -102,6 +102,7 @@ export function createDefaultConfig() {
102
102
  projection: { enabled: true, windowMinutes: 90, wasteFloor: 0.1 },
103
103
  eventLogging: 'hide',
104
104
  activityLog: null,
105
+ autoRestart: false,
105
106
  blockedModels: [],
106
107
  accounts: [],
107
108
  };
package/src/index.js CHANGED
@@ -37,6 +37,8 @@ import { SessionTitles } from './session-titles.js';
37
37
  import { RemoteControl, createAttachSession } from './tui-remote.js';
38
38
  import { SxManager } from './sx.js';
39
39
  import { autoUpdate, checkForUpdate, currentVersion, resolveVersionLabel, runUpdate, installKind, updateAvailableFromCache, PKG_NAME } from './updater.js';
40
+ import { drainServer, superviseServer, DRAIN_DEADLINE_MS, RESTART_COUNT_ENV, RESTART_EXIT_CODE, SUPERVISED_ENV } from './restart.js';
41
+ import { createVersionSource, UpdateWatcher } from './update-watch.js';
40
42
  import { renderStatus, formatPercent } from './status-renderer.js';
41
43
  import { sanitizeText } from './safe-text.js';
42
44
  import { ClientUsageTracker, UsageDimensionTracker } from './client-usage.js';
@@ -223,6 +225,12 @@ switch (command) {
223
225
  // ── server ──────────────────────────────────────────────────
224
226
 
225
227
  async function serverCommand() {
228
+ // --supervise: this process supervises, it does not serve. The config, the
229
+ // accounts, the certificates and the terminal all belong to the child it
230
+ // starts, so this branch comes before any of them is touched.
231
+ if (args.includes('--supervise')) {
232
+ process.exit(await superviseServer());
233
+ }
226
234
  // Installed first: the server is the long-lived process, it runs under a TUI
227
235
  // that repaints over anything Node prints on the way out, and a crash here
228
236
  // takes every routed session with it. Without this, a proxy that vanished
@@ -331,6 +339,7 @@ async function serverCommand() {
331
339
  const persistQuotaState = () =>
332
340
  saveState({ quota: accountManager.exportQuotaState(), clients: clientUsage.export(), usageDimensions: dimensionUsage.export(), sidecars: sidecar?.exportPids() || savedState?.sidecars || {} })
333
341
  .catch(err => console.error(`[TeamClaude] Failed to save quota state: ${err.message}`));
342
+ /** @type {ReturnType<typeof setInterval>|null} */
334
343
  let quotaSaveInterval = null;
335
344
 
336
345
  // Persist refreshed tokens back to config (re-read from disk to avoid clobbering
@@ -376,12 +385,23 @@ async function serverCommand() {
376
385
  const headless = args.includes('--headless') || args.includes('--no-tui');
377
386
  const useTUI = !headless && process.stdout.isTTY && process.stdin.isTTY;
378
387
 
388
+ // Is anything waiting to relaunch this process? Exit 75 is a request, not a
389
+ // mechanism: with nothing supervising, it is merely an exit. So the two things
390
+ // that can ask for one — the `u` key and autoRestart — are wired only when the
391
+ // answer is yes. `teamclaude server --supervise` sets this on its child, and
392
+ // the shell loop in docs/usage.md exports it for the same reason.
393
+ const supervised = process.env[SUPERVISED_ENV] === '1';
394
+ const restartCount = Number(process.env[RESTART_COUNT_ENV]) || 0;
395
+
379
396
  // Opt-in background quota probe (config.quotaProbeSeconds, default 0 = off).
397
+ /** @type {Prober|null} */
380
398
  let prober = null;
381
399
  // Opt-in keep-warm scheduler (interval or persisted reset-target schedule).
400
+ /** @type {Warmer|null} */
382
401
  let warmer = null;
383
402
  // Supervised sidecar processes (config.sidecars, default none) — e.g. a local
384
403
  // Anthropic→OpenAI translating proxy that a third-party account routes to.
404
+ /** @type {Sidecar|null} */
385
405
  let sidecar = null;
386
406
  const serverStartedAt = Date.now();
387
407
  // Read once here, not per request: `teamclaude update` swaps package.json on
@@ -491,7 +511,9 @@ async function serverCommand() {
491
511
  };
492
512
 
493
513
  let tui = null;
494
- /** @type {Object} */
514
+ // A bag of optional callbacks the application installs on a shared object the
515
+ // server, the MITM listener and the control endpoints all read through.
516
+ /** @type {Record<string, any>} */
495
517
  let hooks = {};
496
518
 
497
519
  if (useTUI) {
@@ -531,6 +553,10 @@ async function serverCommand() {
531
553
  // POSIX signals (defined below). In raw mode ctrl-c never reaches the OS as
532
554
  // a signal, so without this the process would only tear down via keypress.
533
555
  onQuit: () => shutdown(),
556
+ // `u`. Null without a supervisor: draining to an exit nothing acts on
557
+ // would take the proxy — and every session on it — down, which is the
558
+ // opposite of what a key labelled "update" offers.
559
+ onRestart: supervised ? () => { drainAndRestart('Restart requested'); } : null,
534
560
  });
535
561
  hooks = {
536
562
  onRequestStart: (id, info) => tui.onRequestStart(id, info),
@@ -662,13 +688,14 @@ async function serverCommand() {
662
688
  }
663
689
  if (tui) {
664
690
  tui.start();
665
- console.log(`Listening on port ${port} with ${accounts.length} account(s)`);
691
+ console.log(`Listening on port ${port} with ${accounts.length} account(s) on ${versionLabel}`);
666
692
  } else {
667
693
  const sep = '='.repeat(60);
668
694
  console.log('');
669
695
  console.log(sep);
670
696
  console.log(' TeamClaude Proxy');
671
697
  console.log(sep);
698
+ console.log(` Version: ${versionLabel}`);
672
699
  console.log(` Bind: ${bindHost}:${port}${bindHost === '127.0.0.1' ? ' (localhost only)' : ' (reachable off-box — ensure proxy.apiKey is set)'}`);
673
700
  console.log(` Accounts: ${accounts.length}`);
674
701
  console.log(` Threshold: ${(threshold * 100).toFixed(0)}%`);
@@ -683,11 +710,20 @@ async function serverCommand() {
683
710
  console.log(sep);
684
711
  console.log('');
685
712
  }
713
+ // Said once, plainly. The complaint this whole path exists to answer is
714
+ // that the build changes under a window nobody is watching and nothing
715
+ // anywhere admits it: the title carries the version from here on, and this
716
+ // line is the moment it changed.
717
+ if (restartCount > 0) {
718
+ console.log(`[TeamClaude] Restarted on ${versionLabel} — relaunch #${restartCount} of this supervised run.`);
719
+ }
686
720
  });
687
721
 
688
- // Reflect the active account in the terminal title so a backgrounded/tabbed
689
- // server is glanceable. Works in both TUI and headless modes.
690
- const stopTitle = startTerminalTitleUpdater(accountManager);
722
+ // Reflect the active account and the running build in the terminal title so a
723
+ // backgrounded/tabbed server is glanceable. Works in both TUI and headless
724
+ // modes, and is the surface an unattended restart announces itself on: the
725
+ // title is all that is readable when the window is not the one in front.
726
+ const stopTitle = startTerminalTitleUpdater(accountManager, versionLabel);
691
727
 
692
728
  // Persist quota every minute; unref so it never keeps the process alive.
693
729
  quotaSaveInterval = setInterval(persistQuotaState, 60_000);
@@ -739,11 +775,27 @@ async function serverCommand() {
739
775
  // instead of re-running teardown, which would re-arm server.close() and leak a
740
776
  // 'close' listener on the server each time (MaxListenersExceededWarning).
741
777
  let shuttingDown = false;
742
- async function shutdown() {
743
- if (shuttingDown) process.exit(0); // second ctrl-c: stop waiting, just go
744
- 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 = () => {
745
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.
746
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();
747
799
  if (!tui) console.log('\n[TeamClaude] Shutting down...');
748
800
  prober?.stop();
749
801
  warmer?.stop();
@@ -760,6 +812,105 @@ async function serverCommand() {
760
812
  }
761
813
  process.on('SIGINT', shutdown);
762
814
  process.on('SIGTERM', shutdown);
815
+
816
+ // The graceful counterpart to shutdown(), for the other request an operator
817
+ // can make: not "stop now" but "come back on the new build". shutdown() is
818
+ // left exactly as it is — destroying live streams is the right answer to
819
+ // ctrl-c — while this path has to cost the fleet nothing, because the point
820
+ // of automating it is that it happens while nobody is watching.
821
+ //
822
+ // Order matters and is not the same as shutdown()'s. The flag goes up FIRST,
823
+ // so every answer still to be written carries the header that retires its
824
+ // socket (markDraining, server.js) — that, not the waiting, is what stops a
825
+ // restart from breaking sessions that were only ever idle. Then the listener
826
+ // stops accepting while the connections already open keep serving; then the
827
+ // bounded wait; then the sidecar, whose replacement the next process spawns;
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.
835
+ let draining = false;
836
+ hooks.isDraining = () => draining;
837
+ /** @param {string} why what put the restart in motion, for the line on the way out */
838
+ async function drainAndRestart(why) {
839
+ if (shuttingDown) return; // ctrl-c beat us here, or a second trigger did
840
+ shuttingDown = true;
841
+ draining = true;
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.
849
+ console.log(`\n[TeamClaude] ${why} — draining, up to ${Math.round(DRAIN_DEADLINE_MS / 1000)}s for requests in flight.`);
850
+ prober?.stop();
851
+ warmer?.stop();
852
+ eventLoopMonitor.stop();
853
+ /** @type {string|null} */
854
+ let failure = null;
855
+ // Nothing below may throw its way out. Neither caller awaits this — the TUI
856
+ // key returns to its handler and the watcher fires from a timer — so an
857
+ // escaping rejection would be an unhandled one, and crash-log.js turns that
858
+ // into exit 1: the supervisor would read a crash and stop, on the one path
859
+ // whose entire purpose is to come back.
860
+ try {
861
+ const { drained, waitedMs, inFlight } = await drainServer({
862
+ server,
863
+ inFlight: () => accountManager.inFlightRequests(),
864
+ });
865
+ console.log(drained
866
+ ? `[TeamClaude] Drained in ${(waitedMs / 1000).toFixed(1)}s. Restarting on the new build.`
867
+ : `[TeamClaude] ${inFlight} request(s) still in flight after ${(waitedMs / 1000).toFixed(0)}s — restarting anyway.`);
868
+ sidecar?.stop();
869
+ if (quotaSaveInterval) clearInterval(quotaSaveInterval);
870
+ await persistQuotaState();
871
+ } catch (err) {
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();
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}`);
887
+ process.exit(RESTART_EXIT_CODE);
888
+ }
889
+
890
+ // Opt-in, and only where a restart would actually happen. Nothing below runs
891
+ // on a default config, so the install probe behind createVersionSource is not
892
+ // paid for by anyone who did not ask for this.
893
+ if (config.autoRestart && !supervised) {
894
+ console.error('[TeamClaude] autoRestart is set, but nothing will relaunch this process — start it with "teamclaude server --supervise". Auto-restart is off for this run.');
895
+ } else if (config.autoRestart) {
896
+ const source = await createVersionSource();
897
+ if (!source) {
898
+ console.error('[TeamClaude] autoRestart is set, but this copy is a local or npx install — nothing rewrites it, so a restart would come back on the same build. Auto-restart is off for this run.');
899
+ } else {
900
+ new UpdateWatcher({
901
+ source,
902
+ // Nothing running and no session still counted active: a restart now
903
+ // costs a reconnect and nothing else.
904
+ isIdle: () => accountManager.inFlightRequests() === 0 && accountManager.sessionStats().active === 0,
905
+ onRestart: ({ build, forced }) => {
906
+ drainAndRestart(forced
907
+ ? `Build ${build} is waiting and the fleet has not gone idle`
908
+ : `Build ${build} is waiting`);
909
+ },
910
+ }).start();
911
+ console.log(`[TeamClaude] Auto-restart is on, watching ${source.describes}.`);
912
+ }
913
+ }
763
914
  }
764
915
 
765
916
  // ── import ──────────────────────────────────────────────────
@@ -2192,6 +2343,9 @@ Options:
2192
2343
  --log-to DIR Log requests/responses to DIR (server, one file per request)
2193
2344
  --activity-log FILE Append TUI activity lines to FILE (server; works in headless mode too)
2194
2345
  --headless Run the server without the interactive TUI (for backgrounding)
2346
+ --supervise (server) run the proxy as a child process and relaunch it
2347
+ whenever it drains for a new build (the TUI's 'u' key, or
2348
+ the autoRestart setting). Without it neither can restart
2195
2349
  --no-mitm (run) skip the forward proxy; route via ANTHROPIC_BASE_URL only
2196
2350
  --auto-fallback (run) if the proxy is down, launch claude directly instead
2197
2351
  of erroring out (bypasses the proxy: no rotation)
@@ -2403,13 +2557,19 @@ function argValue(flag) {
2403
2557
  return (i >= 0 && args[i + 1]) ? args[i + 1] : null;
2404
2558
  }
2405
2559
 
2406
- // Keep the terminal title in sync with the active account (e.g. "teamclaude 2/4
2407
- // work") so a backgrounded or tabbed `teamclaude server` is glanceable. TTY-only
2560
+ // Keep the terminal title in sync with the active account and the running build
2561
+ // (e.g. "teamclaude 2/4 work 1.1.20-rik.11") so a backgrounded or tabbed
2562
+ // `teamclaude server` is glanceable — and so a restart onto a new build is
2563
+ // visible there without anyone going looking. TTY-only
2408
2564
  // — never emit escapes into a pipe, a `--log-to` redirect, or a systemd journal;
2409
2565
  // opt out entirely with TEAMCLAUDE_NO_TITLE. Polls (rather than hooking every
2410
2566
  // currentIndex mutation) and writes only when the title actually changes.
2411
2567
  // Returns an idempotent stop() that restores the shell's previous title.
2412
- function startTerminalTitleUpdater(accountManager) {
2568
+ /**
2569
+ * @param {AccountManager} accountManager
2570
+ * @param {string|null} [version]
2571
+ */
2572
+ function startTerminalTitleUpdater(accountManager, version = null) {
2413
2573
  const out = process.stdout;
2414
2574
  if (!out.isTTY || process.env.TEAMCLAUDE_NO_TITLE) return () => {};
2415
2575
 
@@ -2418,7 +2578,7 @@ function startTerminalTitleUpdater(accountManager) {
2418
2578
  const total = accountManager.accounts.length;
2419
2579
  const index = Math.min(accountManager.currentIndex || 0, Math.max(0, total - 1));
2420
2580
  const name = accountManager.accounts[index]?.name || null;
2421
- const title = formatTerminalTitle({ index, total, name });
2581
+ const title = formatTerminalTitle({ index, total, name, version });
2422
2582
  if (title !== last) { last = title; out.write(titleSequence(title)); }
2423
2583
  };
2424
2584
 
package/src/restart.js ADDED
@@ -0,0 +1,186 @@
1
+ // Drain-and-restart: applying a new build without cutting a live session.
2
+ //
3
+ // Restarting the proxy used to mean ctrl-c and a fast re-run, which breaks
4
+ // every Claude Code session going through it, for two separate reasons:
5
+ //
6
+ // 1. shutdown() calls server.closeAllConnections(), which DESTROYS in-flight
7
+ // streaming responses. That abruptness is deliberate — a person holding
8
+ // ctrl-c wants out now — so it is left exactly as it is, and the graceful
9
+ // path lives here instead.
10
+ // 2. A restart kills idle keep-alive sockets the client still holds pooled.
11
+ // The client finds out by writing to a corpse, which is the same failure
12
+ // family as the two keep-alive fixes before this one.
13
+ //
14
+ // A drain answers both: stop accepting connections, tell every response on the
15
+ // way out that its socket is finished (`Connection: close`, set in server.js
16
+ // off `hooks.isDraining`), and wait for the requests already running to end.
17
+ // Clients retire their pooled sockets cooperatively and reconnect into the
18
+ // relaunched process.
19
+ //
20
+ // The relaunch itself is exit code 75 plus a supervisor, because a foreground
21
+ // TUI cannot re-exec itself: the parent exits, the shell prints a prompt, and
22
+ // the child fights it for the terminal.
23
+
24
+ import { spawn } from 'node:child_process';
25
+
26
+ // "Restart me." Chosen from the sysexits.h range (EX_TEMPFAIL) so it cannot
27
+ // collide with the 0/1 a crash or a clean quit already uses, and so a
28
+ // supervisor that does not know about it treats it as an ordinary failure.
29
+ export const RESTART_EXIT_CODE = 75;
30
+
31
+ // How long a drain waits for in-flight requests before going anyway. A stuck
32
+ // stream — an upstream that stopped sending without closing — must never be
33
+ // able to block a restart forever, and the requests that outlive this get the
34
+ // abrupt end they would have got from ctrl-c.
35
+ export const DRAIN_DEADLINE_MS = 30_000;
36
+ const DRAIN_POLL_MS = 100;
37
+
38
+ // A child that asks to be restarted this many times inside the window is
39
+ // looping, not updating: something makes the new build ask for a restart the
40
+ // moment it is up, and relaunching it forever would hide that behind a
41
+ // flickering terminal.
42
+ export const RESTART_LOOP_LIMIT = 5;
43
+ export const RESTART_LOOP_WINDOW_MS = 60_000;
44
+
45
+ // Set on a supervised child. Two things read it: the `u` key and the automatic
46
+ // restart, neither of which may exit 75 unless something is actually waiting to
47
+ // relaunch this process — otherwise "apply the update" reads as "kill the proxy
48
+ // and every session on it". The documented shell one-liner exports it too.
49
+ export const SUPERVISED_ENV = 'TEAMCLAUDE_SUPERVISED';
50
+ // How many relaunches this process is, so it can say so once it is listening.
51
+ export const RESTART_COUNT_ENV = 'TEAMCLAUDE_RESTARTS';
52
+
53
+ /**
54
+ * Stop taking work and wait for what is running to finish.
55
+ *
56
+ * `server.close()` and NOT `closeAllConnections()`: close stops the listener,
57
+ * while the connections already open keep streaming — and keep serving, which
58
+ * is the point. A client whose pooled socket is still good sends its next
59
+ * request into this window and gets a real answer carrying `Connection: close`,
60
+ * so it retires that socket itself rather than discovering it dead later. Only
61
+ * the sockets that stayed idle through the whole drain are closed, at the end,
62
+ * where the gap between the close and the relaunch is as small as it can be.
63
+ *
64
+ * Every side effect is injectable so the deadline can be tested without
65
+ * spending it.
66
+ *
67
+ * @param {Object} opts
68
+ * @param {{ close?: Function, closeIdleConnections?: Function }} opts.server
69
+ * @param {() => number} opts.inFlight requests still running, fleet-wide
70
+ * @param {number} [opts.deadlineMs]
71
+ * @param {number} [opts.pollMs]
72
+ * @param {() => number} [opts.now]
73
+ * @param {(ms: number) => Promise<void>} [opts.sleep]
74
+ * @returns {Promise<{ drained: boolean, waitedMs: number, inFlight: number }>}
75
+ */
76
+ export async function drainServer({
77
+ server, inFlight, deadlineMs = DRAIN_DEADLINE_MS, pollMs = DRAIN_POLL_MS,
78
+ now = Date.now, sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)),
79
+ }) {
80
+ const started = now();
81
+ // No callback: a CONNECT tunnel is not tracked as a connection Node will tell
82
+ // us about, so waiting for close() to call back could wait forever. What we
83
+ // wait on is the request count below, which the deadline bounds.
84
+ server.close?.();
85
+ let open = inFlight();
86
+ while (open > 0 && now() - started < deadlineMs) {
87
+ await sleep(pollMs);
88
+ open = inFlight();
89
+ }
90
+ // The sockets that never carried a response during the drain, and so never
91
+ // got the `Connection: close` that retires them politely. Closing them is
92
+ // unavoidable — the process is going — so it happens here, last, rather than
93
+ // at the start where it would yank a socket the client was about to use.
94
+ server.closeIdleConnections?.();
95
+ return { drained: open <= 0, waitedMs: now() - started, inFlight: Math.max(0, open) };
96
+ }
97
+
98
+ /**
99
+ * Run the real server as a child and relaunch it when it asks (exit 75). Any
100
+ * other exit is the child's answer and ends the supervisor with it.
101
+ *
102
+ * `stdio: 'inherit'` hands over the actual terminal, so the child's TUI owns
103
+ * raw mode, the tty size and the title exactly as it would unsupervised. The
104
+ * supervisor itself must therefore touch neither stdin nor the screen.
105
+ *
106
+ * Resolves with the exit code this process should use.
107
+ *
108
+ * @param {Object} [opts]
109
+ * @param {string[]} [opts.argv] what to run: [script, ...args], `--supervise` removed
110
+ * @param {typeof spawn} [opts.spawnFn]
111
+ * @param {string} [opts.execPath]
112
+ * @param {NodeJS.ProcessEnv} [opts.env]
113
+ * @param {(line: string) => void} [opts.log]
114
+ * @param {() => number} [opts.now]
115
+ * @param {number} [opts.limit]
116
+ * @param {number} [opts.windowMs]
117
+ * @param {(event: string, handler: Function) => void} [opts.onSignal]
118
+ */
119
+ export function superviseServer({
120
+ argv = process.argv.slice(1), spawnFn = spawn, execPath = process.execPath,
121
+ env = process.env, log = console.error, now = Date.now,
122
+ limit = RESTART_LOOP_LIMIT, windowMs = RESTART_LOOP_WINDOW_MS,
123
+ onSignal = (event, handler) => process.on(event, /** @type {any} */ (handler)),
124
+ } = {}) {
125
+ const childArgv = argv.filter(a => a !== '--supervise');
126
+ /** @type {number[]} */
127
+ const relaunches = [];
128
+ /** @type {import('node:child_process').ChildProcess|null} */
129
+ let child = null;
130
+ let stopping = false;
131
+
132
+ // SIGTERM is aimed at this pid alone, so it has to be passed on or the child
133
+ // keeps the terminal with nothing supervising it. SIGINT deliberately is NOT:
134
+ // ctrl-c goes to the whole foreground process group, so the child already has
135
+ // it, and a second one tells its shutdown() to stop waiting and exit at once
136
+ // — the graceful teardown skipped by the very key meant to allow it.
137
+ onSignal('SIGTERM', () => { stopping = true; child?.kill('SIGTERM'); });
138
+ onSignal('SIGINT', () => { stopping = true; });
139
+
140
+ return new Promise((resolve) => {
141
+ const launch = (/** @type {number} */ restarts) => {
142
+ try {
143
+ child = spawnFn(execPath, childArgv, {
144
+ stdio: 'inherit',
145
+ env: { ...env, [SUPERVISED_ENV]: '1', [RESTART_COUNT_ENV]: String(restarts) },
146
+ });
147
+ } catch (err) {
148
+ log(`[TeamClaude] Could not start the server: ${/** @type {Error} */ (err).message}`);
149
+ resolve(1);
150
+ return;
151
+ }
152
+ if (!child || typeof child.once !== 'function') { resolve(1); return; }
153
+ child.once('error', (err) => {
154
+ log(`[TeamClaude] Could not start the server: ${err.message}`);
155
+ resolve(1);
156
+ });
157
+ child.once('exit', (code, signal) => {
158
+ child = null;
159
+ // Killed rather than exited: the operator or the OS ended it, and there
160
+ // is nothing here to second-guess. 128+n is the shell's convention for
161
+ // reporting which signal it was.
162
+ if (code === null) { resolve(signal ? 128 + (signalNumber(signal) || 0) : 1); return; }
163
+ if (code !== RESTART_EXIT_CODE || stopping) { resolve(code); return; }
164
+
165
+ const at = now();
166
+ relaunches.push(at);
167
+ while (relaunches.length && at - relaunches[0] > windowMs) relaunches.shift();
168
+ if (relaunches.length > limit) {
169
+ log(`[TeamClaude] The server asked to restart ${relaunches.length} times in ${Math.round(windowMs / 1000)}s — it is looping, not updating. Giving up; start it again by hand once you know why.`);
170
+ resolve(1);
171
+ return;
172
+ }
173
+ launch(restarts + 1);
174
+ });
175
+ };
176
+ launch(0);
177
+ });
178
+ }
179
+
180
+ /** Signal name to number, for the 128+n exit convention. Unknown names report
181
+ * 0, which reads as "killed, we don't know by what" rather than throwing.
182
+ * @param {NodeJS.Signals|string} name */
183
+ function signalNumber(name) {
184
+ const table = /** @type {Record<string, number>} */ ({ SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 });
185
+ return table[String(name)] || 0;
186
+ }
package/src/server.js CHANGED
@@ -144,6 +144,36 @@ const CONNECTION_SPECIFIC_HEADERS = new Set([
144
144
  'proxy-connection', 'te', 'trailer',
145
145
  ]);
146
146
 
147
+ /**
148
+ * While the server is draining, tell this response's client that the socket is
149
+ * finished with.
150
+ *
151
+ * The cooperative half of a restart (see restart.js). A process that simply
152
+ * stops leaves every pooled keep-alive socket a corpse the client discovers
153
+ * only by writing to it — the failure this proxy has now been bitten by twice,
154
+ * and the reason a restart "breaks running sessions" even when the drain waits
155
+ * politely for the requests it can see. `Connection: close` retires the socket
156
+ * from the client's pool the moment this response lands, so the next request
157
+ * opens a fresh connection into the relaunched process.
158
+ *
159
+ * Set with setHeader rather than in a writeHead object: every answer below
160
+ * builds its own header object, and `connection` is stripped from all of them
161
+ * as hop-by-hop, so this survives the merge on each of the dozen exits instead
162
+ * of having to be added to each.
163
+ *
164
+ * HTTP/2 is left alone — the header is illegal there (Node refuses it) and a
165
+ * MITM tunnel's h2 session goes away with the CONNECT socket regardless.
166
+ *
167
+ * @param {import('node:http').IncomingMessage} req
168
+ * @param {import('node:http').ServerResponse} res
169
+ * @param {any} hooks the application's hook bag; `isDraining` is optional
170
+ */
171
+ export function markDraining(req, res, hooks) {
172
+ if (!hooks.isDraining?.()) return;
173
+ if ((req.httpVersionMajor || 1) >= 2) return;
174
+ try { res.setHeader('Connection', 'close'); } catch { /* already answered */ }
175
+ }
176
+
147
177
  // Constant-time proxy-API-key comparison (both the HTTP gate and the CONNECT
148
178
  // gate use it). Returns false on any type/length mismatch without leaking timing.
149
179
  export function safeKeyEqual(a, b) {
@@ -270,6 +300,9 @@ export function createProxyServer(accountManager, config, hooks = {}, sx = null,
270
300
 
271
301
  const requestHandler = async (req, res) => {
272
302
  try {
303
+ // Before any exit below writes a head, control endpoints included: a
304
+ // status poll and a dashboard refresh hold pooled sockets too.
305
+ markDraining(req, res, hooks);
273
306
  // Dashboard page — served BEFORE the auth gate on purpose. The page is a
274
307
  // static asset containing no data: everything it shows comes from
275
308
  // /teamclaude/status, which stays behind the gate and is fetched by the
@@ -873,6 +906,9 @@ export function clientSessionId(headers) {
873
906
  export function createProxyRequestListener({ accountManager, upstream, logDir = null, hooks = {}, sx = null, holdMs = 0, config = {}, forcedPin = null, egress = null, clientUsage = null, forcedClient = null, dimensionUsage = null }) {
874
907
  let counter = 0;
875
908
  return async (req, res) => {
909
+ // Again here, not only in the base server's wrapper: this listener is also
910
+ // the MITM tunnel's, where nothing above it has seen the request.
911
+ markDraining(req, res, hooks);
876
912
  // The activity entry this request opened, while it is still open. Every
877
913
  // consumer holds the row until it is told the request ended, so exactly one
878
914
  // path must close it. Each closing site clears this first, which is how the
@@ -341,6 +341,20 @@ export class SessionTracker {
341
341
  return s.inFlight > 0 || now - s.lastSeen <= this.activeTtlMs;
342
342
  }
343
343
 
344
+ /** Requests in flight across every session, for a drain to wait on.
345
+ *
346
+ * The hold this counts spans the whole client request, a long stream
347
+ * included (see beginRequest), so zero here means no client is mid-answer —
348
+ * which is the one thing a restart has to be sure of. Counted on demand
349
+ * rather than kept as a running total: this is asked a few times a second
350
+ * during a drain and never otherwise, while the map is bounded by MAX_SESSIONS.
351
+ */
352
+ inFlightCount() {
353
+ let n = 0;
354
+ for (const s of this.sessions.values()) n += s.inFlight;
355
+ return n;
356
+ }
357
+
344
358
  // Expired = idle past the known window AND nothing in flight (a long-running
345
359
  // request keeps the session alive no matter how old lastSeen is).
346
360
  _isExpired(s, now) {
@@ -1,7 +1,8 @@
1
- // Reflect the active account in the terminal title (e.g. "teamclaude 2/4 work"),
2
- // so a backgrounded or tabbed `teamclaude server` is glanceable without
3
- // switching to it. Pure/side-effect-free here so it can be unit-tested; the
4
- // caller owns the TTY gate and the interval.
1
+ // Reflect the active account and the running build in the terminal title (e.g.
2
+ // "teamclaude 2/4 work 1.1.20-rik.12+acc19b3"), so a backgrounded or tabbed
3
+ // `teamclaude server` is glanceable without switching to it. Pure/side-effect-
4
+ // free here so it can be unit-tested; the caller owns the TTY gate and the
5
+ // interval.
5
6
 
6
7
  const OSC_TITLE = '\x1b]0;'; // OSC 0 — set icon name + window title
7
8
  const BEL = '\x07';
@@ -20,11 +21,25 @@ function truncate(s, max) {
20
21
  return s.length <= max ? s : `${s.slice(0, max - 1)}…`;
21
22
  }
22
23
 
23
- // Short, glanceable title: "teamclaude <pos>/<total> <name>". `index` is 0-based.
24
- export function formatTerminalTitle({ index = 0, total = 0, name = null } = {}) {
24
+ // Short, glanceable title: "teamclaude <pos>/<total> <name> <version>". `index`
25
+ // is 0-based.
26
+ //
27
+ // The version is here because the title is the only surface still readable
28
+ // while the window is backgrounded — which is exactly the state a server is in
29
+ // when a drain-and-restart swaps the build under it. Without it the only
30
+ // evidence that anything happened is a screen that scrolled while nobody was
31
+ // looking.
32
+ export function formatTerminalTitle({ index = 0, total = 0, name = null, version = null } = {}) {
25
33
  const pos = total > 0 ? `${index + 1}/${total}` : '0/0';
26
34
  const who = name ? ` ${truncate(name, 24)}` : '';
27
- return `teamclaude ${pos}${who}`;
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)}` : '';
42
+ return `teamclaude ${pos}${who}${build}`;
28
43
  }
29
44
 
30
45
  // Wrap a title string in the OSC set-title sequence, stripping control chars so a
package/src/tui.js CHANGED
@@ -213,11 +213,15 @@ 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 centred version label needs on each side before it is drawn
217
- // at all. Below that it reads as a collision with the title or the port block,
218
- // so the whole label is dropped rather than squeezed.
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.
219
218
  const HEAD_GAP = 2;
220
219
 
220
+ // Narrowest label still worth drawing: an ellipsis and three columns of build.
221
+ // Under that the header goes back to naming no build at all, which at those
222
+ // widths is the honest answer.
223
+ const HEAD_LABEL_MIN = 4;
224
+
221
225
  // Which pair of bars a row draws: the subscription buckets (Ses/Wk, plus the
222
226
  // S7/F7 family bars) when any unified reading exists, else the metered Tok/Req
223
227
  // pair an API-key account reports. The account row budget is drawn per
@@ -281,6 +285,35 @@ export function fitLine(s, w) {
281
285
  return s;
282
286
  }
283
287
 
288
+ /** The build label at `max` columns, or '' when nothing legible fits.
289
+ *
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.
303
+ * @param {string} label
304
+ * @param {number} max */
305
+ export function fitHeadLabel(label, max) {
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;
313
+ if (max < HEAD_LABEL_MIN) return '';
314
+ return `…${bare.slice(bare.length - (max - 1))}`;
315
+ }
316
+
284
317
  function formatReset(resetTs) {
285
318
  if (!resetTs) return '';
286
319
  const ms = resetTs - Date.now();
@@ -411,6 +444,10 @@ function timestamp() {
411
444
 
412
445
  export class TUI {
413
446
  constructor({ accountManager, config, saveConfig, syncAccounts, onQuit, sx = null, probeQuota = null, activityLogPath = null,
447
+ // `u`: drain and come back on the new build. Null when nothing would
448
+ // relaunch the process, which is what stops a key offering an update from
449
+ // meaning "kill the proxy and every session on it".
450
+ onRestart = /** @type {(() => void)|null} */ (null),
414
451
  // Supervised sidecar state for the conduit lines. A getter, not a snapshot:
415
452
  // the supervisor respawns on its own schedule and the TUI redraws on a timer.
416
453
  getSidecars = null,
@@ -435,6 +472,7 @@ export class TUI {
435
472
  this.saveConfig = saveConfig;
436
473
  this.syncAccounts = syncAccounts;
437
474
  this.onQuit = onQuit;
475
+ this.onRestart = onRestart; // drain-and-restart, when something supervises us
438
476
  this.sx = sx; // sx.org proxy manager (may be null)
439
477
  this.sxBalance = null; // last fetched sx.org balance, for the settings screen
440
478
  this.probeQuota = probeQuota; // on-demand fleet-wide quota refresh (may be null)
@@ -466,6 +504,15 @@ export class TUI {
466
504
  this.frame = 0;
467
505
  this.running = false;
468
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;
469
516
  // Injectable so a test can drive the repaint tick by hand instead of
470
517
  // sleeping through real 500ms/5s intervals.
471
518
  this._setTimeout = setTimeout;
@@ -544,8 +591,11 @@ export class TUI {
544
591
  this._scheduleTick();
545
592
  }
546
593
 
547
- /** Fast while something is animating, slow when there is nothing to animate. */
548
- _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; }
549
599
 
550
600
  _scheduleTick() {
551
601
  if (!this.running) return;
@@ -600,6 +650,28 @@ export class TUI {
600
650
  process.stdin.pause();
601
651
  }
602
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
+
603
675
  // A title lookup costs a directory scan and a file read, so it stays off the
604
676
  // render path: this returns what is cached and schedules the rest.
605
677
  _sessionTag(sid) {
@@ -676,6 +748,17 @@ export class TUI {
676
748
  _key(k) {
677
749
  if (k === 'ctrl-c') { this.stop(); this.onQuit?.(); return; }
678
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
+
679
762
  switch (this.mode) {
680
763
  case 'normal': this._keyNormal(k); break;
681
764
  case 'select': this._keySelect(k); break;
@@ -704,6 +787,7 @@ export class TUI {
704
787
  this.mode = 'select'; this.selAction = 'toggle'; this.selIdx = this.am.currentIndex; this.selReturn = 'normal';
705
788
  }
706
789
  else if (k === 'p' && this.am.accounts.length > 0) { this._doProbe(); }
790
+ else if (k === 'u' && this.onRestart) { this._doRestart(); }
707
791
  else if (k === 'g') { this.mode = 'settings'; this.setIdx = 0; this._loadSxBalance(); }
708
792
  }
709
793
 
@@ -1128,6 +1212,20 @@ export class TUI {
1128
1212
  }
1129
1213
  }
1130
1214
 
1215
+ // `u`: pick up a new build now instead of waiting for the next lull. The
1216
+ // server owns what happens next — it drains, then exits asking to be
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.
1224
+ _doRestart() {
1225
+ if (!this.onRestart || this._restartDrain) return;
1226
+ this.onRestart();
1227
+ }
1228
+
1131
1229
  // ── Network settings ───────────────────────────────
1132
1230
 
1133
1231
  /**
@@ -1474,8 +1572,15 @@ export class TUI {
1474
1572
  // server's. A local AccountManager has neither property.
1475
1573
  const label = this.am.versionLabel ?? this.versionLabel;
1476
1574
  const upd = this.am.updateAvailable ?? this.updateAvailable;
1477
- const mid = label ? dim(label) + (upd ? ` ${green('▲')}` : '') : '';
1478
- const lw = vw(left), rw = vw(right), mw = vw(mid);
1575
+ const lw = vw(left), rw = vw(right);
1576
+ // Columns left between the two blocks, and what the label may take of them.
1577
+ // The marker is budgeted before the label is cut, so a shortened label and
1578
+ // its marker still fit the room they were measured against.
1579
+ const room = W - lw - rw;
1580
+ const markerW = upd ? 2 : 0;
1581
+ const text = label ? fitHeadLabel(label, room - 2 * HEAD_GAP - markerW) : '';
1582
+ const mid = text ? dim(text) + (upd ? ` ${green('▲')}` : '') : '';
1583
+ const mw = vw(mid);
1479
1584
  // Centred on the line, not in the gap between the two blocks, so the label
1480
1585
  // holds still as the session segment comes and goes.
1481
1586
  const start = Math.floor((W - mw) / 2);
@@ -1484,9 +1589,20 @@ export class TUI {
1484
1589
  // branch can never produce the over-wide line the other branch can, so the
1485
1590
  // two are not interchangeable.
1486
1591
  const midFits = mw > 0 && start - lw >= HEAD_GAP && (W - rw) - (start + mw) >= HEAD_GAP;
1592
+ // Line-centring is a position, not a fit. The two blocks are different
1593
+ // widths, so a label small enough for the gap can still be pushed inside
1594
+ // one of them by where the centre of the LINE falls — and that, not width,
1595
+ // is what used to drop the label every time the session segment grew,
1596
+ // leaving a header that silently stopped naming the build it exists to
1597
+ // name. Centre it in the GAP instead, which is exact by construction: the
1598
+ // two runs below sum to `room`. The label moves when sessions come and go,
1599
+ // which is the price; being able to read it is what that buys.
1600
+ const gapPad = mw > 0 && room - mw >= 2 * HEAD_GAP ? Math.floor((room - mw) / 2) : -1;
1487
1601
  lines.push(midFits
1488
1602
  ? left + ' '.repeat(start - lw) + mid + ' '.repeat(W - rw - start - mw) + right
1489
- : left + ' '.repeat(Math.max(1, W - lw - rw)) + right);
1603
+ : gapPad >= 0
1604
+ ? left + ' '.repeat(gapPad) + mid + ' '.repeat(room - mw - gapPad) + right
1605
+ : left + ' '.repeat(Math.max(1, W - lw - rw)) + right);
1490
1606
  lines.push(' ' + dim('─'.repeat(W - 2)));
1491
1607
 
1492
1608
  const footerH = 2;
@@ -1656,7 +1772,7 @@ export class TUI {
1656
1772
 
1657
1773
  // ── Footer
1658
1774
  lines.push(' ' + dim('─'.repeat(W - 2)));
1659
- lines.push(this._renderFooter());
1775
+ lines.push(this._renderFooter(W));
1660
1776
 
1661
1777
  // Write buffer
1662
1778
  let buf = `${ESC}H`;
@@ -2261,12 +2377,17 @@ export class TUI {
2261
2377
  }
2262
2378
  }
2263
2379
 
2264
- _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);
2265
2386
  switch (this.mode) {
2266
2387
  case 'normal':
2267
2388
  return this.remote
2268
2389
  ? ` ${bold('s')}witch ${bold('R')}eload ${bold('q')}uit`
2269
- : ` ${bold('s')}witch ${bold('d')}isable ${bold('p')}robe quota ${bold('R')}eload ${bold('g')} settings ${bold('q')}uit`;
2390
+ : ` ${bold('s')}witch ${bold('d')}isable ${bold('p')}robe quota ${bold('R')}eload${this.onRestart ? ` ${bold('u')}pdate` : ''} ${bold('g')} settings ${bold('q')}uit`;
2270
2391
  case 'settings':
2271
2392
  return ` ${dim('↑↓')} navigate ${dim('←→')} change ${bold('Enter')} edit ${bold('Esc')} back`;
2272
2393
  case 'routes':
@@ -2298,4 +2419,30 @@ export class TUI {
2298
2419
  return '';
2299
2420
  }
2300
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
+ }
2301
2448
  }
@@ -0,0 +1,232 @@
1
+ // What tells a running proxy that a newer build is waiting for it.
2
+ //
3
+ // The signal is not the same on every install, and picking only one of them
4
+ // would leave the primary user with dead code:
5
+ //
6
+ // global npm install — `npm install -g` replaces the files under the running
7
+ // process, so the version in the package's own package.json stops matching
8
+ // the version this process loaded. That difference, not the registry, is
9
+ // the signal: a published release nothing has installed yet is not
10
+ // something a restart can apply. autoUpdate (updater.js) does the
11
+ // installing; this only notices that it landed.
12
+ // git checkout — npm refuses to touch one by design, so there is no installed
13
+ // version to compare. The deploy clone's HEAD moving is the signal, read
14
+ // with one cheap `git rev-parse` on a timer.
15
+ //
16
+ // Both answer the same question — "would restarting run something else?" — so
17
+ // both feed the same drain-and-restart, and the install kind picks which is
18
+ // asked.
19
+ //
20
+ // Detecting a new build is not permission to apply it. A restart costs every
21
+ // live session its connection, so it waits for the fleet to go quiet; a fleet
22
+ // that never does gets restarted anyway, because a proxy that updates only
23
+ // when nobody is using it never updates at all.
24
+
25
+ import { execFile } from 'node:child_process';
26
+ import { promisify } from 'node:util';
27
+ import { compareVersions, currentVersion, installKind, isReleaseVersion, packageRoot } from './updater.js';
28
+
29
+ const pexec = promisify(execFile);
30
+
31
+ // How often the source is asked. A git checkout costs one `rev-parse` and a
32
+ // global install one small file read, so this is cheap enough to be frequent
33
+ // and slow enough to be invisible.
34
+ export const UPDATE_POLL_MS = 60_000;
35
+
36
+ // How long the fleet must stay quiet before a pending build is applied. The
37
+ // session tracker already calls a session inactive after two idle minutes
38
+ // (SESSION_ACTIVE_TTL_MS), so this is the quiet period ON TOP of that — proof
39
+ // that the lull held rather than a gap between two turns of one conversation.
40
+ export const FLEET_IDLE_MS = 60_000;
41
+
42
+ // ...and how long a pending build waits for that quiet before going anyway. A
43
+ // busy fleet must not be able to postpone an update forever; at this point the
44
+ // drain is what protects the sessions, not the wait.
45
+ export const RESTART_DEADLINE_MS = 30 * 60_000;
46
+
47
+ // Bounded like every other git call in this package: a checkout on a stalled
48
+ // network mount must cost the proxy a skipped check, not a stuck timer.
49
+ const GIT_TIMEOUT_MS = 2000;
50
+
51
+ /**
52
+ * @typedef {Object} VersionSource
53
+ * @property {string} kind the install shape this reads
54
+ * @property {string} describes what it names, for the log line
55
+ * @property {() => Promise<string|null>} read the build a restart would run, or null when that is the one already running
56
+ */
57
+
58
+ /**
59
+ * A git checkout: HEAD's sha, compared against the sha this process started on.
60
+ *
61
+ * The baseline is taken on the first successful read rather than in the
62
+ * constructor, because reading is asynchronous and a watcher that had no
63
+ * baseline yet would read the first pull as "nothing changed". A first read
64
+ * that fails (no git binary, a broken checkout) leaves the baseline unset, so
65
+ * the next successful one becomes it — one missed pull rather than a watcher
66
+ * that never works again.
67
+ *
68
+ * @param {Object} [opts]
69
+ * @param {string} [opts.root]
70
+ * @param {(file: string, args: string[], options: { cwd: string, encoding: 'utf8', timeout: number }) => Promise<{ stdout: string }>} [opts.exec]
71
+ * @returns {VersionSource}
72
+ */
73
+ export function gitHeadSource({ root = packageRoot(), exec = pexec } = {}) {
74
+ let baseline = null;
75
+ return {
76
+ kind: 'git',
77
+ describes: 'the checkout HEAD',
78
+ async read() {
79
+ let head = null;
80
+ try {
81
+ /** @type {{ cwd: string, encoding: 'utf8', timeout: number }} */
82
+ const opts = { cwd: root, encoding: 'utf8', timeout: GIT_TIMEOUT_MS };
83
+ head = String((await exec('git', ['rev-parse', 'HEAD'], opts)).stdout).trim();
84
+ } catch { return null; } // no git, no repository, or it took too long
85
+ // Shape-checked before it is compared: anything else is git reporting a
86
+ // problem on stdout, and treating that as a sha would restart the proxy
87
+ // on every poll for as long as the problem lasted.
88
+ if (!/^[0-9a-f]{7,64}$/.test(head)) return null;
89
+ baseline ||= head;
90
+ return head === baseline ? null : head;
91
+ },
92
+ };
93
+ }
94
+
95
+ /**
96
+ * A global npm install: the version on disk at the package root.
97
+ *
98
+ * `currentVersion` is re-read every time on purpose — that file is what
99
+ * `npm install -g` rewrites under a running process, and the whole signal is
100
+ * that it stopped matching what this process loaded. Only a newer release
101
+ * counts: a reinstall of the same version changes nothing worth a restart, and
102
+ * a downgrade or a version string that is not a release is not something to
103
+ * relaunch into.
104
+ *
105
+ * @param {Object} [opts]
106
+ * @param {string} [opts.root]
107
+ * @param {string|null} [opts.running] the version this process is executing
108
+ * @returns {VersionSource}
109
+ */
110
+ export function npmVersionSource({ root = packageRoot(), running = currentVersion(root) } = {}) {
111
+ return {
112
+ kind: 'global',
113
+ describes: 'the installed package',
114
+ async read() {
115
+ const installed = currentVersion(root);
116
+ if (!installed || !running || !isReleaseVersion(installed)) return null;
117
+ return compareVersions(installed, running) > 0 ? installed : null;
118
+ },
119
+ };
120
+ }
121
+
122
+ /**
123
+ * The source for how this copy was installed, or null when a restart could not
124
+ * pick anything up: a local dependency or an npx copy is nobody's deployment,
125
+ * and nothing rewrites it under us.
126
+ *
127
+ * @param {Object} [opts]
128
+ * @param {string} [opts.root]
129
+ * @param {Function} [opts.kind]
130
+ * @param {Function} [opts.exec]
131
+ * @param {string|null} [opts.running]
132
+ * @returns {Promise<VersionSource|null>}
133
+ */
134
+ export async function createVersionSource({ root = packageRoot(), kind = installKind, exec = pexec, running = currentVersion(root) } = {}) {
135
+ const how = await kind({ root });
136
+ if (how === 'git') return gitHeadSource({ root, exec: /** @type {any} */ (exec) });
137
+ if (how === 'global') return npmVersionSource({ root, running });
138
+ return null;
139
+ }
140
+
141
+ /**
142
+ * Polls a source and asks for a restart once a new build is waiting AND the
143
+ * fleet is quiet enough to lose its connections cheaply.
144
+ *
145
+ * Fires exactly once: the restart it asks for ends the process, and a second
146
+ * request during the drain would be noise at best.
147
+ */
148
+ export class UpdateWatcher {
149
+ /**
150
+ * @param {Object} opts
151
+ * @param {VersionSource|null} opts.source
152
+ * @param {() => boolean} opts.isIdle is the fleet serving nothing right now
153
+ * @param {(info: { build: string, forced: boolean, waitedMs: number }) => void} opts.onRestart
154
+ * @param {number} [opts.pollMs]
155
+ * @param {number} [opts.idleMs]
156
+ * @param {number} [opts.deadlineMs]
157
+ * @param {() => number} [opts.now]
158
+ * @param {(line: string) => void} [opts.log]
159
+ */
160
+ constructor({
161
+ source, isIdle, onRestart, pollMs = UPDATE_POLL_MS, idleMs = FLEET_IDLE_MS,
162
+ deadlineMs = RESTART_DEADLINE_MS, now = Date.now, log = console.log,
163
+ }) {
164
+ this.source = source;
165
+ this.isIdle = isIdle;
166
+ this.onRestart = onRestart;
167
+ this.pollMs = pollMs;
168
+ this.idleMs = idleMs;
169
+ this.deadlineMs = deadlineMs;
170
+ this.now = now;
171
+ this.log = log;
172
+ /** @type {ReturnType<typeof setInterval>|null} */
173
+ this.timer = null;
174
+ /** @type {string|null} the build waiting to be picked up */
175
+ this._build = null;
176
+ // Timestamps, and null rather than 0 for "not yet": a clock can legitimately
177
+ // read 0, and a falsy check would then re-stamp both of these on every poll
178
+ // — the deadline would never arrive and the quiet period never accumulate.
179
+ /** @type {number|null} when the build was first seen, for the deadline */
180
+ this._pendingSince = null;
181
+ /** @type {number|null} when the fleet last went quiet, for the wait */
182
+ this._idleSince = null;
183
+ this._fired = false;
184
+ this._busy = false; // a read is outstanding; never overlap them
185
+ }
186
+
187
+ /** No-op without a source, so the caller needs no second condition. */
188
+ start() {
189
+ if (!this.source || this.timer) return;
190
+ // Primes the baseline (see gitHeadSource) rather than waiting a full poll
191
+ // to learn what is already running.
192
+ this.check();
193
+ this.timer = setInterval(() => { this.check(); }, this.pollMs);
194
+ this.timer.unref?.();
195
+ }
196
+
197
+ stop() {
198
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
199
+ }
200
+
201
+ /** One poll. Public so a test can drive the decision without spending the
202
+ * interval, the idle wait and the deadline in real time. */
203
+ async check() {
204
+ if (this._fired || this._busy || !this.source) return null;
205
+ this._busy = true;
206
+ try {
207
+ const build = await this.source.read();
208
+ if (build) this._build = build;
209
+ // Nothing waiting: whatever idleness was accumulating is not evidence
210
+ // about a build, so it starts again with the next one.
211
+ if (!this._build) { this._idleSince = null; return null; }
212
+
213
+ const now = this.now();
214
+ if (this._pendingSince === null) {
215
+ this._pendingSince = now;
216
+ this.log(`[TeamClaude] A newer build is waiting (${this._build}) — restarting once the fleet is idle, or in ${Math.round(this.deadlineMs / 60_000)} min regardless.`);
217
+ }
218
+ if (this.isIdle()) this._idleSince ??= now; else this._idleSince = null;
219
+
220
+ const settled = this._idleSince !== null && now - this._idleSince >= this.idleMs;
221
+ const overdue = now - this._pendingSince >= this.deadlineMs;
222
+ if (!settled && !overdue) return null;
223
+
224
+ this._fired = true;
225
+ this.stop();
226
+ this.onRestart({ build: this._build, forced: !settled, waitedMs: now - this._pendingSince });
227
+ return this._build;
228
+ } finally {
229
+ this._busy = false;
230
+ }
231
+ }
232
+ }
package/src/updater.js CHANGED
@@ -29,7 +29,7 @@ const LABEL_MAX = 32;
29
29
  const pexec = promisify(execFile);
30
30
 
31
31
  /** Package root = one directory above this file's src/ directory. */
32
- function packageRoot() {
32
+ export function packageRoot() {
33
33
  return resolve(dirname(fileURLToPath(import.meta.url)), '..');
34
34
  }
35
35
 
@@ -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.