@rikcodes/teamclaude 1.1.20-rik.12 → 1.1.20-rik.14

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