pi-legion 2.0.3 → 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 (52) hide show
  1. package/CHANGELOG.md +93 -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 +396 -62
  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 +15 -3
  23. package/src/extension/legion-doctor.ts +55 -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/extension/timing.ts +391 -0
  30. package/src/runs/args.ts +8 -0
  31. package/src/runs/orchestrator.ts +125 -16
  32. package/src/shared/output-cap.ts +2 -2
  33. package/src/shared/session.ts +2 -1
  34. package/src/shared/timing-breakdown.ts +23 -0
  35. package/src/shared/types.ts +74 -0
  36. package/src/supervisor/child-context.ts +90 -0
  37. package/src/supervisor/child-memory.ts +27 -3
  38. package/src/supervisor/inprocess-depth.ts +71 -0
  39. package/src/supervisor/inprocess-supervisor.ts +1011 -0
  40. package/src/supervisor/routing-supervisor.ts +179 -0
  41. package/src/supervisor/rpc-supervisor.ts +14 -0
  42. package/src/supervisor/spawn-mode.ts +35 -0
  43. package/src/supervisor/types.ts +13 -0
  44. package/src/supervisor/ui-proxy.ts +78 -0
  45. package/src/tui/live-overlay.ts +297 -24
  46. package/src/tui/status.ts +71 -35
  47. package/src/tui/viz/layout.ts +50 -6
  48. package/src/tui/viz/model.ts +6 -0
  49. package/src/tui/viz/panels.ts +164 -12
  50. package/src/tui/viz/railway.ts +154 -21
  51. package/src/tui/viz/rate.ts +36 -14
  52. package/src/tui/viz/view.ts +69 -10
@@ -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";
@@ -21,6 +23,9 @@ import {
21
23
  } from "./chat-items.ts";
22
24
 
23
25
  export interface LiveOverlayNode extends Pick<LegionNode, "id" | "name" | "role" | "model" | "status" | "createdAt" | "updatedAt"> {
26
+ /** Null parent = root/主 agent; set for 子 agents so the modal title can mark 主/子. */
27
+ parentId?: string | null;
28
+ depth?: number;
24
29
  turn?: number;
25
30
  turns?: number;
26
31
  sessionFile?: string | null;
@@ -147,6 +152,59 @@ const DEFAULT_BACKFILL_TURNS = 3;
147
152
  const DEFAULT_TERMINAL_ROWS = 24;
148
153
  const TOOL_COLLAPSED_PREVIEW_LINES = 10;
149
154
  const BACKFILL_POLL_INTERVAL_MS = 250;
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;
173
+ const MODAL_CLOSE_HINT = "Esc";
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
+ }
150
208
 
151
209
  /**
152
210
  * Build an on-demand context-viewer controller. Calling `open` subscribes to
@@ -287,7 +345,13 @@ function createSession(
287
345
  return value;
288
346
  };
289
347
 
290
- const bodyPageSize = (): number => Math.max(0, currentTerminalRows() - 1);
348
+ /** Full terminal width last passed to render/handleMouse (modal chrome uses this). */
349
+ let terminalWidth = 80;
350
+
351
+ const layoutFor = (width = terminalWidth): ModalLayout =>
352
+ resolveModalLayout(currentTerminalRows(), Math.max(1, Math.floor(width)));
353
+
354
+ const bodyPageSize = (): number => layoutFor().bodyRows;
291
355
 
