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
@@ -14,6 +14,8 @@ export interface LegionLayout {
14
14
  header: Rect;
15
15
  galaxy: Rect;
16
16
  chart: Rect;
17
+ /** Subagent wall/gen/other table (#151). */
18
+ timing: Rect;
17
19
  log: Rect;
18
20
  footer: Rect;
19
21
  }
@@ -28,6 +30,11 @@ export const PORTRAIT_CHART_MAX = 18;
28
30
  /** Side-by-side layouts never stretch panels beyond this many rows. */
29
31
  export const STACKED_TOP_MAX = 24;
30
32
  export const WIDE_CHART_MAX = 24;
33
+ /** Compact timing band in portrait / stacked-strip. */
34
+ export const TIMING_BAND_MIN = 5;
35
+ export const TIMING_BAND_MAX = 10;
36
+ /** Below this bottom height, stacked prefers a timing strip over side-by-side. */
37
+ export const STACKED_TIMING_SIDE_MIN = 8;
31
38
 
32
39
  /** Terminal cells are ~2:1 (tall), so rows*2 >= cols means the window looks taller than wide. */
33
40
  export function isTall(cols: number, rows: number): boolean {
@@ -60,35 +67,65 @@ export function computeLayout(cols: number, rows: number, hints: LayoutHints = {
60
67
  // Chart is capped; on very tall wide terminals the galaxy absorbs the rest.
61
68
  const chartH = Math.min(WIDE_CHART_MAX, Math.max(3, Math.round(bodyH * 0.45)));
62
69
  const galaxyH = bodyH - chartH;
70
+ const rightW = W - leftW;
71
+ // Right column: timing on top, log below (#151).
72
+ const timingH = Math.min(
73
+ Math.max(TIMING_BAND_MIN, Math.round(bodyH * 0.42)),
74
+ Math.max(TIMING_BAND_MIN, bodyH - 6),
75
+ );
63
76
  return {
64
77
  kind,
65
78
  header,
66
79
  footer,
67
80
  galaxy: { x: 0, y: bodyY, w: leftW, h: galaxyH },
68
81
  chart: { x: 0, y: bodyY + galaxyH, w: leftW, h: chartH },
69
- log: { x: leftW, y: bodyY, w: W - leftW, h: bodyH },
82
+ timing: { x: leftW, y: bodyY, w: rightW, h: timingH },
83
+ log: { x: leftW, y: bodyY + timingH, w: rightW, h: Math.max(0, bodyH - timingH) },
70
84
  };
71
85
  }
72
86
  if (kind === "stacked") {
73
87
  const topH = Math.min(STACKED_TOP_MAX, Math.max(3, Math.round(bodyH * 0.58)));
74
88
  const galaxyW = Math.floor(W * 0.5);
89
+ const bottomH = bodyH - topH;
90
+ const bottomY = bodyY + topH;
91
+ // Prefer timing|log side-by-side; strip when the bottom band is too short (#151).
92
+ if (bottomH >= STACKED_TIMING_SIDE_MIN) {
93
+ const timingW = Math.floor(W * 0.45);
94
+ return {
95
+ kind,
96
+ header,
97
+ footer,
98
+ galaxy: { x: 0, y: bodyY, w: galaxyW, h: topH },
99
+ chart: { x: galaxyW, y: bodyY, w: W - galaxyW, h: topH },
100
+ timing: { x: 0, y: bottomY, w: timingW, h: bottomH },
101
+ log: { x: timingW, y: bottomY, w: W - timingW, h: bottomH },
102
+ };
103
+ }
104
+ const timingH = Math.min(TIMING_BAND_MAX, Math.max(TIMING_BAND_MIN, Math.round(bottomH * 0.4)), Math.max(3, bottomH - 3));
75
105
  return {
76
106
  kind,
77
107
  header,
78
108
  footer,
79
109
  galaxy: { x: 0, y: bodyY, w: galaxyW, h: topH },
80
110
  chart: { x: galaxyW, y: bodyY, w: W - galaxyW, h: topH },
81
- log: { x: 0, y: bodyY + topH, w: W, h: bodyH - topH },
111
+ timing: { x: 0, y: bottomY, w: W, h: timingH },
112
+ log: { x: 0, y: bottomY + timingH, w: W, h: Math.max(0, bottomH - timingH) },
82
113
  };
83
114
  }
84
- // Portrait: galaxy (aspect-limited), chart (fixed band), log gets the rest.
115
+ // Portrait: galaxy (aspect-limited), chart (fixed band), timing (compact), log gets the rest.
85
116
  const chartH = Math.min(PORTRAIT_CHART_MAX, Math.max(PORTRAIT_CHART_MIN, Math.round(bodyH * 0.3)), Math.max(3, bodyH - 9));
86
117
  // A visually round galaxy is ~W/2 rows tall (cells are 2:1); allow it up to ~W*0.6.
87
118
  const galaxyCap = Math.max(8, Math.round(W * 0.5));
88
119
  const galaxyGrowCap = Math.max(galaxyCap, Math.round(W * 0.65));
89
120
  let galaxyH = Math.max(3, Math.min(galaxyCap, Math.round(bodyH * 0.4), bodyH - chartH - 6));
90
- let logH = bodyH - galaxyH - chartH;
91
- // Few events: give some of the empty log area back to the galaxy.
121
+ // Reserve a compact timing band between chart and log (#151).
122
+ let timingH = Math.min(
123
+ TIMING_BAND_MAX,
124
+ Math.max(TIMING_BAND_MIN, Math.round(bodyH * 0.18)),
125
+ Math.max(3, bodyH - galaxyH - chartH - 4),
126
+ );
127
+ let logH = bodyH - galaxyH - chartH - timingH;
128
+ // Few events: give some of the empty log area back to the galaxy (keep timing).
92
129
  if (hints.logEvents !== undefined) {
93
130
  const logNeeded = Math.max(8, hints.logEvents + 4);
94
131
  const spare = logH - logNeeded;
@@ -100,12 +137,19 @@ export function computeLayout(cols: number, rows: number, hints: LayoutHints = {
100
137
  }
101
138
  }
102
139
  }
140
+ // If body is too short, shrink timing before wiping log.
141
+ if (logH < 3 && timingH > 3) {
142
+ const give = Math.min(timingH - 3, 3 - logH);
143
+ timingH -= give;
144
+ logH += give;
145
+ }
103
146
  return {
104
147
  kind,
105
148
  header,
106
149
  footer,
107
150
  galaxy: { x: 0, y: bodyY, w: W, h: galaxyH },
108
151
  chart: { x: 0, y: bodyY + galaxyH, w: W, h: chartH },
109
- log: { x: 0, y: bodyY + galaxyH + chartH, w: W, h: Math.max(0, logH) },
152
+ timing: { x: 0, y: bodyY + galaxyH + chartH, w: W, h: Math.max(0, timingH) },
153
+ log: { x: 0, y: bodyY + galaxyH + chartH + Math.max(0, timingH), w: W, h: Math.max(0, logH) },
110
154
  };
111
155
  }
@@ -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.
@@ -5,14 +5,15 @@ import type { Rect } from "./layout.ts";
5
5
  import { formatCount, formatDuration, isAgent, type VizNode } from "./model.ts";
6
6
  import { nodeToken, roleColor } from "./palette.ts";
7
7
  import type { RateSampler } from "./rate.ts";
8
+ import { breakdown } from "../../shared/timing-breakdown.ts";
8
9
 
9
- export type Pane = "galaxy" | "chart" | "log";
10
+ export type Pane = "galaxy" | "chart" | "timing" | "log";
10
11
 
11
12
  export interface ViewData {
12
13
  nodes: readonly VizNode[];
13
14
  sampler: RateSampler;
14
15
  now: number;
15
- /** 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. */
16
17
  rate: number;
17
18
  }
18
19
 
@@ -279,10 +280,16 @@ export interface ChartStats {
279
280
  byRole: Array<{ role: string; tokens: number }>;
280
281
  }
281
282
 
283
+ /**
284
+ * Chart x-domain. `to` advances only on substantive RateSampler changes
285
+ * (tokensOut / generationMs); wall-clock `now` alone does not scroll the axis.
286
+ * `from` stays relative to that frozen `to` (earliest start, or to−60s).
287
+ */
282
288
  export function chartWindow(data: ViewData): { from: number; to: number } {
283
289
  const starts = data.nodes.filter(isAgent).map((node) => node.startedAt);
284
- const from = Math.min(data.sampler.startedAt ?? data.now, ...(starts.length ? starts : [data.now]));
285
- return { from: Math.min(from, data.now - 60_000), to: data.now };
290
+ const earliest = Math.min(data.sampler.startedAt ?? data.now, ...(starts.length ? starts : [data.now]));
291
+ const to = data.sampler.lastChangeAt ?? data.sampler.startedAt ?? data.now;
292
+ return { from: Math.min(earliest, to - 60_000), to };
286
293
  }
287
294
 
288
295
  export function computeStats(data: ViewData, buckets: number): ChartStats {
@@ -296,7 +303,7 @@ export function computeStats(data: ViewData, buckets: number): ChartStats {
296
303
  peakIndex = i;
297
304
  }
298
305
  });
