pi-legion 2.0.4 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +71 -1
  2. package/CONTRIBUTING.md +27 -24
  3. package/README.md +103 -19
  4. package/docs/contracts/osc-busy-plugin.md +72 -0
  5. package/docs/contracts/spawn-default-inprocess-v3.md +322 -0
  6. package/docs/contracts/wait-not-stop.md +30 -0
  7. package/docs/design.md +2 -2
  8. package/docs/headless-child-contract.md +83 -0
  9. package/docs/inprocess-supervisor.md +140 -0
  10. package/docs/qa-hunting-round.md +150 -0
  11. package/docs/subagents-teams.md +55 -18
  12. package/docs/tui-qa-playbook.md +284 -0
  13. package/index.ts +315 -49
  14. package/package.json +15 -8
  15. package/skills/pi-legion/SKILL.md +5 -0
  16. package/src/agents/agents.ts +34 -6
  17. package/src/agents/context.ts +83 -1
  18. package/src/agents/overrides.ts +1 -0
  19. package/src/agents/settings.ts +122 -1
  20. package/src/api.ts +23 -0
  21. package/src/extension/child-guard.ts +42 -3
  22. package/src/extension/legion-commands.ts +9 -2
  23. package/src/extension/legion-doctor.ts +54 -0
  24. package/src/extension/legion-stop.ts +6 -2
  25. package/src/extension/playbook.ts +3 -3
  26. package/src/extension/runtime.ts +57 -8
  27. package/src/extension/scout-guard.ts +39 -1
  28. package/src/extension/session-errors.ts +191 -0
  29. package/src/runs/args.ts +8 -0
  30. package/src/runs/orchestrator.ts +125 -16
  31. package/src/shared/output-cap.ts +2 -2
  32. package/src/shared/session.ts +2 -1
  33. package/src/shared/types.ts +74 -0
  34. package/src/supervisor/child-context.ts +90 -0
  35. package/src/supervisor/child-memory.ts +27 -3
  36. package/src/supervisor/inprocess-depth.ts +71 -0
  37. package/src/supervisor/inprocess-supervisor.ts +1011 -0
  38. package/src/supervisor/routing-supervisor.ts +179 -0
  39. package/src/supervisor/rpc-supervisor.ts +14 -0
  40. package/src/supervisor/spawn-mode.ts +35 -0
  41. package/src/supervisor/types.ts +13 -0
  42. package/src/supervisor/ui-proxy.ts +78 -0
  43. package/src/tui/live-overlay.ts +147 -55
  44. package/src/tui/status.ts +71 -35
  45. package/src/tui/viz/model.ts +6 -0
  46. package/src/tui/viz/panels.ts +8 -9
  47. package/src/tui/viz/railway.ts +154 -21
  48. package/src/tui/viz/rate.ts +15 -14
@@ -13,7 +13,7 @@ export interface ViewData {
13
13
  nodes: readonly VizNode[];
14
14
  sampler: RateSampler;
15
15
  now: number;
16
- /** Header "now" tok/s (live agents only; 0 when none running). Chart avg/buckets keep full-series windows. */
16
+ /** Header tok/s (live agents only; 0 when none running). Chart avg/buckets keep full-series windows. */
17
17
  rate: number;
18
18
  }
19
19
 
@@ -303,7 +303,7 @@ export function computeStats(data: ViewData, buckets: number): ChartStats {
303
303
  peakIndex = i;
304
304
  }
305
305
  });
306
- // "now" is the shared header definition, never the last bucket.
306
+ // The header tok/s is the shared definition, never the last bucket.
307
307
  peak = Math.max(peak, data.rate);
308
308
  const peakAt = peakIndex >= 0 && peak > data.rate ? series.times[peakIndex]! : to;
309
309
  const roleTotals = new Map<string, number>();
@@ -315,7 +315,7 @@ export function computeStats(data: ViewData, buckets: number): ChartStats {
315
315
  }