292
356
  const mountNewItems = (): void => {
293
357
  for (const item of mountedItems) {
@@ -458,7 +522,7 @@ function createSession(
458
522
  consumedFinalizedText ||
459
523
  message.role !== "assistant" ||
460
524
  !Array.isArray(message.content) ||
461
- !messageText(message.content).includes(finalizedText)
525
+ !finalizedText.includes(messageText(message.content))
462
526
  ) {
463
527
  return message;
464
528
  }
@@ -534,7 +598,6 @@ function createSession(
534
598
 
535
599
  const updateStreamingText = (delta: string): void => {
536
600
  if (!delta) return;
537
- if (finalizedAssistantText) finalizedAssistantText = "";
538
601
  streamingText = clampStreamingText(`${streamingText}${delta}`);
539
602
  appendActivityText(delta);
540
603
  chat.updateStreamingText(streamingText, sourceNode()?.model ?? initialNode?.model ?? undefined);
@@ -661,13 +724,19 @@ function createSession(
661
724
  };
662
725
 
663
726
  let stopPending = false;
664
- const headerLine = (width: number): string => {
665
- const safeWidth = Math.max(1, Math.floor(width));
727
+
728
+ /** Slim title: name + 主/子 only. Run meta lives in the footer band (#157). */
729
+ const titlePlain = (): string => {
666
730
  const node = sourceNode();
667
- if (!node) {
668
- const missing = `● ${nodeId} · node unavailable`;
669
- return style(options.theme, "dim", fitLine(missing, safeWidth));
670
- }
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";
671
740
  const status = node.status;
672
741
  const completed = status === "settled" || status === "failed" || status === "retired";
673
742
  const elapsedEnd = completed ? node.updatedAt : now();
@@ -677,14 +746,45 @@ function createSession(
677
746
  ? `${Math.max(0, Math.round((now() - turnStartedAt) / 1_000))}s`
678
747
  : undefined;
679
748
  const turnLabel = `turn ${turnNumber}${turnElapsed !== undefined ? ` (${turnElapsed})` : ""}`;
680
- const details = [
681
- `● ${safeText(node.name)} (${safeText(node.role)})`,
749
+ return [
750
+ safeText(node.role),
682
751
  ...(node.model ? [safeText(node.model)] : []),
683
752
  turnLabel,
684
753
  elapsed,
685
754
  status,
686
- ];
687
- return style(options.theme, completed ? "dim" : "accent", fitLine(details.join(" · "), safeWidth));
755
+ ].join(" · ");
756
+ };
757
+
758
+ const titleBarInner = (innerWidth: number): string => {
759
+ const safeWidth = Math.max(1, Math.floor(innerWidth));
760
+ const node = sourceNode();
761
+ const completed = node !== undefined &&
762
+ (node.status === "settled" || node.status === "failed" || node.status === "retired");
763
+ if (stopPending) {
764
+ return style(
765
+ options.theme,
766
+ "accent",
767
+ fitLine(
768
+ `Stop ${safeText(node?.name ?? nodeId)}? Its own subagents are retired with it. y stop · any other key cancel`,
769
+ safeWidth,
770
+ ),
771
+ );
772
+ }
773
+ const closeHint = ` ${MODAL_CLOSE_HINT} `;
774
+ const closeWidth = visibleWidth(closeHint);
775
+ const titleBudget = Math.max(1, safeWidth - closeWidth);
776
+ const coloredTitle = style(
777
+ options.theme,
778
+ !node || completed ? "dim" : "accent",
779
+ fitLine(titlePlain(), titleBudget),
780
+ );
781
+ const pad = Math.max(0, titleBudget - visibleWidth(stripTerminalSequences(coloredTitle)));
782
+ return coloredTitle + " ".repeat(pad) + style(options.theme, "dim", closeHint);
783
+ };
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));
688
788
  };
689
789
 
690
790
  const close = (): void => {
@@ -741,15 +841,27 @@ function createSession(
741
841
  const component: LiveOverlayComponent = {
742
842
  nodeId,
743
843
  render(width) {
744
- contentWidth = Math.max(1, Math.floor(width));
844
+ terminalWidth = Math.max(1, Math.floor(width));
845
+ const layout = layoutFor(terminalWidth);
846
+ contentWidth = layout.innerWidth;
745
847
  lastBodyLines = renderBody(contentWidth);
746
848
  syncScroll(lastBodyLines);
747
849
  const start = Math.max(0, scrollTop);
748
- const end = start + bodyPageSize();
749
- const header = stopPending
750
- ? style(options.theme, "accent", fitLine(`Stop ${safeText(sourceNode()?.name ?? nodeId)}? Its own subagents are retired with it. y stop · any other key cancel`, contentWidth))
751
- : headerLine(contentWidth);
752
- return [header, ...lastBodyLines.slice(start, end)];
850
+ const end = start + layout.bodyRows;
851
+ const visible = lastBodyLines.slice(start, end);
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`;
857
+ return paintModalFrame({
858
+ layout,
859
+ theme: options.theme,
860
+ titleInner: titleBarInner(layout.innerWidth),
861
+ bodyLines: visible,
862
+ metaInner: layout.showMeta ? metaBarInner(layout.innerWidth) : undefined,
863
+ footerHint,
864
+ });
753
865
  },
754
866
  handleInput(data) {
755
867
  if (!open) return;
@@ -794,20 +906,33 @@ function createSession(
794
906
  },
795
907
  handleMouse(event: TuiMouseEvent) {
796
908
  if (!open) return undefined;
909
+ terminalWidth = Math.max(1, Math.floor(event.width));
910
+ const layout = layoutFor(terminalWidth);
911
+ contentWidth = layout.innerWidth;
797
912
  if (event.type === "wheel" && event.wheelDelta) {
913
+ // Wheel over the frame scrolls the modal body.
798
914
  scrollBy(event.wheelDelta);
799
915
  return { handled: true };
800
916
  }
801
- contentWidth = Math.max(1, Math.floor(event.width));
802
917
  lastBodyLines = renderBody(contentWidth);
803
918
  clampScroll(lastBodyLines);
804
- const viewportY = event.y - 1;
805
- if (viewportY < 0 || viewportY >= bodyPageSize()) return undefined;
806
- const transcriptY = viewportY + scrollTop;
807
- if (transcriptY >= lastBodyLines.length) return undefined;
919
+ const hit = hitTestModal(layout, event.x, event.y);
920
+ if (hit.zone === "close") {
921
+ if (event.type === "click" && event.button === "left") {
922
+ // Esc / close hint: dismiss only — never retire or stop (esc-no-retire-v3).
923
+ close();
924
+ return { handled: true };
925
+ }
926
+ return { handled: true };
927
+ }
928
+ if (hit.zone !== "body" || hit.bodyY === undefined) return { handled: true };
929
+ const transcriptY = hit.bodyY + scrollTop;
930
+ if (transcriptY >= lastBodyLines.length) return { handled: true };
808
931
  return transcript.handleMouse({
809
932
  ...event,
933
+ x: hit.bodyX ?? 0,
810
934
  y: transcriptY,
935
+ width: layout.innerWidth,
811
936
  height: lastBodyLines.length,
812
937
  });
813
938
  },
@@ -872,3 +997,151 @@ function style(
872
997
  ): string {
873
998
  return theme ? theme.fg(color, line) : line;
874
999
  }
1000
+
1001
+ /** Label the viewed agent as 主 (root) or 子 (child) for the modal title bar. */
1002
+ export function contextKindLabel(node: LiveOverlayNode | undefined, nodeId: string): "主" | "子" {
1003
+ if (node?.parentId === null || node?.depth === 0 || nodeId === "root") return "主";
1004
+ if (node?.parentId !== undefined && node.parentId !== null) return "子";
1005
+ if (node?.depth !== undefined && node.depth > 0) return "子";
1006
+ // Infer from Legion id shape: root has no dots; children are parent.child…
1007
+ return nodeId === "root" || !nodeId.includes(".") ? "主" : "子";
1008
+ }
1009
+
1010
+ export interface ModalLayout {
1011
+ rows: number;
1012
+ cols: number;
1013
+ marginY: number;
1014
+ marginX: number;
1015
+ titleRow: number;
1016
+ bodyStartRow: number;
1017
+ /** Fixed meta row below the scrollable body; null when collapsed into the footer. */
1018
+ metaRow: number | null;
1019
+ footerRow: number;
1020
+ bodyRows: number;
1021
+ innerWidth: number;
1022
+ /** Inclusive frame height in rows (title + body + optional meta + footer). */
1023
+ frameRows: number;
1024
+ /** Column where modal border (│/┏) starts. */
1025
+ frameCol: number;
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
+ */
1032
+ closeColStart: number;
1033
+ closeColEnd: number;
1034
+ }
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
+ */
1045
+ export function resolveModalLayout(rows: number, cols: number): ModalLayout {
1046
+ const safeRows = Math.max(1, Math.floor(rows));
1047
+ const safeCols = Math.max(1, Math.floor(cols));
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;
1063
+ const closeWidth = visibleWidth(` ${MODAL_CLOSE_HINT} `);
1064
+ const closeColEnd = 1 + innerWidth;
1065
+ const closeColStart = Math.max(1, closeColEnd - closeWidth);
1066
+ return {
1067
+ rows: safeRows,
1068
+ cols: safeCols,
1069
+ marginY: 0,
1070
+ marginX: 0,
1071
+ titleRow,
1072
+ bodyStartRow,
1073
+ metaRow,
1074
+ footerRow,
1075
+ bodyRows,
1076
+ innerWidth,
1077
+ frameRows,
1078
+ frameCol: 0,
1079
+ showMeta,
1080
+ closeColStart,
1081
+ closeColEnd,
1082
+ };
1083
+ }
1084
+
1085
+ type ModalHitZone = "close" | "title" | "body" | "meta" | "footer";
1086
+
1087
+ function hitTestModal(
1088
+ layout: ModalLayout,
1089
+ x: number,
1090
+ y: number,
1091
+ ): { zone: ModalHitZone; bodyX?: number; bodyY?: number } {
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.
1095
+ if (y === layout.titleRow) {
1096
+ if (x >= layout.closeColStart && x < layout.closeColEnd) return { zone: "close" };
1097
+ return { zone: "title" };
1098
+ }
1099
+ if (layout.metaRow !== null && y === layout.metaRow) return { zone: "meta" };
1100
+ if (y === layout.footerRow) return { zone: "footer" };
1101
+ return {
1102
+ zone: "body",
1103
+ bodyX: Math.max(0, x - 1),
1104
+ bodyY: y - layout.bodyStartRow,
1105
+ };
1106
+ }
1107
+
1108
+ function padToInner(line: string, innerWidth: number): string {
1109
+ const clipped = fitLine(line, innerWidth);
1110
+ const pad = Math.max(0, innerWidth - visibleWidth(stripTerminalSequences(clipped)));
1111
+ return clipped + " ".repeat(pad);
1112
+ }
1113
+
1114
+ function paintModalFrame(input: {
1115
+ layout: ModalLayout;
1116
+ theme: LiveOverlayTheme | undefined;
1117
+ titleInner: string;
1118
+ bodyLines: readonly string[];
1119
+ /** Fixed meta band below the body; omitted when layout.showMeta is false. */
1120
+ metaInner?: string;
1121
+ footerHint: string;
1122
+ }): string[] {
1123
+ const { layout, theme, titleInner, bodyLines, metaInner, footerHint } = input;
1124
+ const dim = (s: string) => style(theme, "dim", s);
1125
+ const accent = (s: string) => style(theme, "accent", s);
1126
+ const lines: string[] = [];
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("┗") +
1133
+ padToInner(dim(` ${footerHint} `), layout.innerWidth) +
1134
+ accent("┛");
1135
+
1136
+ lines.push(top);
1137
+ for (let i = 0; i < layout.bodyRows; i++) {
1138
+ const body = padToInner(bodyLines[i] ?? "", layout.innerWidth);
1139
+ lines.push(accent("┃") + body + accent("┃"));
1140
+ }
1141
+ if (layout.showMeta) {
1142
+ const meta = padToInner(metaInner ?? "", layout.innerWidth);
1143
+ lines.push(accent("┃") + meta + accent("┃"));
1144
+ }
1145
+ lines.push(bottom);
1146
+ return lines;
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
  }