299
- // "now" is the shared header definition, never the last bucket.
306
+ // The header tok/s is the shared definition, never the last bucket.
300
307
  peak = Math.max(peak, data.rate);
301
308
  const peakAt = peakIndex >= 0 && peak > data.rate ? series.times[peakIndex]! : to;
302
309
  const roleTotals = new Map<string, number>();
@@ -308,7 +315,7 @@ export function computeStats(data: ViewData, buckets: number): ChartStats {
308
315
  }
309
316
  return {
310
317
  now: data.rate,
311
- // 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.
312
319
  avg: data.sampler.totalRate(to, Math.max(1, to - from)),
313
320
  peak,
314
321
  peakAt,
@@ -326,7 +333,7 @@ export function drawChart(cv: Canvas, r: Rect, data: ViewData, selected: VizNode
326
333
  const statsBelow = statsW === 0 ? Math.min(selected ? 4 : 3, Math.max(0, r.h - 6)) : 0;
327
334
  const axisW = 6;
328
335
  const plotX = r.x + axisW;
329
- // 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.
330
337
  const plotW = Math.max(4, r.w - axisW - statsW - (statsW ? 9 : 0) - 1);
331
338
  const plotTop = r.y + 1;
332
339
  const plotH = Math.max(1, r.h - 2 - statsBelow);
@@ -387,14 +394,13 @@ export function drawChart(cv: Canvas, r: Rect, data: ViewData, selected: VizNode
387
394
  if (stats.peak > 0) yLabel(max, plotTop);
388
395
  if (plotH >= 5 && max >= 20) yLabel(max / 2, axisY - 1 - Math.floor(plotH / 2));
389
396
  yLabel(0, axisY - 1);
390
- // X axis with "now" at the right edge.
397
+ // X axis: elapsed time since the window start; the right edge is the present.
391
398
  cv.text(plotX - 1, axisY, "└" + "─".repeat(plotW), "borderMuted", { maxX: plotX + plotW });
392
399
  cv.text(plotX, axisY, "0m", "dim");
393
400
  const mid = formatDuration((to - from) / 2);
394
401
  if (plotW > 24) cv.text(plotX + Math.floor(plotW / 2) - Math.floor(mid.length / 2), axisY, mid, "dim");
395
- cv.text(plotX + plotW - 3, axisY, "now", "text", { bold: true });
396
402
  const nowLabel = `◂ ${formatCount(stats.now)}`;
397
- 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) });
398
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 });
399
405
 
