pi-legion 2.0.4 → 3.0.0

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +71 -1
  2. package/CONTRIBUTING.md +27 -24
  3. package/README.md +103 -19
  4. package/docs/contracts/osc-busy-plugin.md +72 -0
  5. package/docs/contracts/spawn-default-inprocess-v3.md +322 -0
  6. package/docs/contracts/wait-not-stop.md +30 -0
  7. package/docs/design.md +2 -2
  8. package/docs/headless-child-contract.md +83 -0
  9. package/docs/inprocess-supervisor.md +140 -0
  10. package/docs/qa-hunting-round.md +150 -0
  11. package/docs/subagents-teams.md +55 -18
  12. package/docs/tui-qa-playbook.md +284 -0
  13. package/index.ts +315 -49
  14. package/package.json +15 -8
  15. package/skills/pi-legion/SKILL.md +5 -0
  16. package/src/agents/agents.ts +34 -6
  17. package/src/agents/context.ts +83 -1
  18. package/src/agents/overrides.ts +1 -0
  19. package/src/agents/settings.ts +122 -1
  20. package/src/api.ts +23 -0
  21. package/src/extension/child-guard.ts +42 -3
  22. package/src/extension/legion-commands.ts +9 -2
  23. package/src/extension/legion-doctor.ts +54 -0
  24. package/src/extension/legion-stop.ts +6 -2
  25. package/src/extension/playbook.ts +3 -3
  26. package/src/extension/runtime.ts +57 -8
  27. package/src/extension/scout-guard.ts +39 -1
  28. package/src/extension/session-errors.ts +191 -0
  29. package/src/runs/args.ts +8 -0
  30. package/src/runs/orchestrator.ts +125 -16
  31. package/src/shared/output-cap.ts +2 -2
  32. package/src/shared/session.ts +2 -1
  33. package/src/shared/types.ts +74 -0
  34. package/src/supervisor/child-context.ts +90 -0
  35. package/src/supervisor/child-memory.ts +27 -3
  36. package/src/supervisor/inprocess-depth.ts +71 -0
  37. package/src/supervisor/inprocess-supervisor.ts +1011 -0
  38. package/src/supervisor/routing-supervisor.ts +179 -0
  39. package/src/supervisor/rpc-supervisor.ts +14 -0
  40. package/src/supervisor/spawn-mode.ts +35 -0
  41. package/src/supervisor/types.ts +13 -0
  42. package/src/supervisor/ui-proxy.ts +78 -0
  43. package/src/tui/live-overlay.ts +147 -55
  44. package/src/tui/status.ts +71 -35
  45. package/src/tui/viz/model.ts +6 -0
  46. package/src/tui/viz/panels.ts +8 -9
  47. package/src/tui/viz/railway.ts +154 -21
  48. package/src/tui/viz/rate.ts +15 -14
@@ -8,6 +8,8 @@ import {
8
8
  visibleWidth,
9
9
  type Component,
10
10
  type OverlayHandle,
11
+ type OverlayOptions,
12
+ type SizeValue,
11
13
  type TUI,
12
14
  type TuiMouseEvent,
13
15
  } from "@earendil-works/pi-tui";
@@ -150,12 +152,60 @@ const DEFAULT_BACKFILL_TURNS = 3;
150
152
  const DEFAULT_TERMINAL_ROWS = 24;
151
153
  const TOOL_COLLAPSED_PREVIEW_LINES = 10;
152
154
  const BACKFILL_POLL_INTERVAL_MS = 250;
153
- /** Outer dim mask rows when the terminal is tall enough for a clear floating modal. */
154
- const MODAL_MARGIN_Y_COMFORTABLE = 1;
155
- /** Outer dim mask columns (left/right) outside the modal border. */
156
- const MODAL_MARGIN_X_COMFORTABLE = 2;
155
+ /**
156
+ * #157: Cap the floating context modal by terminal ratio so large transcripts do
157
+ * not stretch the shell near-fullscreen. Content scrolls inside the body.
158
+ * Tuned on 80×24 + 160×50 dumps (≈75% × ≈65% feels clearly smaller than full
159
+ * screen without feeling cramped). Floor sizes kick in on mid terminals; on
160
+ * very small terminals we still fill the screen so body remains usable.
161
+ *
162
+ * #193: the surrounding area is NOT painted by the viewer. pi-tui's overlay
163
+ * compositor discards every base cell inside the overlay rectangle (there is no
164
+ * transparency option in `OverlayOptions`), so any mask this component paints is
165
+ * opaque by construction. The host underlay (chat / /legion) stays visible only
166
+ * because the overlay is anchored to the frame rect alone — see
167
+ * `resolveModalOverlayOptions()`.
168
+ */
169
+ const MODAL_MAX_WIDTH_RATIO = 0.75;
170
+ const MODAL_MAX_HEIGHT_RATIO = 0.65;
171
+ /** Prefer at least this frame height once the terminal itself is tall enough. */
172
+ const MODAL_MIN_FRAME_ROWS = 8;
157
173
  const MODAL_CLOSE_HINT = "Esc";
