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.
- package/CHANGELOG.md +71 -1
- package/CONTRIBUTING.md +27 -24
- package/README.md +103 -19
- package/docs/contracts/osc-busy-plugin.md +72 -0
- package/docs/contracts/spawn-default-inprocess-v3.md +322 -0
- package/docs/contracts/wait-not-stop.md +30 -0
- package/docs/design.md +2 -2
- package/docs/headless-child-contract.md +83 -0
- package/docs/inprocess-supervisor.md +140 -0
- package/docs/qa-hunting-round.md +150 -0
- package/docs/subagents-teams.md +55 -18
- package/docs/tui-qa-playbook.md +284 -0
- package/index.ts +315 -49
- package/package.json +15 -8
- package/skills/pi-legion/SKILL.md +5 -0
- package/src/agents/agents.ts +34 -6
- package/src/agents/context.ts +83 -1
- package/src/agents/overrides.ts +1 -0
- package/src/agents/settings.ts +122 -1
- package/src/api.ts +23 -0
- package/src/extension/child-guard.ts +42 -3
- package/src/extension/legion-commands.ts +9 -2
- package/src/extension/legion-doctor.ts +54 -0
- package/src/extension/legion-stop.ts +6 -2
- package/src/extension/playbook.ts +3 -3
- package/src/extension/runtime.ts +57 -8
- package/src/extension/scout-guard.ts +39 -1
- package/src/extension/session-errors.ts +191 -0
- package/src/runs/args.ts +8 -0
- package/src/runs/orchestrator.ts +125 -16
- package/src/shared/output-cap.ts +2 -2
- package/src/shared/session.ts +2 -1
- package/src/shared/types.ts +74 -0
- package/src/supervisor/child-context.ts +90 -0
- package/src/supervisor/child-memory.ts +27 -3
- package/src/supervisor/inprocess-depth.ts +71 -0
- package/src/supervisor/inprocess-supervisor.ts +1011 -0
- package/src/supervisor/routing-supervisor.ts +179 -0
- package/src/supervisor/rpc-supervisor.ts +14 -0
- package/src/supervisor/spawn-mode.ts +35 -0
- package/src/supervisor/types.ts +13 -0
- package/src/supervisor/ui-proxy.ts +78 -0
- package/src/tui/live-overlay.ts +147 -55
- package/src/tui/status.ts +71 -35
- package/src/tui/viz/model.ts +6 -0
- package/src/tui/viz/panels.ts +8 -9
- package/src/tui/viz/railway.ts +154 -21
- package/src/tui/viz/rate.ts +15 -14
package/src/tui/live-overlay.ts
CHANGED
|
@@ -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
|
-
/**
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 === "
|
|
920
|
+
if (hit.zone === "close") {
|
|
855
921
|
if (event.type === "click" && event.button === "left") {
|
|
856
|
-
// Esc /
|
|
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
|
-
/**
|
|
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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
const
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
const
|
|
972
|
-
const
|
|
973
|
-
const
|
|
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 =
|
|
976
|
-
const closeColStart = Math.max(
|
|
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
|
-
|
|
1077
|
+
frameRows,
|
|
1078
|
+
frameCol: 0,
|
|
1079
|
+
showMeta,
|
|
988
1080
|
closeColStart,
|
|
989
1081
|
closeColEnd,
|
|
990
1082
|
};
|
|
991
1083
|
}
|
|
992
1084
|
|
|
993
|
-
type ModalHitZone = "
|
|
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
|
-
|
|
1001
|
-
|
|
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
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
const top =
|
|
1039
|
-
const bottom =
|
|
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(
|
|
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
|
-
|
|
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
|
|
135
|
+
* Elapsed since the most recent task (turn) started, shown next to the
|
|
136
|
+
* lifetime elapsed.
|
|
115
137
|
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
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"
|
|
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
|
-
*
|
|
151
|
-
*
|
|
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" | "
|
|
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;
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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?():
|
|
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
|
-
//
|
|
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
|
}
|
package/src/tui/viz/model.ts
CHANGED
|
@@ -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.
|