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.
- package/CHANGELOG.md +93 -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 +396 -62
- 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 +15 -3
- package/src/extension/legion-doctor.ts +55 -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/extension/timing.ts +391 -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/timing-breakdown.ts +23 -0
- 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 +297 -24
- package/src/tui/status.ts +71 -35
- package/src/tui/viz/layout.ts +50 -6
- package/src/tui/viz/model.ts +6 -0
- package/src/tui/viz/panels.ts +164 -12
- package/src/tui/viz/railway.ts +154 -21
- package/src/tui/viz/rate.ts +36 -14
- package/src/tui/viz/view.ts +69 -10
package/src/tui/viz/rate.ts
CHANGED
|
@@ -1,23 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Minimal in-memory token-rate sampling.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* tree detail t/s) uses ONE definition:
|
|
4
|
+
* Two UI surfaces show throughput with different definitions:
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* - Header / snap tok/s (`nowRate`): 60s trailing window + live (running)
|
|
7
|
+
* agents only. Numerator = Δ cumulative output tokens; denominator =
|
|
8
|
+
* Δ cumulative generationMs over the same window (falls back to wall-clock
|
|
9
|
+
* window when generationMs is unavailable / zero, or when in-window
|
|
10
|
+
* ΔgenerationMs is below MIN_RATE_GENERATION_MS — same 1s floor as the
|
|
11
|
+
* tree-row gate). Once nothing is live, the header rate is 0 even if the
|
|
12
|
+
* window still holds Δtokens.
|
|
13
|
+
* - Tree-row label (`avg t/s` in status.ts): lifetime cumulative average
|
|
14
|
+
* `tokensOut / (generationMs/1000)` — no window, no live filter.
|
|
12
15
|
*
|
|
13
16
|
* generationMs comes from session JSONL: sum of (assistant_ts − prior
|
|
14
17
|
* user/toolResult_ts). Idle and tool gaps are excluded. Approximate —
|
|
15
18
|
* usage lands when each assistant message completes, not mid-stream.
|
|
16
19
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* chart buckets/avg so the history window stays accurate; once nothing is live,
|
|
20
|
-
* "now" is forced to 0 even if the 60s window still holds Δtokens.
|
|
20
|
+
* Settled/retired series keep contributing to chart buckets/avg so the
|
|
21
|
+
* history window stays accurate.
|
|
21
22
|
*/
|
|
22
23
|
|
|
23
24
|
import type { VizNode } from "./model.ts";
|
|
@@ -53,6 +54,8 @@ export class RateSampler {
|
|
|
53
54
|
private readonly series = new Map<string, Series>();
|
|
54
55
|
private readonly retentionMs: number;
|
|
55
56
|
private first: number | undefined;
|
|
57
|
+
/** Wall time of the latest tokensOut/gen change across all series (not flat-collapse moves). */
|
|
58
|
+
private lastChange: number | undefined;
|
|
56
59
|
|
|
57
60
|
constructor(options: { retentionMs?: number } = {}) {
|
|
58
61
|
this.retentionMs = options.retentionMs ?? DEFAULT_RETENTION_MS;
|
|
@@ -62,6 +65,15 @@ export class RateSampler {
|
|
|
62
65
|
return this.first;
|
|
63
66
|
}
|
|
64
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Latest timestamp where any series recorded a substantive tokensOut or
|
|
70
|
+
* generationMs change. Flat-collapse (advancing a flat trailing sample to
|
|
71
|
+
* `now`) does not move this — chart x-axis freezes until real data arrives.
|
|
72
|
+
*/
|
|
73
|
+
get lastChangeAt(): number | undefined {
|
|
74
|
+
return this.lastChange;
|
|
75
|
+
}
|
|
76
|
+
|
|
65
77
|
record(now: number, nodes: readonly VizNode[]): void {
|
|
66
78
|
if (!Number.isFinite(now)) return;
|
|
67
79
|
if (this.first === undefined) this.first = now;
|
|
@@ -85,9 +97,11 @@ export class RateSampler {
|
|
|
85
97
|
// Cumulative counters never decrease; ignore regressions from stale sources.
|
|
86
98
|
const value = last === undefined ? node.tokensOut : Math.max(last, node.tokensOut);
|
|
87
99
|
const gen = lastGen === undefined ? node.generationMs : Math.max(lastGen, node.generationMs);
|
|
100
|
+
const changed = last === undefined || value !== last || gen !== lastGen;
|
|
88
101
|
if (lastTs !== undefined && now <= lastTs) {
|
|
89
102
|
s.value[s.value.length - 1] = value;
|
|
90
103
|
s.gen[s.gen.length - 1] = gen;
|
|
104
|
+
if (changed) this.noteChange(lastTs);
|
|
91
105
|
continue;
|
|
92
106
|
}
|
|
93
107
|
if (
|
|
@@ -99,16 +113,23 @@ export class RateSampler {
|
|
|
99
113
|
s.gen[s.gen.length - 2] === lastGen
|
|
100
114
|
) {
|
|
101
115
|
// Collapse flat runs: move the trailing sample forward.
|
|
116
|
+
// Does NOT advance lastChangeAt — chart x-axis stays frozen.
|
|
102
117
|
s.ts[s.ts.length - 1] = now;
|
|
103
118
|
continue;
|
|
104
119
|
}
|
|
105
120
|
s.ts.push(now);
|
|
106
121
|
s.value.push(value);
|
|
107
122
|
s.gen.push(gen);
|
|
123
|
+
if (changed) this.noteChange(now);
|
|
108
124
|
this.prune(s, now);
|
|
109
125
|
}
|
|
110
126
|
}
|
|
111
127
|
|
|
128
|
+
private noteChange(at: number): void {
|
|
129
|
+
if (!Number.isFinite(at)) return;
|
|
130
|
+
this.lastChange = this.lastChange === undefined ? at : Math.max(this.lastChange, at);
|
|
131
|
+
}
|
|
132
|
+
|
|
112
133
|
private prune(s: Series, now: number): void {
|
|
113
134
|
const cutoff = now - this.retentionMs;
|
|
114
135
|
let drop = 0;
|
|
@@ -152,7 +173,7 @@ export class RateSampler {
|
|
|
152
173
|
|
|
153
174
|
/**
|
|
154
175
|
* Sum of node rates. When `onlyNodeIds` is set (including empty), only those
|
|
155
|
-
* series contribute — used for header
|
|
176
|
+
* series contribute — used for the header tok/s. Omit it for chart buckets/avg
|
|
156
177
|
* so settled agents still paint their trailing-window history.
|
|
157
178
|
*/
|
|
158
179
|
totalRate(now: number, windowMs = RATE_WINDOW_MS, onlyNodeIds?: Iterable<string>): number {
|
|
@@ -165,7 +186,7 @@ export class RateSampler {
|
|
|
165
186
|
return sum;
|
|
166
187
|
}
|
|
167
188
|
|
|
168
|
-
/** Header / snap
|
|
189
|
+
/** Header / snap tok/s: live (running) agents only; none live → 0. */
|
|
169
190
|
nowRate(now: number, nodes: readonly VizNode[], windowMs = RATE_WINDOW_MS): number {
|
|
170
191
|
return this.totalRate(now, windowMs, liveRateNodeIds(nodes));
|
|
171
192
|
}
|
|
@@ -215,10 +236,11 @@ export class RateSampler {
|
|
|
215
236
|
clear(): void {
|
|
216
237
|
this.series.clear();
|
|
217
238
|
this.first = undefined;
|
|
239
|
+
this.lastChange = undefined;
|
|
218
240
|
}
|
|
219
241
|
}
|
|
220
242
|
|
|
221
|
-
/** Node ids that contribute to header
|
|
243
|
+
/** Node ids that contribute to the header tok/s (running agents; root excluded). */
|
|
222
244
|
export function liveRateNodeIds(nodes: readonly VizNode[]): string[] {
|
|
223
245
|
return nodes.filter((n) => n.id !== "root" && n.status === "running").map((n) => n.id);
|
|
224
246
|
}
|
package/src/tui/viz/view.ts
CHANGED
|
@@ -1,26 +1,39 @@
|
|
|
1
|
-
/** /legion full view: galaxy + tok/s chart + git-log history with linked selection. */
|
|
1
|
+
/** /legion full view: galaxy + tok/s chart + timing + git-log history with linked selection. */
|
|
2
2
|
|
|
3
3
|
import { Key, matchesKey } from "@earendil-works/pi-tui";
|
|
4
4
|
import { Canvas, cellWidth, ellipsize } from "./canvas.ts";
|
|
5
5
|
import { computeLayout, type LayoutKind } from "./layout.ts";
|
|
6
6
|
import { formatCount, formatDuration, isAgent, type VizNode } from "./model.ts";
|
|
7
7
|
import { roleColor, type VizTheme } from "./palette.ts";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
buildLog,
|
|
10
|
+
dfsOrder,
|
|
11
|
+
drawChart,
|
|
12
|
+
drawGalaxy,
|
|
13
|
+
drawLog,
|
|
14
|
+
drawTiming,
|
|
15
|
+
frame,
|
|
16
|
+
timingRowsFromNodes,
|
|
17
|
+
type Pane,
|
|
18
|
+
type ViewData,
|
|
19
|
+
} from "./panels.ts";
|
|
9
20
|
|
|
10
21
|
export interface LegionViewState {
|
|
11
22
|
focus: Pane;
|
|
12
23
|
selectedId?: string;
|
|
13
24
|
logOffset: number;
|
|
14
25
|
logCursor: number;
|
|
26
|
+
timingOffset: number;
|
|
27
|
+
timingCursor: number;
|
|
15
28
|
/** `x` pressed: an in-view y/N prompt for stopping this agent (dialogs would be hidden behind the full-screen view). */
|
|
16
29
|
pendingStopId?: string;
|
|
17
30
|
}
|
|
18
31
|
|
|
19
32
|
export function initialViewState(): LegionViewState {
|
|
20
|
-
return { focus: "galaxy", logOffset: 0, logCursor: 0 };
|
|
33
|
+
return { focus: "galaxy", logOffset: 0, logCursor: 0, timingOffset: 0, timingCursor: 0 };
|
|
21
34
|
}
|
|
22
35
|
|
|
23
|
-
const PANES: Pane[] = ["galaxy", "chart", "log"];
|
|
36
|
+
const PANES: Pane[] = ["galaxy", "chart", "timing", "log"];
|
|
24
37
|
|
|
25
38
|
/** Min grapheme cells before painting a selection badge. Shared by portrait + landscape so a truncated stub never paints (#128). Was portrait `>4` vs landscape `>0`. */
|
|
26
39
|
export const MIN_SELECTION_BADGE_W = 5;
|
|
@@ -72,8 +85,26 @@ export function renderLegionView(data: ViewData, state: LegionViewState, cols: n
|
|
|
72
85
|
drawGalaxy(cv, galaxyInner, data, state.selectedId);
|
|
73
86
|
const chartInner = frame(cv, layout.chart, portrait ? "tok/s · by role" : "tok/s · stacked by role", state.focus === "chart");
|
|
74
87
|
drawChart(cv, chartInner, data, selected, !portrait);
|
|
88
|
+
|
|
89
|
+
const timingRows = timingRowsFromNodes(data.nodes, data.now);
|
|
90
|
+
const timingVisible = Math.max(1, layout.timing.h - 3);
|
|
91
|
+
clampList(state, "timing", timingRows.length, timingVisible);
|
|
92
|
+
const timingInner = frame(
|
|
93
|
+
cv,
|
|
94
|
+
layout.timing,
|
|
95
|
+
portrait ? "timing" : "timing · wall/gen/other",
|
|
96
|
+
state.focus === "timing",
|
|
97
|
+
portrait ? undefined : "↑↓ · ⏎ sel · o open",
|
|
98
|
+
);
|
|
99
|
+
drawTiming(cv, timingInner, timingRows, {
|
|
100
|
+
offset: state.timingOffset,
|
|
101
|
+
cursor: state.timingCursor,
|
|
102
|
+
focused: state.focus === "timing",
|
|
103
|
+
...(state.selectedId ? { selectedId: state.selectedId } : {}),
|
|
104
|
+
});
|
|
105
|
+
|
|
75
106
|
const events = buildLog(data.nodes);
|
|
76
|
-
|
|
107
|
+
clampList(state, "log", events.length, Math.max(1, layout.log.h - 3));
|
|
77
108
|
const logInner = frame(cv, layout.log, "log --graph", state.focus === "log", portrait ? undefined : "↑↓ scroll · ⏎ select");
|
|
78
109
|
drawLog(cv, logInner, events, {
|
|
79
110
|
offset: state.logOffset,
|
|
@@ -112,11 +143,23 @@ function statusLabel(node: VizNode): string {
|
|
|
112
143
|
return node.status === "settled" ? "done" : node.status;
|
|
113
144
|
}
|
|
114
145
|
|
|
115
|
-
function
|
|
116
|
-
state
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
146
|
+
function clampList(
|
|
147
|
+
state: LegionViewState,
|
|
148
|
+
which: "log" | "timing",
|
|
149
|
+
count: number,
|
|
150
|
+
visible: number,
|
|
151
|
+
): void {
|
|
152
|
+
if (which === "log") {
|
|
153
|
+
state.logCursor = Math.max(0, Math.min(state.logCursor, count - 1));
|
|
154
|
+
if (state.logCursor < state.logOffset) state.logOffset = state.logCursor;
|
|
155
|
+
if (state.logCursor >= state.logOffset + visible) state.logOffset = state.logCursor - visible + 1;
|
|
156
|
+
state.logOffset = Math.max(0, Math.min(state.logOffset, Math.max(0, count - visible)));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
state.timingCursor = Math.max(0, Math.min(state.timingCursor, count - 1));
|
|
160
|
+
if (state.timingCursor < state.timingOffset) state.timingOffset = state.timingCursor;
|
|
161
|
+
if (state.timingCursor >= state.timingOffset + visible) state.timingOffset = state.timingCursor - visible + 1;
|
|
162
|
+
state.timingOffset = Math.max(0, Math.min(state.timingOffset, Math.max(0, count - visible)));
|
|
120
163
|
}
|
|
121
164
|
|
|
122
165
|
export type ViewAction = "none" | "render" | "close" | "open" | "stop";
|
|
@@ -124,6 +167,7 @@ export type ViewAction = "none" | "render" | "close" | "open" | "stop";
|
|
|
124
167
|
/** Pure key handling so it can be unit tested without a terminal. */
|
|
125
168
|
export function handleViewKey(data: string, state: LegionViewState, nodes: readonly VizNode[], page = 10): ViewAction {
|
|
126
169
|
const events = buildLog(nodes);
|
|
170
|
+
const timingRows = timingRowsFromNodes(nodes, Date.now());
|
|
127
171
|
const order = dfsOrder(nodes).filter(isAgent);
|
|
128
172
|
if (state.pendingStopId) {
|
|
129
173
|
const confirmed = matchesKey(data, "y") || data === "Y" || matchesKey(data, Key.enter);
|
|
@@ -171,6 +215,21 @@ export function handleViewKey(data: string, state: LegionViewState, nodes: reado
|
|
|
171
215
|
} else return "none";
|
|
172
216
|
return "render";
|
|
173
217
|
}
|
|
218
|
+
if (state.focus === "timing") {
|
|
219
|
+
if (down) state.timingCursor = Math.min(timingRows.length - 1, state.timingCursor + 1);
|
|
220
|
+
else if (up) state.timingCursor = Math.max(0, state.timingCursor - 1);
|
|
221
|
+
else if (matchesKey(data, Key.pageDown)) state.timingCursor = Math.min(timingRows.length - 1, state.timingCursor + page);
|
|
222
|
+
else if (matchesKey(data, Key.pageUp)) state.timingCursor = Math.max(0, state.timingCursor - page);
|
|
223
|
+
else if (matchesKey(data, Key.home)) state.timingCursor = 0;
|
|
224
|
+
else if (matchesKey(data, Key.end)) state.timingCursor = Math.max(0, timingRows.length - 1);
|
|
225
|
+
else if (matchesKey(data, Key.enter)) {
|
|
226
|
+
const row = timingRows[state.timingCursor];
|
|
227
|
+
if (!row) return "none";
|
|
228
|
+
state.selectedId = state.selectedId === row.node.id ? undefined : row.node.id;
|
|
229
|
+
if (state.selectedId === undefined) delete state.selectedId;
|
|
230
|
+
} else return "none";
|
|
231
|
+
return "render";
|
|
232
|
+
}
|
|
174
233
|
if (down || up || matchesKey(data, Key.right) || matchesKey(data, Key.left)) {
|
|
175
234
|
if (order.length === 0) return "none";
|
|
176
235
|
const step = down || matchesKey(data, Key.right) ? 1 : -1;
|