158
174
 
175
+ /**
176
+ * Host overlay geometry for the floating frame: cover the frame rect only.
177
+ *
178
+ * pi-tui composites an overlay by replacing every base cell inside its
179
+ * rectangle, and `OverlayOptions` has no transparency mechanism. A full-screen
180
+ * overlay therefore hides the host chat / /legion underlay completely, and
181
+ * because the viewer's mask cells are spaces with no background of their own,
182
+ * the wiped area renders as the terminal's default background — the "black
183
+ * modal" bug (#193). Anchoring the overlay to the frame rect instead leaves
184
+ * every cell outside the frame to the host, which paints it.
185
+ *
186
+ * The frame is centered horizontally and vertically by the host (`anchor:
187
+ * "center"`); this module only declares what fraction of the terminal the
188
+ * frame should occupy.
189
+ */
190
+ export function resolveModalOverlayOptions(): {
191
+ width: SizeValue;
192
+ anchor: "center";
193
+ } & OverlayOptions {
194
+ // Percentages, not absolute cells: pi resolves `overlayOptions()` once when the
195
+ // overlay is shown, so absolute numbers freeze at the open-time terminal size and
196
+ // the frame gets truncated on resize (bottom border and footer disappear).
197
+ // pi-tui re-derives percentages from the current terminal every frame.
198
+ //
199
+ // No `maxHeight`: the component renders exactly the frame rows for whatever
200
+ // width it is given, so its own height is already the right rect height. A
201
+ // percentage cap would disagree with it on small terminals (the layout has a
202
+ // minimum frame height), cutting the bottom border off.
203
+ return {
204
+ width: `${MODAL_MAX_WIDTH_RATIO * 100}%`,
205
+ anchor: "center",
206
+ };
207
+ }
208
+
159
209
  /**
160
210
  * Build an on-demand context-viewer controller. Calling `open` subscribes to
161
211
  * the requested node; closing, Esc, disposal, or replacing it unsubscribes once.
@@ -472,7 +522,7 @@ function createSession(
472
522
  consumedFinalizedText ||
473
523
  message.role !== "assistant" ||
474
524
  !Array.isArray(message.content) ||
475
- !messageText(message.content).includes(finalizedText)
525
+ !finalizedText.includes(messageText(message.content))
476
526
  ) {
477
527
  return message;
478
528
  }
@@ -548,7 +598,6 @@ function createSession(
548
598
 
549
599
  const updateStreamingText = (delta: string): void => {
550
600
  if (!delta) return;
551
- if (finalizedAssistantText) finalizedAssistantText = "";
552
601
  streamingText = clampStreamingText(`${streamingText}${delta}`);
553
602
  appendActivityText(delta);
554
603
  chat.updateStreamingText(streamingText, sourceNode()?.model ?? initialNode?.model ?? undefined);
@@ -676,9 +725,18 @@ function createSession(
676
725
 
677
726
  let stopPending = false;
678
727
 
728
+ /** Slim title: name + 主/子 only. Run meta lives in the footer band (#157). */
679
729
  const titlePlain = (): string => {
680
730
  const node = sourceNode();
681
731
  if (!node) return `● ${nodeId} · node unavailable`;
732
+ const kind = contextKindLabel(node, nodeId);
733
+ return `● ${safeText(node.name)} · ${kind}`;
734
+ };
735
+
736
+ /** Agent/run meta shown below the message body (footer band), not in the title. */
737
+ const metaPlain = (): string => {
738
+ const node = sourceNode();
739
+ if (!node) return "node unavailable";
682
740
  const status = node.status;
683
741
  const completed = status === "settled" || status === "failed" || status === "retired";
684
742
  const elapsedEnd = completed ? node.updatedAt : now();
@@ -688,10 +746,8 @@ function createSession(
688
746
  ? `${Math.max(0, Math.round((now() - turnStartedAt) / 1_000))}s`
689
747
  : undefined;
690
748
  const turnLabel = `turn ${turnNumber}${turnElapsed !== undefined ? ` (${turnElapsed})` : ""}`;
691
- const kind = contextKindLabel(node, nodeId);
692
749
  return [
693
- `● ${safeText(node.name)} (${safeText(node.role)})`,
694
- kind,
750
+ safeText(node.role),
695
751
  ...(node.model ? [safeText(node.model)] : []),
696
752
  turnLabel,
697
753
  elapsed,
@@ -726,6 +782,11 @@ function createSession(
726
782
  return coloredTitle + " ".repeat(pad) + style(options.theme, "dim", closeHint);
727
783
  };
728
784
 
785
+ const metaBarInner = (innerWidth: number): string => {
786
+ const safeWidth = Math.max(1, Math.floor(innerWidth));
787
+ return style(options.theme, "dim", fitLine(metaPlain(), safeWidth));
788
+ };
789
+
729
790
  const close = (): void => {
730
791
  if (!open) return;
731
792
  open = false;
@@ -789,12 +850,17 @@ function createSession(
789
850
  const end = start + layout.bodyRows;
790
851
  const visible = lastBodyLines.slice(start, end);
791
852
  while (visible.length < layout.bodyRows) visible.push("");
853
+ const footerHint = layout.showMeta
854
+ ? "↑↓ scroll · Esc close"
855
+ // Tiny terminals collapse the dedicated meta row into the bottom chrome.
856
+ : `${metaPlain()} · ↑↓ · Esc`;
792
857
  return paintModalFrame({
793
858
  layout,
794
859
  theme: options.theme,
795
860
  titleInner: titleBarInner(layout.innerWidth),
796
861
  bodyLines: visible,
797
- footerHint: "↑↓ scroll · Esc close · click mask to dismiss",
862
+ metaInner: layout.showMeta ? metaBarInner(layout.innerWidth) : undefined,
863
+ footerHint,
798
864
  });
799
865
  },
800
866
  handleInput(data) {
@@ -844,16 +910,16 @@ function createSession(
844
910
  const layout = layoutFor(terminalWidth);
845
911
  contentWidth = layout.innerWidth;
846
912
  if (event.type === "wheel" && event.wheelDelta) {
847
- // Wheel anywhere over the overlay scrolls the modal body (mask included).
913
+ // Wheel over the frame scrolls the modal body.
848
914
  scrollBy(event.wheelDelta);
849
915
  return { handled: true };
850
916
  }
851
917
  lastBodyLines = renderBody(contentWidth);
852
918
  clampScroll(lastBodyLines);
853
919
  const hit = hitTestModal(layout, event.x, event.y);
854
- if (hit.zone === "mask" || hit.zone === "close") {
920
+ if (hit.zone === "close") {
855
921
  if (event.type === "click" && event.button === "left") {
856
- // Esc / mask / close: dismiss only — never retire or stop (esc-no-retire-v3).
922
+ // Esc / close hint: dismiss only — never retire or stop (esc-no-retire-v3).
857
923
  close();
858
924
  return { handled: true };
859
925
  }
@@ -948,70 +1014,95 @@ export interface ModalLayout {
948
1014
  marginX: number;
949
1015
  titleRow: number;
950
1016
  bodyStartRow: number;
1017
+ /** Fixed meta row below the scrollable body; null when collapsed into the footer. */
1018
+ metaRow: number | null;
951
1019
  footerRow: number;
952
1020
  bodyRows: number;
953
1021
  innerWidth: number;
1022
+ /** Inclusive frame height in rows (title + body + optional meta + footer). */
1023
+ frameRows: number;
954
1024
  /** Column where modal border (│/┏) starts. */
955
1025
  frameCol: number;
956
- /** Absolute screen x range of the Esc close hint in the title bar. */
1026
+ /** Whether a dedicated meta row is painted below the body. */
1027
+ showMeta: boolean;
1028
+ /**
1029
+ * x range of the Esc close hint in the title bar, RELATIVE to the frame rect
1030
+ * (pi-tui hands the component `screenX - layout.col`, not the screen column).
1031
+ */
957
1032
  closeColStart: number;
958
1033
  closeColEnd: number;
959
1034
  }
960
1035
 
1036
+ /**
1037
+ * Layout of the floating frame. All row/col values are relative to the frame
1038
+ * rect the component is mounted at (#193): the host sizes that rect
1039
+ * (`resolveModalOverlayOptions()`) and centers it, and the component paints
1040
+ * nothing outside it, so the host chat / /legion underlay stays visible.
1041
+ *
1042
+ * `rows` is the terminal height (used for the height ratio); `cols` is the
1043
+ * frame rect width handed to `render()`.
1044
+ */
961
1045
  export function resolveModalLayout(rows: number, cols: number): ModalLayout {
962
1046
  const safeRows = Math.max(1, Math.floor(rows));
963
1047
  const safeCols = Math.max(1, Math.floor(cols));
964
- // Prefer a 1-row dim mask; collapse it on short terminals so body still has room.
965
- const marginY = safeRows >= 6 ? MODAL_MARGIN_Y_COMFORTABLE : 0;
966
- const marginX = safeCols >= 24 ? MODAL_MARGIN_X_COMFORTABLE : safeCols >= 12 ? 1 : 0;
967
- const chromeY = 2 * marginY + 2; // masks + title + footer
968
- const bodyRows = Math.max(1, safeRows - chromeY);
969
- const frameCol = marginX;
970
- const innerWidth = Math.max(1, safeCols - 2 * marginX - 2);
971
- const titleRow = marginY;
972
- const bodyStartRow = marginY + 1;
973
- const footerRow = marginY + 1 + bodyRows;
1048
+ const ratioRows = Math.max(1, Math.floor(safeRows * MODAL_MAX_HEIGHT_RATIO));
1049
+ // Small terminals: fill the screen so the body stays usable. Otherwise cap by
1050
+ // ratio (and a modest floor) so the shell stays clearly smaller than the host.
1051
+ const frameRows = safeRows < MODAL_MIN_FRAME_ROWS
1052
+ ? safeRows
1053
+ : Math.min(safeRows, Math.max(MODAL_MIN_FRAME_ROWS, ratioRows));
1054
+ // Need title + ≥1 body + meta + footer (=4) for a dedicated meta band.
1055
+ const showMeta = frameRows >= 4;
1056
+ const chromeInside = 2 + (showMeta ? 1 : 0); // title + optional meta + footer
1057
+ const bodyRows = Math.max(1, frameRows - chromeInside);
1058
+ const innerWidth = Math.max(1, safeCols - 2);
1059
+ const titleRow = 0;
1060
+ const bodyStartRow = 1;
1061
+ const metaRow = showMeta ? bodyStartRow + bodyRows : null;
1062
+ const footerRow = showMeta ? metaRow! + 1 : bodyStartRow + bodyRows;
974
1063
  const closeWidth = visibleWidth(` ${MODAL_CLOSE_HINT} `);
975
- const closeColEnd = frameCol + 1 + innerWidth;
976
- const closeColStart = Math.max(frameCol + 1, closeColEnd - closeWidth);
1064
+ const closeColEnd = 1 + innerWidth;
1065
+ const closeColStart = Math.max(1, closeColEnd - closeWidth);
977
1066
  return {
978
1067
  rows: safeRows,
979
1068
  cols: safeCols,
980
- marginY,
981
- marginX,
1069
+ marginY: 0,
1070
+ marginX: 0,
982
1071
  titleRow,
983
1072
  bodyStartRow,
1073
+ metaRow,
984
1074
  footerRow,
985
1075
  bodyRows,
986
1076
  innerWidth,
987
- frameCol,
1077
+ frameRows,
1078
+ frameCol: 0,
1079
+ showMeta,
988
1080
  closeColStart,
989
1081
  closeColEnd,
990
1082
  };
991
1083
  }
992
1084
 
993
- type ModalHitZone = "mask" | "close" | "title" | "body" | "footer";
1085
+ type ModalHitZone = "close" | "title" | "body" | "meta" | "footer";
994
1086
 
995
1087
  function hitTestModal(
996
1088
  layout: ModalLayout,
997
1089
  x: number,
998
1090
  y: number,
999
1091
  ): { zone: ModalHitZone; bodyX?: number; bodyY?: number } {
1000
- if (y < layout.marginY || y >= layout.rows - layout.marginY) return { zone: "mask" };
1001
- if (x < layout.frameCol || x >= layout.frameCol + layout.innerWidth + 2) return { zone: "mask" };
1092
+ // x/y are relative to the frame rect (#193): pi-tui dispatches
1093
+ // `screenX - layout.col`, and never dispatches outside the rect at all, so
1094
+ // there is no mask zone to hit-test.
1002
1095
  if (y === layout.titleRow) {
1003
1096
  if (x >= layout.closeColStart && x < layout.closeColEnd) return { zone: "close" };
1004
1097
  return { zone: "title" };
1005
1098
  }
1099
+ if (layout.metaRow !== null && y === layout.metaRow) return { zone: "meta" };
1006
1100
  if (y === layout.footerRow) return { zone: "footer" };
1007
- if (y >= layout.bodyStartRow && y < layout.bodyStartRow + layout.bodyRows) {
1008
- return {
1009
- zone: "body",
1010
- bodyX: Math.max(0, x - (layout.frameCol + 1)),
1011
- bodyY: y - layout.bodyStartRow,
1012
- };
1013
- }
1014
- return { zone: "mask" };
1101
+ return {
1102
+ zone: "body",
1103
+ bodyX: Math.max(0, x - 1),
1104
+ bodyY: y - layout.bodyStartRow,
1105
+ };
1015
1106
  }
1016
1107
 
1017
1108
  function padToInner(line: string, innerWidth: number): string {
@@ -1025,31 +1116,32 @@ function paintModalFrame(input: {
1025
1116
  theme: LiveOverlayTheme | undefined;
1026
1117
  titleInner: string;
1027
1118
  bodyLines: readonly string[];
1119
+ /** Fixed meta band below the body; omitted when layout.showMeta is false. */
1120
+ metaInner?: string;
1028
1121
  footerHint: string;
1029
1122
  }): string[] {
1030
- const { layout, theme, titleInner, bodyLines, footerHint } = input;
1123
+ const { layout, theme, titleInner, bodyLines, metaInner, footerHint } = input;
1031
1124
  const dim = (s: string) => style(theme, "dim", s);
1032
1125
  const accent = (s: string) => style(theme, "accent", s);
1033
1126
  const lines: string[] = [];
1034
- const maskLine = dim(" ".repeat(layout.cols));
1035
- const leftMask = dim(" ".repeat(layout.marginX));
1036
- const rightMaskWidth = Math.max(0, layout.cols - layout.marginX - layout.innerWidth - 2);
1037
- const rightMask = dim(" ".repeat(rightMaskWidth));
1038
- const top = leftMask + accent("┏") + padToInner(titleInner, layout.innerWidth) + accent("┓") + rightMask;
1039
- const bottom = leftMask +
1040
- accent("┗") +
1127
+ // #193: the component paints the frame rect only — no side/top/bottom mask.
1128
+ // Every cell outside it belongs to the host, which already painted the chat or
1129
+ // the /legion view; painting spaces here would wipe it (the composite has no
1130
+ // transparency) and render as the terminal's default background.
1131
+ const top = accent("┏") + padToInner(titleInner, layout.innerWidth) + accent("┓");
1132
+ const bottom = accent("┗") +
1041
1133
  padToInner(dim(` ${footerHint} `), layout.innerWidth) +
1042
- accent("┛") +
1043
- rightMask;
1134
+ accent("┛");
1044
1135
 
1045
- for (let i = 0; i < layout.marginY; i++) lines.push(maskLine);
1046
1136
  lines.push(top);
1047
1137
  for (let i = 0; i < layout.bodyRows; i++) {
1048
1138
  const body = padToInner(bodyLines[i] ?? "", layout.innerWidth);
1049
- lines.push(leftMask + accent("┃") + body + accent("┃") + rightMask);
1139
+ lines.push(accent("┃") + body + accent("┃"));
1140
+ }
1141
+ if (layout.showMeta) {
1142
+ const meta = padToInner(metaInner ?? "", layout.innerWidth);
1143
+ lines.push(accent("┃") + meta + accent("┃"));
1050
1144
  }
1051
1145
  lines.push(bottom);
1052
- for (let i = 0; i < layout.marginY; i++) lines.push(maskLine);
1053
- while (lines.length < layout.rows) lines.push(maskLine);
1054
- return lines.slice(0, layout.rows);
1146
+ return lines;
1055
1147
  }
package/src/tui/status.ts CHANGED
@@ -22,6 +22,7 @@ import { railwayLines, type RailScroll } from "./viz/railway.ts";
22
22
  import { IDLE_STOP_HINT, IDLE_STOP_HINT_SHORT } from "../extension/legion-stop.ts";
23
23
 
24
24
  export const STATUS_WIDGET_KEY = "pi-legion-status";
25
+ /** @deprecated Footer segment removed (#192); kept for API soft-land. Prefer the aboveEditor widget. */
25
26
  export const STATUS_FOOTER_KEY = "pi-legion";
26
27
  export const STATUS_WIDGET_PLACEMENT = "aboveEditor" as const;
27
28
 
@@ -47,6 +48,11 @@ export interface StatusEntry {
47
48
  currentTurnStartedAt?: number;
48
49
  /** Unix ms the current turn settled. Absent while the turn is still in flight. */
49
50
  currentTurnEndedAt?: number;
51
+ /**
52
+ * Unix ms of the agent's last visible activity (edit / reply / status or
53
+ * output refresh). Not the run wall-clock start.
54
+ */
55
+ lastActivityAt?: number;
50
56
  /** Cumulative tokens (input+cache+output); from session jsonl (fresh) or ledger usage (≤30s stale). */
51
57
  tokensTotal?: number;
52
58
  /** Cumulative output tokens only; numerator for tok/s / t/s. */
@@ -100,6 +106,21 @@ export function formatElapsed(ms: number): string {
100
106
  return formatElapsedParts(ms, false);
101
107
  }
102
108
 
109
+ /**
110
+ * Relative age for railway "last activity" (`3s ago` / `2m ago` / `1h ago`).
111
+ * Missing timestamp → `—` (placeholder; see #159).
112
+ */
113
+ export function formatActivityAgo(lastActivityAt: number | undefined, now: number): string {
114
+ if (lastActivityAt === undefined || !Number.isFinite(lastActivityAt)) return "—";
115
+ const secs = Math.max(0, Math.floor((now - lastActivityAt) / 1000));
116
+ if (secs < 60) return `${secs}s ago`;
117
+ const mins = Math.floor(secs / 60);
118
+ if (mins < 60) return `${mins}m ago`;
119
+ const hours = Math.floor(mins / 60);
120
+ if (hours < 48) return `${hours}h ago`;
121
+ return `${Math.floor(hours / 24)}d ago`;
122
+ }
123
+
103
124
  /** Compact token count: 950 -> "950", 1200 -> "1.2K", 2500000 -> "2.5M". */
104
125
  function formatTokensCompact(value: number): string {
105
126
  if (!Number.isFinite(value) || value <= 0) return "0";
@@ -111,28 +132,25 @@ function formatTokensCompact(value: number): string {
111
132
  }
112
133
 
113
134
  /**
114
- * Elapsed of the current turn, shown next to the lifetime elapsed.
135
+ * Elapsed since the most recent task (turn) started, shown next to the
136
+ * lifetime elapsed.
115
137
  *
116
- * An in-flight turn ticks with `now`. Once `currentTurnEndedAt` is set the
117
- * value stays frozen until the next turn replaces the start. A terminal entry
118
- * with no explicit end freezes at `finishedAt` so the clock does not run on
119
- * after the agent exits. Undefined when the start is unknown, or when the
120
- * entry is neither active nor terminal and the turn was not frozen.
138
+ * Ticks with `now` for as long as the agent itself is live — including the
139
+ * quiet gap after a turn settles and before the next message arrives, which
140
+ * is part of "time since the last task started". It freezes only when the
141
+ * agent ends: a terminal entry clamps at `finishedAt` so the clock does not
142
+ * run on after the agent exits. Undefined when the turn start is unknown, or
143
+ * when the entry is neither active nor terminal (an idle seat has no
144
+ * meaningful running clock).
121
145
  */
122
146
  function currentTurnElapsedMs(
123
- entry: Pick<StatusEntry, "state" | "herdrStatus" | "finishedAt" | "currentTurnStartedAt" | "currentTurnEndedAt">,
147
+ entry: Pick<StatusEntry, "state" | "herdrStatus" | "finishedAt" | "currentTurnStartedAt">,
124
148
  now: number,
125
149
  ): number | undefined {
126
150
  if (entry.currentTurnStartedAt === undefined) return undefined;
127
151
  const start = entry.currentTurnStartedAt;
128
- if (entry.currentTurnEndedAt !== undefined) {
129
- return Math.max(0, entry.currentTurnEndedAt - start);
130
- }
131
152
  const status = entry.herdrStatus ?? entry.state;
132
- if (TERMINAL_STATES.has(status)) {
133
- if (entry.finishedAt === undefined) return undefined;
134
- return Math.max(0, entry.finishedAt - start);
135
- }
153
+ if (TERMINAL_STATES.has(status)) return Math.max(0, (entry.finishedAt ?? now) - start);
136
154
  if (!ACTIVE_STATES.has(status)) return undefined;
137
155
  return Math.max(0, now - start);
138
156
  }
@@ -147,20 +165,38 @@ function lifetimeElapsedMs(
147
165
  }
148
166
 
149
167
  /**
150
- * Headline clock: `12m12s / 1h03m` (current turn / lifetime). Lifetime only
151
- * when this turn has no timing yet.
168
+ * How close `currentTurnStartedAt` may sit to `startedAt` and still count as
169
+ * the same event (round 1). Only a fallback for entries that report no turn
170
+ * count: the turn start comes from the session's first user message while
171
+ * `startedAt` is the process spawn, so they differ by the spawn handshake
172
+ * (~1s) even on round 1.
173
+ */
174
+ export const SAME_TURN_START_MS = 5_000;
175
+
176
+ /**
177
+ * Headline clock: `12m12s / 1h03m` (since last task start / lifetime).
178
+ *
179
+ * Lifetime only when the leading clock adds nothing: either this turn has no
180
+ * timing yet, or it is still round 1 — that turn started with the agent, so
181
+ * the two clocks are one fact and `16s / 17s` is just the spawn gap.
152
182
  */
153
183
  export function formatAgentElapsed(
154
- entry: Pick<StatusEntry, "state" | "herdrStatus" | "startedAt" | "finishedAt" | "currentTurnStartedAt" | "currentTurnEndedAt">,
184
+ entry: Pick<StatusEntry, "state" | "herdrStatus" | "startedAt" | "finishedAt" | "currentTurnStartedAt" | "turns">,
155
185
  now: number,
156
186
  ): string {
157
187
  const total = formatElapsed(lifetimeElapsedMs(entry, now));
158
188
  const turnElapsed = currentTurnElapsedMs(entry, now);
159
189
  if (turnElapsed === undefined) return total;
190
+ if (entry.turns !== undefined) {
191
+ if (entry.turns <= 1) return total;
192
+ } else if (entry.currentTurnStartedAt !== undefined
193
+ && Math.abs(entry.currentTurnStartedAt - entry.startedAt) <= SAME_TURN_START_MS) {
194
+ return total;
195
+ }
160
196
  return `${formatElapsedParts(turnElapsed, true)} / ${total}`;
161
197
  }
162
198
 
163
- function activeEntries(entries: StatusEntry[]): StatusEntry[] {
199
+ export function activeEntries(entries: StatusEntry[]): StatusEntry[] {
164
200
  return entries.filter((entry) =>
165
201
  entry.parentId !== null && entry.depth !== 0 && ACTIVE_STATES.has(entry.herdrStatus ?? entry.state),
166
202
  );
@@ -169,7 +205,7 @@ function activeEntries(entries: StatusEntry[]): StatusEntry[] {
169
205
  /**
170
206
  * Panel-visibility gate for the status board: with any Legion-identity
171
207
  * entries present, the board mirrors the live-tree filter (completed
172
- * branches age out; footer/busy counts stay consistent with the widget).
208
+ * branches age out; busy counts stay consistent with the widget).
173
209
  * Legacy flat rosters keep the historical always-shown behavior.
174
210
  */
175
211
  function hasTreeIdentity(entries: StatusEntry[]): boolean {
@@ -182,11 +218,15 @@ export function visiblePanelEntries(entries: StatusEntry[]): StatusEntry[] {
182
218
  );
183
219
  if (!hasTreeIdentity) return entries;
184
220
  const live = filterLiveTree(entries);
185
- // Footer/busy labels key off activeEntries(); keep the ledger root row out
221
+ // Busy labels key off activeEntries(); keep the ledger root row out
186
222
  // of the visible set entirely (it is not an agent).
187
223
  return live.filter((entry) => entry.nodeId !== "root");
188
224
  }
189
225
 
226
+ /**
227
+ * @deprecated Footer `N agents running` removed (#192); the aboveEditor widget
228
+ * already shows the same count. Kept for API soft-land — do not call from new code.
229
+ */
190
230
  export function formatFooterStatus(entries: StatusEntry[]): string | undefined {
191
231
  const active = activeEntries(entries);
192
232
  if (active.length === 0) return undefined;
@@ -203,7 +243,7 @@ export function formatBusyLabel(entries: StatusEntry[]): string | undefined {
203
243
  names.length > 0
204
244
  ? ` (${names.slice(0, 3).join(", ")}${names.length > 3 ? ", …" : ""})`
205
245
  : "";
206
- return `⏳ ${active.length} subagent${active.length === 1 ? "" : "s"}${who}`;
246
+ return `${active.length} subagent${active.length === 1 ? "" : "s"}${who}`;
207
247
  }
208
248
 
209
249
  function roleSuffix(entry: StatusEntry): string {
@@ -247,7 +287,7 @@ function headlineBits(entry: StatusEntry, now: number): string[] {
247
287
  return bits;
248
288
  }
249
289
 
250
- function detailBits(entry: StatusEntry, now: number): string[] {
290
+ function detailBits(entry: StatusEntry): string[] {
251
291
  const status = entryStatus(entry);
252
292
  const bits = [entry.phase ? `${entry.phase}/${status}` : status];
253
293
  if (entry.turns && entry.turns > 0) bits.push(`turn ${entry.turns}`);
@@ -257,9 +297,9 @@ function detailBits(entry: StatusEntry, now: number): string[] {
257
297
  if (entry.worktreeBranch) {
258
298
  bits.push(`wt ${formatWorktreeBranch(entry.worktreeBranch)}`);
259
299
  }
260
- // Output tok/s over generation time (same definition as railway /legion).
300
+ // Lifetime avg output tok/s over generationMs (not the header 60s window / live-only nowRate).
261
301
  if (entry.tokensOut && entry.tokensOut > 0 && entry.generationMs && entry.generationMs >= 1_000) {
262
- bits.push(`${formatTokensCompact(Math.round(entry.tokensOut / (entry.generationMs / 1_000)))} t/s`);
302
+ bits.push(`${formatTokensCompact(Math.round(entry.tokensOut / (entry.generationMs / 1_000)))} avg t/s`);
263
303
  }
264
304
  return bits;
265
305
  }
@@ -271,7 +311,7 @@ function formatEntryLines(
271
311
  ): string[] {
272
312
  return [
273
313
  `${prefix.head}● ${headlineBits(entry, now).join(" · ")}`,
274
- `${prefix.detail} ⎿ ${detailBits(entry, now).join(" · ")}`,
314
+ `${prefix.detail} ⎿ ${detailBits(entry).join(" · ")}`,
275
315
  ];
276
316
  }
277
317
 
@@ -476,7 +516,7 @@ function renderTreeWidgetLines(
476
516
  lines.push(widgetRow(`${branch}● ${headline.join(" · ")}`, entry.nodeId));
477
517
  const detailPrefix = `${ancestors}${isLast ? " " : "│ "}`;
478
518
  lines.push(widgetRow(
479
- `${detailPrefix} ⎿ ${detailBits(effectiveEntry, now).map(cleanDisplayText).join(" · ")}`,
519
+ `${detailPrefix} ⎿ ${detailBits(effectiveEntry).map(cleanDisplayText).join(" · ")}`,
480
520
  entry.nodeId,
481
521
  ));
482
522
  const childPrefix = `${ancestors}${isLast ? " " : "│ "}`;
@@ -532,7 +572,7 @@ type Theme = {
532
572
  fg(color: string, text: string): string;
533
573
  };
534
574
 
535
- type WidgetTui = { requestRender(): void };
575
+ export type WidgetTui = { requestRender(): void };
536
576
 
537
577
  export type WidgetFactory = (
538
578
  tui: WidgetTui,
@@ -598,18 +638,18 @@ export interface StatusVizSnapshot {
598
638
  nodes: VizNode[];
599
639
  sampler: RateSampler;
600
640
  now: number;
601
- /** Header "now" tok/s (live agents only; 0 when none running). */
641
+ /** Header tok/s (60s window, live nodes only; 0 when none running). */
602
642
  rate: number;
603
643
  }
604
644
 
605
645
  export interface StatusBoard {
606
646
  bind(ctx: StatusUi): void;
607
- /** Live data for the /legion view (same sampler and rate definition as the strip). */
647
+ /** Live data for the /legion view (same sampler; rate is header nowRate). */
608
648
  vizSnapshot?(now?: number): StatusVizSnapshot;
609
649
  paint(entries: StatusEntry[], now: number): void;
610
650
  clear(): void;
611
651
  /** The TUI the widget is mounted in, when registered (for focus/overlay checks). */
612
- getTui?(): unknown;
652
+ getTui?(): WidgetTui | undefined;
613
653
  }
614
654
 
615
655
  /**
@@ -704,17 +744,14 @@ export function createStatusBoard(options: {
704
744
  if (registered && ui) {
705
745
  safe(() => ui!.setWidget(STATUS_WIDGET_KEY, undefined));
706
746
  }
707
- if (ui) safe(() => ui!.setStatus(STATUS_FOOTER_KEY, undefined));
708
747
  registered = false;
709
748
  tui = undefined;
710
749
  };
711
750
 
712
751
  const paint = (): void => {
713
752
  if (!ctx || !liveHasUi(ctx)) return;
714
- // Footer and widget share the live-tree visibility filter so the counts
715
- // stay consistent ("N agents running" refers to visible live agents).
753
+ // Widget uses the live-tree visibility filter (completed branches age out).
716
754
  const visible = visiblePanelEntries(entries);
717
- safe(() => ctx!.ui.setStatus(STATUS_FOOTER_KEY, formatFooterStatus(visible)));
718
755
  if (visible.length === 0) {
719
756
  dropWidget();
720
757
  return;
@@ -832,7 +869,6 @@ export function applyStatus(
832
869
  themed.length ? themed : undefined,
833
870
  { placement: STATUS_WIDGET_PLACEMENT },
834
871
  );
835
- ctx.ui.setStatus(STATUS_FOOTER_KEY, formatFooterStatus(entries));
836
872
  } catch (error) {
837
873
  if (!isStaleUiError(error)) throw error;
838
874
  }
@@ -33,6 +33,10 @@ export interface VizNode {
33
33
  currentTurnStartedAt?: number;
34
34
  /** Unix ms the current turn settled. Absent while it is still in flight. */
35
35
  currentTurnEndedAt?: number;
36
+ /** Completed turn count (1-based latest); used to gate round clocks on the strip. */
37
+ turns?: number;
38
+ /** Unix ms of last visible activity for this seat (not run wall-clock start). */
39
+ lastActivityAt?: number;
36
40
  }
37
41
 
38
42
  /** Same set the footer/busy label treat as active, so live counts agree everywhere. */
@@ -71,6 +75,8 @@ export function nodesFromEntries(entries: readonly StatusEntry[]): VizNode[] {
71
75
  generationMs: Math.max(0, entry.generationMs ?? 0),
72
76
  ...(entry.currentTurnStartedAt !== undefined ? { currentTurnStartedAt: entry.currentTurnStartedAt } : {}),
73
77
  ...(entry.currentTurnEndedAt !== undefined ? { currentTurnEndedAt: entry.currentTurnEndedAt } : {}),
78
+ ...(entry.turns !== undefined && entry.turns > 0 ? { turns: entry.turns } : {}),
79
+ ...(entry.lastActivityAt !== undefined ? { lastActivityAt: entry.lastActivityAt } : {}),
74
80
  });
75
81
  }
76
82
  // Parents that are missing from the snapshot re-attach to the nearest known ancestor.