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
@@ -1,23 +1,24 @@
1
1
  /**
2
2
  * Minimal in-memory token-rate sampling.
3
3
  *
4
- * Every tok/s figure in the UI (railway header, /legion header, chart "now",
5
- * tree detail t/s) uses ONE definition:
4
+ * Two UI surfaces show throughput with different definitions:
6
5
  *
7
- * numerator = Δ cumulative output tokens over the trailing window
8
- * denominator = Δ cumulative generationMs over the same window
9
- * (falls back to wall-clock window when generationMs is
10
- * unavailable / zero, or when in-window ΔgenerationMs is
11
- * below MIN_RATE_GENERATION_MS — same 1s floor as tree t/s)
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
- * Header / snap "now" (`nowRate` / `totalRate` with live node ids) only sums
18
- * agents that are still `running`. Settled/retired series keep contributing to
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 "now". Omit it for chart buckets/avg
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 "now" tok/s: live (running) agents only; none live → 0. */
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 "now" tok/s (running agents; root excluded). */
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
  }
@@ -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 { buildLog, dfsOrder, drawChart, drawGalaxy, drawLog, frame, type Pane, type ViewData } from "./panels.ts";
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
- clampLog(state, events.length, Math.max(1, layout.log.h - 3));
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 clampLog(state: LegionViewState, count: number, visible: number): void {
116
- state.logCursor = Math.max(0, Math.min(state.logCursor, count - 1));
117
- if (state.logCursor < state.logOffset) state.logOffset = state.logCursor;
118
- if (state.logCursor >= state.logOffset + visible) state.logOffset = state.logCursor - visible + 1;
119
- state.logOffset = Math.max(0, Math.min(state.logOffset, Math.max(0, count - visible)));
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;