400
406
  const selLines = (): Array<[string, string]> => {
@@ -403,14 +409,14 @@ export function drawChart(cv: Canvas, r: Rect, data: ViewData, selected: VizNode
403
409
  const share = stats.total > 0 ? (selected.tokens / stats.total) * 100 : 0;
404
410
  return [
405
411
  [`▸ ${selected.name}`, roleColor(selected.role)],
406
- [`${formatCount(rate)} tok/s now · ${formatCount(selected.tokens)} tok`, "text"],
412
+ [`${formatCount(rate)} tok/s · ${formatCount(selected.tokens)} tok`, "text"],
407
413
  [`${share.toFixed(1)}% of run · ━ bright line`, "dim"],
408
414
  ];
409
415
  };
410
416
  if (statsW) {
411
417
  const sx = r.x + r.w - statsW;
412
418
  const rows: Array<[string, string, string]> = [
413
- ["now", `${formatCount(stats.now)} tok/s`, "text"],
419
+ ["rate", `${formatCount(stats.now)} tok/s`, "text"],
414
420
  ["avg", `${formatCount(stats.avg)} tok/s`, "text"],
415
421
  ["peak", stats.peak > 0 ? `${formatCount(stats.peak)} @ ${formatDuration(stats.peakAt - from)}` : "—", "text"],
416
422
  ["total", `${formatCount(stats.total)} tok`, "text"],
@@ -536,3 +542,149 @@ export function drawLog(
536
542
  for (let i = 0; i < rows; i++) cv.set(r.x + r.w - 1, r.y + 1 + i, i >= pos && i < pos + thumb ? "┃" : "│", i >= pos && i < pos + thumb ? "scrollbarThumb" : "scrollbarTrack");
537
543
  }
538
544
  }
545
+
546
+ // ---------------------------------------------------------------- timing (#151)
547
+ // TUI scope = current view VizNodes; CLI `/legion timing` = cwd RunStore children.
548
+ // Formula matches #150 (wall / gen / other); the node set can differ.
549
+
550
+ export interface TimingRow {
551
+ node: VizNode;
552
+ wallMs: number;
553
+ generationMs: number | null;
554
+ otherMs: number | null;
555
+ liveWall: boolean;
556
+ }
557
+
558
+ /**
559
+ * Build timing rows from viz nodes (agents only).
560
+ * VizNode has no turn count: generationMs === 0 → gen/other shown as — (no turn data).
561
+ */
562
+ export function timingRowsFromNodes(nodes: readonly VizNode[], now: number): TimingRow[] {
563
+ const rows: TimingRow[] = [];
564
+ for (const node of nodes) {
565
+ if (!isAgent(node)) continue;
566
+ const end = node.endedAt ?? now;
567
+ const wallMs = Math.max(0, end - node.startedAt);
568
+ const liveWall = node.status === "running" && node.endedAt === undefined;
569
+ // No turn data on VizNode → treat 0 generationMs as absent (align "无 turn → —").
570
+ const generationMs = node.generationMs > 0 ? node.generationMs : null;
571
+ const parts = breakdown(wallMs, generationMs);
572
+ rows.push({
573
+ node,
574
+ wallMs: parts.wall_ms,
575
+ generationMs: parts.generation_ms,
576
+ otherMs: parts.other_ms,
577
+ liveWall,
578
+ });
579
+ }
580
+ rows.sort((a, b) => a.node.startedAt - b.node.startedAt || a.node.name.localeCompare(b.node.name));
581
+ return rows;
582
+ }
583
+
584
+ function formatTimingCell(ms: number | null, opts: { live?: boolean } = {}): string {
585
+ if (ms === null) return "—";
586
+ const rendered = formatDuration(ms);
587
+ return opts.live ? `${rendered}+` : rendered;
588
+ }
589
+
590
+ function roleAbbrev(role: string): string {
591
+ const t = role.trim();
592
+ if (!t) return "";
593
+ if (t.length <= 4) return t;
594
+ return t.slice(0, 3);
595
+ }
596
+
597
+ export interface TimingDrawState {
598
+ offset: number;
599
+ cursor: number;
600
+ focused: boolean;
601
+ selectedId?: string;
602
+ }
603
+
604
+ export function drawTiming(
605
+ cv: Canvas,
606
+ r: Rect,
607
+ rows: readonly TimingRow[],
608
+ state: TimingDrawState,
609
+ ): void {
610
+ if (r.w < 8 || r.h < 2) return;
611
+ const showExtra = r.w >= 36;
612
+ const showRole = r.w >= 28;
613
+ const header = showExtra
614
+ ? "name" + (showRole ? " · role" : "") + " wall gen other"
615
+ : "name" + (showRole ? " · role" : "") + " wall";
616
+ cv.text(r.x, r.y, ellipsize(header, r.w), "dim");
617
+
618
+ const footerH = rows.length > 0 && r.h >= 4 ? 1 : 0;
619
+ const rowsAvail = Math.max(0, r.h - 1 - footerH);
620
+ for (let i = 0; i < rowsAvail; i++) {
621
+ const index = state.offset + i;
622
+ const row = rows[index];
623
+ if (!row) break;
624
+ const y = r.y + 1 + i;
625
+ const isCursor = state.focused && index === state.cursor;
626
+ const highlight = state.selectedId === row.node.id;
627
+ const dim = state.selectedId !== undefined && !highlight;
628
+
629
+ const wall = formatTimingCell(row.wallMs, { live: row.liveWall });
630
+ const gen = formatTimingCell(row.generationMs);
631
+ const other = formatTimingCell(row.otherMs);
632
+
633
+ const wallW = showExtra ? 8 : Math.max(6, Math.min(10, r.w - 12));
634
+ const genW = 7;
635
+ const otherW = 7;
636
+ const metaW = showExtra ? wallW + 1 + genW + 1 + otherW : wallW;
637
+ const nameBudget = Math.max(4, r.w - metaW - (showRole ? 5 : 1));
638
+
639
+ let name = ellipsize(row.node.name, nameBudget);
640
+ if (showRole) {
641
+ const abbr = roleAbbrev(row.node.role);
642
+ const room = nameBudget - cellWidth(name) - 1;
643
+ if (abbr && room >= cellWidth(abbr)) {
644
+ name = `${name} ${abbr}`;
645
+ }
646
+ }
647
+
648
+ cv.text(r.x, y, ellipsize(name, nameBudget), dim ? "dim" : roleColor(row.node.role), {
649
+ bold: !dim,
650
+ inverse: isCursor,
651
+ });
652
+
653
+ const metaX = r.x + r.w - metaW;
654
+ if (showExtra) {
655
+ cv.text(metaX, y, wall.padStart(wallW), highlight ? "text" : "dim", { maxX: r.x + r.w });
656
+ cv.text(metaX + wallW + 1, y, gen.padStart(genW), "dim", { maxX: r.x + r.w });
657
+ cv.text(metaX + wallW + 1 + genW + 1, y, other.padStart(otherW), "dim", { maxX: r.x + r.w });
658
+ } else {
659
+ cv.text(metaX, y, wall.padStart(wallW), highlight ? "text" : "dim", { maxX: r.x + r.w });
660
+ }
661
+ if (isCursor && highlight) cv.set(r.x + r.w - 1, y, "◂", "accent", { bold: true });
662
+ }
663
+
664
+ if (footerH && rows.length > 0) {
665
+ const started = rows.map((row) => row.node.startedAt);
666
+ const ended = rows.map((row) => (row.liveWall ? null : row.node.endedAt ?? row.node.startedAt + row.wallMs));
667
+ const ends = ended.filter((v): v is number => v !== null);
668
+ const span =
669
+ started.length && ends.length
670
+ ? Math.max(0, Math.max(...ends) - Math.min(...started))
671
+ : null;
672
+ const sumWall = rows.reduce((sum, row) => sum + row.wallMs, 0);
673
+ const fy = r.y + r.h - 1;
674
+ const spanText = span === null ? "—" : formatDuration(span);
675
+ cv.text(r.x, fy, ellipsize(`span ${spanText} · sum ${formatDuration(sumWall)}`, r.w), "dim");
676
+ }
677
+
678
+ if (rows.length > rowsAvail && rowsAvail > 0) {
679
+ const thumb = Math.max(1, Math.round((rowsAvail / rows.length) * rowsAvail));
680
+ const pos = Math.round((state.offset / Math.max(1, rows.length - rowsAvail)) * (rowsAvail - thumb));
681
+ for (let i = 0; i < rowsAvail; i++) {
682
+ cv.set(
683
+ r.x + r.w - 1,
684
+ r.y + 1 + i,
685
+ i >= pos && i < pos + thumb ? "┃" : "│",
686
+ i >= pos && i < pos + thumb ? "scrollbarThumb" : "scrollbarTrack",
687
+ );
688
+ }
689
+ }
690
+ }
@@ -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
  });