316
316
  return {
317
317
  now: data.rate,
318
- // Full-series window average (includes settled); differs from header "now" on purpose.
318
+ // Full-series window average (includes settled); differs from the header tok/s on purpose.
319
319
  avg: data.sampler.totalRate(to, Math.max(1, to - from)),
320
320
  peak,
321
321
  peakAt,
@@ -333,7 +333,7 @@ export function drawChart(cv: Canvas, r: Rect, data: ViewData, selected: VizNode
333
333
  const statsBelow = statsW === 0 ? Math.min(selected ? 4 : 3, Math.max(0, r.h - 6)) : 0;
334
334
  const axisW = 6;
335
335
  const plotX = r.x + axisW;
336
- // With side stats, keep room for the "◂ now" label so it never runs into the stats column.
336
+ // With side stats, keep room for the "◂ N" label so it never runs into the stats column.
337
337
  const plotW = Math.max(4, r.w - axisW - statsW - (statsW ? 9 : 0) - 1);
338
338
  const plotTop = r.y + 1;
339
339
  const plotH = Math.max(1, r.h - 2 - statsBelow);
@@ -394,14 +394,13 @@ export function drawChart(cv: Canvas, r: Rect, data: ViewData, selected: VizNode
394
394
  if (stats.peak > 0) yLabel(max, plotTop);
395
395
  if (plotH >= 5 && max >= 20) yLabel(max / 2, axisY - 1 - Math.floor(plotH / 2));
396
396
  yLabel(0, axisY - 1);
397
- // X axis with "now" at the right edge.
397
+ // X axis: elapsed time since the window start; the right edge is the present.
398
398
  cv.text(plotX - 1, axisY, "└" + "─".repeat(plotW), "borderMuted", { maxX: plotX + plotW });
399
399
  cv.text(plotX, axisY, "0m", "dim");
400
400
  const mid = formatDuration((to - from) / 2);
401
401
  if (plotW > 24) cv.text(plotX + Math.floor(plotW / 2) - Math.floor(mid.length / 2), axisY, mid, "dim");
402
- cv.text(plotX + plotW - 3, axisY, "now", "text", { bold: true });
403
402
  const nowLabel = `◂ ${formatCount(stats.now)}`;
404
- if (statsW === 0) cv.text(r.x, r.y, `now ${formatCount(stats.now)} tok/s`, "accent", { bold: true, maxX: Math.max(r.x, plotX + plotW - 22) });
403
+ if (statsW === 0) cv.text(r.x, r.y, `${formatCount(stats.now)} tok/s`, "accent", { bold: true, maxX: Math.max(r.x, plotX + plotW - 22) });
405
404
  else cv.text(plotX + plotW + 1, axisY - 1 - Math.min(plotH - 1, Math.floor(rowsFor(stats.now))), nowLabel, "text", { maxX: r.x + r.w - statsW - 1 });
406
405
 
407
406
  const selLines = (): Array<[string, string]> => {
@@ -410,14 +409,14 @@ export function drawChart(cv: Canvas, r: Rect, data: ViewData, selected: VizNode
410
409
  const share = stats.total > 0 ? (selected.tokens / stats.total) * 100 : 0;
411
410
  return [
412
411
  [`▸ ${selected.name}`, roleColor(selected.role)],
413
- [`${formatCount(rate)} tok/s now · ${formatCount(selected.tokens)} tok`, "text"],
412
+ [`${formatCount(rate)} tok/s · ${formatCount(selected.tokens)} tok`, "text"],
414
413
  [`${share.toFixed(1)}% of run · ━ bright line`, "dim"],
415
414
  ];
416
415
  };
417
416
  if (statsW) {
418
417
  const sx = r.x + r.w - statsW;
419
418
  const rows: Array<[string, string, string]> = [
420
- ["now", `${formatCount(stats.now)} tok/s`, "text"],
419
+ ["rate", `${formatCount(stats.now)} tok/s`, "text"],
421
420
  ["avg", `${formatCount(stats.avg)} tok/s`, "text"],
422
421
  ["peak", stats.peak > 0 ? `${formatCount(stats.peak)} @ ${formatDuration(stats.peakAt - from)}` : "—", "text"],
423
422
  ["total", `${formatCount(stats.total)} tok`, "text"],
@@ -9,7 +9,7 @@
9
9
  * vertical remains traceable.
10
10
  */
11
11
 
12
- import { formatAgentElapsed } from "../status.ts";
12
+ import { formatAgentElapsed, formatActivityAgo } from "../status.ts";
13
13
  import { Canvas, ellipsize } from "./canvas.ts";
14
14
  import { formatCount, isAgent, type VizNode } from "./model.ts";
15
15
  import { roleColor, type VizTheme } from "./palette.ts";
@@ -18,6 +18,19 @@ export const RAIL_MAX_LANES = 4;
18
18
  export const RAIL_MAX_LANES_NARROW = 3;
19
19
  export const RAIL_NARROW_COLS = 80;
20
20
  export const RAIL_WINDOW_MS = 6 * 60 * 1000;
21
+ /**
22
+ * Floor for the time axis. The right-hand column is measured from what the
23
+ * lanes actually need and the axis takes whatever is left, but never less
24
+ * than this — otherwise a long detail would push the graph off the line.
25
+ */
26
+ export const RAIL_AXIS_MIN_COLS = 10;
27
+ /** Widest a lane name column may grow (it is content-driven, not reserved). */
28
+ export const RAIL_NAME_MAX_COLS = 16;
29
+ /**
30
+ * Narrowest the lane-name column may shrink. Short names would otherwise
31
+ * collapse the column to 1 and leave the label cramped against the ▶ marker.
32
+ */
33
+ export const RAIL_NAME_MIN_COLS = 6;
21
34
 
22
35
  /**
23
36
  * Map a timestamp onto the shared railway axis.
@@ -39,7 +52,7 @@ export interface RailInput {
39
52
  nodes: readonly VizNode[];
40
53
  now: number;
41
54
  width: number;
42
- /** Header "now" tok/s (live agents only; 0 when none running). */
55
+ /** Header tok/s (live agents only; 0 when none running). */
43
56
  rate: number;
44
57
  windowMs?: number;
45
58
  /** Lane scroll position when more agents are live than lanes fit. */
@@ -141,6 +154,128 @@ export function formatRate(rate: number): string {
141
154
  return `${formatCount(rate)} tok/s`;
142
155
  }
143
156
 
157
+
158
+ /**
159
+ * Wide-lane detail after the name: role, turn/lifetime clocks, and
160
+ * last-activity age.
161
+ *
162
+ * The leading clock is "time since the last task started": it keeps ticking
163
+ * through the quiet gap after a turn settles (the old behavior froze at that
164
+ * turn's end) and stops when the agent itself ends.
165
+ *
166
+ * Clocks show from round 1. #159 gated them behind `turns >= 2`, but a child
167
+ * is usually dispatched with a single user message, so in practice that gate
168
+ * hid the clocks almost always and left the lane with a bare `role · 1s ago`
169
+ * (~13 cols) — the axis then absorbed the rest of the line. A round-1 agent
170
+ * has a real start time and a real lifetime; show both.
171
+ */
172
+ export function formatRailLaneDetail(node: VizNode, now: number): string {
173
+ const parts: string[] = [node.role];
174
+ parts.push(formatAgentElapsed({
175
+ state: node.status,
176
+ startedAt: node.startedAt,
177
+ ...(node.endedAt !== undefined ? { finishedAt: node.endedAt } : {}),
178
+ ...(node.currentTurnStartedAt !== undefined ? { currentTurnStartedAt: node.currentTurnStartedAt } : {}),
179
+ }, now));
180
+ parts.push(formatActivityAgo(node.lastActivityAt, now));
181
+ return parts.join(" · ");
182
+ }
183
+
184
+ /** Lane detail candidates, richest first (role → clocks → ago). */
185
+ export function laneDetailCandidates(node: VizNode, now: number): string[] {
186
+ const ago = formatActivityAgo(node.lastActivityAt, now);
187
+ const clocks = formatAgentElapsed({
188
+ state: node.status,
189
+ startedAt: node.startedAt,
190
+ ...(node.endedAt !== undefined ? { finishedAt: node.endedAt } : {}),
191
+ ...(node.currentTurnStartedAt !== undefined ? { currentTurnStartedAt: node.currentTurnStartedAt } : {}),
192
+ }, now);
193
+ return [
194
+ `${node.role} · ${clocks} · ${ago}`,
195
+ `${clocks} · ${ago}`,
196
+ clocks,
197
+ ago,
198
+ ];
199
+ }
200
+
201
+ /**
202
+ * Width of the lane-name column: as wide as the longest name, clamped to
203
+ * `RAIL_NAME_MAX_COLS` and never below `RAIL_NAME_MIN_COLS`.
204
+ *
205
+ * Planning and painting must agree on this, otherwise the axis is sized for
206
+ * one width while the row is drawn at another and the tail gets clipped.
207
+ */
208
+ export function railNameWidth(shown: readonly VizNode[]): number {
209
+ const longest = shown.length ? Math.max(...shown.map((n) => Array.from(n.name).length)) : 0;
210
+ return Math.max(RAIL_NAME_MIN_COLS, Math.min(RAIL_NAME_MAX_COLS, longest));
211
+ }
212
+
213
+ /**
214
+ * Pick the richest lane detail that fits `room` (role → clocks → ago).
215
+ *
216
+ * Callers pass the room shared by every lane, so a field that does not fit
217
+ * one lane is dropped from all of them instead of stripping the role from a
218
+ * single long-named row.
219
+ */
220
+ export function fitRailLaneDetail(node: VizNode, now: number, room: number): string {
221
+ if (room <= 0) return "";
222
+ const candidates = laneDetailCandidates(node, now);
223
+ for (const candidate of candidates) {
224
+ if (Array.from(candidate).length <= room) return candidate;
225
+ }
226
+ return ellipsize(candidates[candidates.length - 1]!, room);
227
+ }
228
+
229
+ export interface RailColumnPlan {
230
+ /** Width reserved for the lane name. */
231
+ nameW: number;
232
+ /** Index into each lane's candidate list (0 = richest). */
233
+ grade: number;
234
+ /** Columns the right-hand block needs, including the ▶ marker. */
235
+ needed: number;
236
+ }
237
+
238
+ /**
239
+ * Measure the right-hand column from the lanes themselves.
240
+ *
241
+ * The name/detail block is content-driven: the axis takes whatever the
242
+ * terminal has left, down to `RAIL_AXIS_MIN_COLS`. Short labels hand the slack
243
+ * back to the graph; long ones shrink it — so a row always fills the line
244
+ * instead of trailing blank space after the detail text.
245
+ */
246
+ export function railColumnPlan(
247
+ shown: readonly VizNode[],
248
+ now: number,
249
+ portrait: boolean,
250
+ width: number,
251
+ reserve = 0,
252
+ ): RailColumnPlan {
253
+ if (shown.length === 0) return { nameW: 0, grade: 0, needed: 0 };
254
+ const nameW = railNameWidth(shown);
255
+ if (portrait) {
256
+ const label = Math.max(...shown.map((n) => {
257
+ const ago = formatActivityAgo(n.lastActivityAt, now);
258
+ return Array.from(ago === "—" ? n.name : `${n.name} · ${ago}`).length;
259
+ }));
260
+ return { nameW, grade: 0, needed: label + 2 + reserve };
261
+ }
262
+ const candidates = shown.map((n) => laneDetailCandidates(n, now));
263
+ const grades = Array.from(
264
+ { length: Math.max(...candidates.map((c) => c.length)) },
265
+ (_, g) => Math.max(...candidates.map((c) => Array.from(c[Math.min(g, c.length - 1)]!).length)),
266
+ );
267
+ // Richest grade that still leaves the axis its floor.
268
+ const budget = Math.max(0, width - 1 - reserve - RAIL_AXIS_MIN_COLS - nameW - 3);
269
+ let grade = grades.length - 1;
270
+ for (let g = 0; g < grades.length; g++) {
271
+ if (grades[g]! <= budget) {
272
+ grade = g;
273
+ break;
274
+ }
275
+ }
276
+ return { nameW, grade, needed: nameW + grades[grade]! + 3 + reserve };
277
+ }
278
+
144
279
  export function renderRailway(input: RailInput): RailRender | undefined {
145
280
  const width = Math.max(0, Math.floor(input.width));
146
281
  if (width < 20) return undefined;
@@ -151,9 +286,14 @@ export function renderRailway(input: RailInput): RailRender | undefined {
151
286
  if (rows === 0) return undefined;
152
287
  const portrait = width < RAIL_NARROW_COLS;
153
288
  const windowMs = input.windowMs ?? RAIL_WINDOW_MS;
154
- const nameCol = portrait ? Math.max(8, Math.min(16, Math.floor(width * 0.32))) : Math.min(38, Math.max(24, Math.floor(width * 0.3)));
289
+ const live = current.total;
290
+ const above = current.offset > 0;
291
+ const below = current.offset + rows < live;
292
+ // Column widths are measured before the canvas exists: the axis gets the
293
+ // slack, the lanes get what they need.
294
+ const plan = railColumnPlan(current.shown, now, portrait, width, above || below ? 2 : 0);
155
295
  const LX = 1;
156
- const AW = Math.max(4, width - LX - nameCol - 2);
296
+ const AW = Math.max(RAIL_AXIS_MIN_COLS, Math.min(width - LX - 1, width - LX - plan.needed));
157
297
  const canvas = new Canvas(width, rows + 1);
158
298
  const hit: Array<Array<string | undefined>> = Array.from({ length: rows + 1 }, () => new Array<string | undefined>(width).fill(undefined));
159
299
  const byId = new Map(nodes.map((node) => [node.id, node]));
@@ -227,9 +367,6 @@ export function renderRailway(input: RailInput): RailRender | undefined {
227
367
  hit[y0 + r]![LX + c] = cell.owner;
228
368
  }
229
369
 
230
- const live = current.total;
231
- const above = current.offset > 0;
232
- const below = current.offset + rows < live;
233
370
  const rateText = formatRate(input.rate);
234
371
  const winLabel = ` −${Math.max(1, Math.round(windowMs / 60000))}m `;
235
372
  canvas.text(LX + 1, 0, winLabel, "dim");
@@ -285,22 +422,18 @@ export function renderRailway(input: RailInput): RailRender | undefined {
285
422
  const overflow = i === 0 && above ? " ▲" : i === rows - 1 && below ? " ▼" : "";
286
423
  const avail = width - (nameX + 2) - overflow.length;
287
424
  if (portrait) {
288
- canvas.text(nameX + 2, y, ellipsize(node.name, avail), col, { bold: true });
425
+ const ago = formatActivityAgo(node.lastActivityAt, now);
426
+ const label = ago === "—" ? node.name : `${node.name} · ${ago}`;
427
+ canvas.text(nameX + 2, y, ellipsize(label, avail), col, { bold: true });
289
428
  } else {
290
- const longest = Math.max(...current.shown.map((n) => Array.from(n.name).length));
291
- const nameW = Math.min(16, longest, Math.max(6, avail - 8));
429
+ // Paint with the same width the plan measured (clamped to what is
430
+ // actually available): mismatched widths sized the axis for one layout
431
+ // and drew another, clipping the tail of the detail text.
432
+ const nameW = Math.min(plan.nameW, Math.max(1, avail));
292
433
  canvas.text(nameX + 2, y, ellipsize(node.name, nameW), col, { bold: true });
293
- const room = Math.max(0, avail - nameW - 1);
294
- const elapsed = formatAgentElapsed({
295
- state: node.status,
296
- startedAt: node.startedAt,
297
- ...(node.endedAt !== undefined ? { finishedAt: node.endedAt } : {}),
298
- ...(node.currentTurnStartedAt !== undefined ? { currentTurnStartedAt: node.currentTurnStartedAt } : {}),
299
- ...(node.currentTurnEndedAt !== undefined ? { currentTurnEndedAt: node.currentTurnEndedAt } : {}),
300
- }, now);
301
- const full = `${node.role} · ${elapsed}`;
302
- const detail = ellipsize(Array.from(full).length <= room ? full : elapsed, room);
303
- canvas.text(nameX + 3 + nameW, y, detail, "dim");
434
+ const candidates = laneDetailCandidates(node, now);
435
+ const detail = candidates[Math.min(plan.grade, candidates.length - 1)]!;
436
+ canvas.text(nameX + 3 + nameW, y, detail, "dim", { maxX: width - overflow.length });
304
437
  }
305
438
  if (overflow) canvas.text(width - overflow.length, y, overflow, "text", { bold: true });
306
439
  });
@@ -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";
@@ -172,7 +173,7 @@ export class RateSampler {
172
173
 
173
174
  /**
174
175
  * Sum of node rates. When `onlyNodeIds` is set (including empty), only those
175
- * 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
176
177
  * so settled agents still paint their trailing-window history.
177
178
  */
178
179
  totalRate(now: number, windowMs = RATE_WINDOW_MS, onlyNodeIds?: Iterable<string>): number {
@@ -185,7 +186,7 @@ export class RateSampler {
185
186
  return sum;
186
187
  }
187
188
 
188
- /** Header / snap "now" tok/s: live (running) agents only; none live → 0. */
189
+ /** Header / snap tok/s: live (running) agents only; none live → 0. */
189
190
  nowRate(now: number, nodes: readonly VizNode[], windowMs = RATE_WINDOW_MS): number {
190
191
  return this.totalRate(now, windowMs, liveRateNodeIds(nodes));
191
192
  }
@@ -239,7 +240,7 @@ export class RateSampler {
239
240
  }
240
241
  }
241
242
 
242
- /** 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). */
243
244
  export function liveRateNodeIds(nodes: readonly VizNode[]): string[] {
244
245
  return nodes.filter((n) => n.id !== "root" && n.status === "running").map((n) => n.id);
245
246
  }