pi-legion 3.0.3 → 4.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 (67) hide show
  1. package/CHANGELOG.md +263 -2
  2. package/CONTRIBUTING.md +4 -17
  3. package/README.md +29 -22
  4. package/agents/worker.md +1 -1
  5. package/docs/contracts/display-liveness-v1.md +373 -0
  6. package/docs/contracts/drop-non-pi-kinds-v4.md +198 -0
  7. package/docs/contracts/esc-abort-v1.md +4 -4
  8. package/docs/contracts/esc-no-retire-v3.md +2 -2
  9. package/docs/contracts/launch-admission-queue-v1.md +839 -0
  10. package/docs/contracts/status-panel-v1.md +174 -0
  11. package/docs/inprocess-supervisor.md +37 -4
  12. package/docs/legion-v2-contract.md +18 -2
  13. package/docs/performance-2026-10-19.md +199 -0
  14. package/docs/spawn-mode-visibility-v1.md +206 -0
  15. package/docs/vexillatio-contract.md +3 -1
  16. package/index.ts +633 -84
  17. package/package.json +4 -2
  18. package/skills/pi-legion/SKILL.md +2 -3
  19. package/src/agents/agents.ts +17 -10
  20. package/src/agents/overrides.ts +0 -1
  21. package/src/agents/presets.ts +14 -40
  22. package/src/agents/settings.ts +26 -77
  23. package/src/agents/step-model.ts +7 -22
  24. package/src/api.ts +1 -35
  25. package/src/extension/event-bus.ts +80 -8
  26. package/src/extension/legion-commands.ts +43 -17
  27. package/src/extension/legion-doctor.ts +1 -1
  28. package/src/extension/legion-stop.ts +39 -13
  29. package/src/extension/legion-tools.ts +21 -4
  30. package/src/extension/liveness.ts +200 -0
  31. package/src/extension/notify.ts +6 -6
  32. package/src/extension/playbook.ts +6 -6
  33. package/src/extension/recycle.ts +6 -2
  34. package/src/extension/runtime.ts +422 -18
  35. package/src/extension/slash.ts +0 -21
  36. package/src/extension/spawn-render.ts +183 -0
  37. package/src/extension/summary.ts +1 -1
  38. package/src/legion/db.ts +165 -1
  39. package/src/legion/events.ts +81 -16
  40. package/src/legion/nodes.ts +13 -2
  41. package/src/legion/rebuild.ts +7 -1
  42. package/src/legion/usage.ts +58 -4
  43. package/src/runs/orchestrator.ts +1165 -62
  44. package/src/runs/worktree.ts +190 -21
  45. package/src/shared/ignored-kind.ts +57 -0
  46. package/src/shared/progress.ts +11 -0
  47. package/src/shared/session.ts +199 -19
  48. package/src/shared/types.ts +50 -66
  49. package/src/supervisor/admission-queue.ts +516 -0
  50. package/src/supervisor/inprocess-supervisor.ts +108 -33
  51. package/src/supervisor/proc-memory.ts +67 -0
  52. package/src/supervisor/routing-supervisor.ts +58 -1
  53. package/src/tui/live-overlay.ts +14 -0
  54. package/src/tui/session-messages-file.ts +100 -0
  55. package/src/tui/status.ts +103 -21
  56. package/src/tui/tree-view.ts +36 -3
  57. package/src/tui/viz/layout.ts +54 -3
  58. package/src/tui/viz/model.ts +67 -14
  59. package/src/tui/viz/palette.ts +11 -0
  60. package/src/tui/viz/panels.ts +222 -8
  61. package/src/tui/viz/railway.ts +28 -25
  62. package/src/tui/viz/view.ts +110 -23
  63. package/src/backends/legacy/client.ts +0 -638
  64. package/src/backends/legacy/cursor-chat.ts +0 -348
  65. package/src/backends/legacy/runner.ts +0 -101
  66. package/src/backends/legacy/types.ts +0 -138
  67. package/src/runs/kind.ts +0 -260
package/src/tui/status.ts CHANGED
@@ -16,6 +16,11 @@ import {
16
16
  visibleWidth,
17
17
  } from "@earendil-works/pi-tui";
18
18
  import { isThinkingLevel } from "../shared/types.ts";
19
+ import {
20
+ NON_TERMINAL,
21
+ TERMINAL_STATES as LIVENESS_TERMINAL_STATES,
22
+ displayStatusFromState,
23
+ } from "../extension/liveness.ts";
19
24
  import { nodesFromEntries, type VizNode } from "./viz/model.ts";
20
25
  import { RateSampler } from "./viz/rate.ts";
21
26
  import { railwayLines, type RailScroll } from "./viz/railway.ts";
@@ -66,6 +71,25 @@ export interface StatusEntry {
66
71
  nodeId?: string;
67
72
  parentId?: string | null;
68
73
  depth?: number;
74
+ /**
75
+ * Backend the child was actually spawned on (#209). Absent for legacy
76
+ * records and for tree-only rows — consumers render `unknown`, never `rpc`.
77
+ */
78
+ spawnMode?: string;
79
+ /**
80
+ * Own OS process of an `rpc` child. Never persisted (pids are host-scoped),
81
+ * so a row restored from disk has none. `inprocess` children have none by
82
+ * definition — they share this process.
83
+ */
84
+ pid?: number;
85
+ /** Prompt size of the last assistant message; absolute tokens, not a ratio. */
86
+ contextTokens?: number;
87
+ /**
88
+ * Resident memory of this child in MB. Only ever set for a child with its own
89
+ * process (D2): an `inprocess` child has no separate RSS and must render
90
+ * blank rather than a share of the host figure.
91
+ */
92
+ rss?: number;
69
93
  }
70
94
 
71
95
  /** Session-wide totals for the tree header: every launched agent (incl. aged-out), root excluded. */
@@ -107,13 +131,22 @@ export function formatElapsed(ms: number): string {
107
131
  }
108
132
 
109
133
  /**
110
- * Relative age for railway "last activity" (`3s ago` / `2m ago` / `1h ago`).
111
- * Missing timestamp → `—` (placeholder; see #159).
134
+ * Age floor for the railway "last activity" field. Below it the field is
135
+ * dropped entirely instead of reading `1s ago` on every live lane.
136
+ */
137
+ export const ACTIVITY_AGO_MIN_SECS = 60;
138
+
139
+ /**
140
+ * Relative age for railway "last activity" (`2m ago` / `1h ago`).
141
+ *
142
+ * Missing timestamp → `—` (placeholder; see #159). Anything fresher than a
143
+ * minute → `""`, meaning "say nothing": a lane that just printed activity is
144
+ * already visibly live, so `1s ago` was pure noise on every running agent.
112
145
  */
113
146
  export function formatActivityAgo(lastActivityAt: number | undefined, now: number): string {
114
147
  if (lastActivityAt === undefined || !Number.isFinite(lastActivityAt)) return "—";
115
148
  const secs = Math.max(0, Math.floor((now - lastActivityAt) / 1000));
116
- if (secs < 60) return `${secs}s ago`;
149
+ if (secs < ACTIVITY_AGO_MIN_SECS) return "";
117
150
  const mins = Math.floor(secs / 60);
118
151
  if (mins < 60) return `${mins}m ago`;
119
152
  const hours = Math.floor(mins / 60);
@@ -140,8 +173,8 @@ function formatTokensCompact(value: number): string {
140
173
  * is part of "time since the last task started". It freezes only when the
141
174
  * agent ends: a terminal entry clamps at `finishedAt` so the clock does not
142
175
  * 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).
176
+ * when the entry is alive but not doing work — an `idle`/`queued`/`awaiting`
177
+ * seat has no meaningful running clock.
145
178
  */
146
179
  function currentTurnElapsedMs(
147
180
  entry: Pick<StatusEntry, "state" | "herdrStatus" | "finishedAt" | "currentTurnStartedAt">,
@@ -151,7 +184,11 @@ function currentTurnElapsedMs(
151
184
  const start = entry.currentTurnStartedAt;
152
185
  const status = entry.herdrStatus ?? entry.state;
153
186
  if (TERMINAL_STATES.has(status)) return Math.max(0, (entry.finishedAt ?? now) - start);
154
- if (!ACTIVE_STATES.has(status)) return undefined;
187
+ // Only a *busy* entry has a live turn clock. An `idle` seat (and a `queued`
188
+ // one, and an `awaiting` centurion waiting on its squad) is visible but not
189
+ // working, so it keeps no running clock: the doc above says so, and letting
190
+ // it tick would show time accruing against nothing.
191
+ if (!BUSY_STATES.has(status)) return undefined;
155
192
  return Math.max(0, now - start);
156
193
  }
157
194
 
@@ -198,7 +235,7 @@ export function formatAgentElapsed(
198
235
 
199
236
  export function activeEntries(entries: StatusEntry[]): StatusEntry[] {
200
237
  return entries.filter((entry) =>
201
- entry.parentId !== null && entry.depth !== 0 && ACTIVE_STATES.has(entry.herdrStatus ?? entry.state),
238
+ entry.parentId !== null && entry.depth !== 0 && BUSY_STATES.has(entry.herdrStatus ?? entry.state),
202
239
  );
203
240
  }
204
241
 
@@ -279,7 +316,8 @@ function formatWorktreeBranch(branch: string): string {
279
316
 
280
317
  function headlineBits(entry: StatusEntry, now: number): string[] {
281
318
  const bits = [`${entry.name}${roleSuffix(entry)}`];
282
- if (entry.kind && entry.kind !== "pi") bits.push(entry.kind);
319
+ // `entry.kind` is deliberately not rendered: v4 has one kind (`pi`), so the
320
+ // label would repeat a constant on every row.
283
321
  const model = formatModel(entry);
284
322
  if (model) bits.push(model);
285
323
  bits.push(formatAgentElapsed(entry, now));
@@ -329,18 +367,56 @@ function widgetRow(text: string, nodeId?: string): WidgetRow {
329
367
  return nodeId === undefined ? { text } : { text, nodeId };
330
368
  }
331
369
 
332
- const RUNNING_STATES = new Set(["starting", "working", "running"]);
333
- const ACTIVE_STATES = new Set([...RUNNING_STATES, "blocked"]);
334
- const TERMINAL_STATES = new Set([
335
- "settled",
336
- "success",
337
- "completed",
338
- "failed",
339
- "aborted",
340
- "retired",
341
- "awaiting",
342
- "unknown",
343
- ]);
370
+ /**
371
+ * Terminal / active / busy / running: one derivation from the liveness
372
+ * authority (contract `docs/contracts/display-liveness-v1.md` D1-a, D5).
373
+ *
374
+ * This file used to carry its own three tables, and the terminal one disagreed
375
+ * with every other surface: it listed `awaiting` and `unknown` as terminal
376
+ * (so a centurion waiting on its squad vanished from the widget) and omitted
377
+ * `error`/`truncated`/`done` (so those kept a live clock). `TERMINAL_STATES` is
378
+ * therefore imported, never re-declared.
379
+ */
380
+ const TERMINAL_STATES: ReadonlySet<string> = LIVENESS_TERMINAL_STATES;
381
+
382
+ /**
383
+ * "This row describes something that is still alive": the liveness module's
384
+ * in-flight labels plus `awaiting`, which D1-a places on the non-terminal side
385
+ * ("alive, waiting on downstream"). Drives visibility (`filterLiveTree`,
386
+ * `isActiveEntry`) and the open-ended clocks.
387
+ *
388
+ * This stays a known-label set on purpose. D1-a's "use the complement, not a
389
+ * whitelist" rule governs `liveness()`'s ledger rule, where the question is
390
+ * *may we declare this lifecycle over?* — the safe answer for an unrecognised
391
+ * label is "no". Here the question is the opposite one, *may this row count as
392
+ * alive?*, and a permissive answer would promote the widget's own synthetic
393
+ * `waiting` label (and any legacy/foreign label) to activity: rows that must
394
+ * age out would be resurrected and the `N running` count would inflate.
395
+ */
396
+ const ACTIVE_STATES: ReadonlySet<string> = new Set([...NON_TERMINAL, "awaiting"]);
397
+
398
+ /**
399
+ * "This row is doing work": active and not merely holding a slot. An `idle`
400
+ * seat is alive and visible (D5) but has no running clock and must not keep the
401
+ * busy label lit forever; same for a `queued` seat that has not started and an
402
+ * `awaiting` centurion whose squad is the thing actually running.
403
+ *
404
+ * Kept separate from `ACTIVE_STATES` because conflating "should I show this?"
405
+ * with "is this busy?" is precisely the mistake that made `idle`/`awaiting`
406
+ * disappear instead of being shown grey.
407
+ */
408
+ const BUSY_STATES: ReadonlySet<string> = new Set(
409
+ [...ACTIVE_STATES].filter((state) => displayStatusFromState(state) === "running"),
410
+ );
411
+
412
+ /**
413
+ * Working states for the header's `N running` count, derived from the busy set
414
+ * so it can never list a state the panel considers dead. `blocked` is an
415
+ * approval gate: busy, but not generating, so it does not count as running.
416
+ */
417
+ const RUNNING_STATES: ReadonlySet<string> = new Set(
418
+ [...BUSY_STATES].filter((state) => state !== "blocked"),
419
+ );
344
420
 
345
421
  function cleanDisplayText(value: string): string {
346
422
  return stripTerminalSequences(value)
@@ -494,6 +570,12 @@ function renderTreeWidgetLines(
494
570
  // A terminal node whose subtree is still running is waiting on
495
571
  // downstream work (e.g. a centurion between turns while its squad
496
572
  // executes) — render it as "waiting" instead of a dead settled state.
573
+ //
574
+ // D1-a: a row that is *already* `awaiting` is not rewritten. `awaiting`
575
+ // means "alive, waiting on downstream", so it is active (ACTIVE_STATES)
576
+ // and this branch never sees it — which is the point: it must neither
577
+ // vanish from the panel nor be relabelled as a finished state. It renders
578
+ // grey, as itself, because `themeLines` dims every non-header row.
497
579
  const effectiveEntry = !isActiveEntry(entry) && activeSubtree
498
580
  ? { ...entry, state: "waiting" as const, herdrStatus: "waiting" as const }
499
581
  : entry;
@@ -543,7 +625,7 @@ function formatWidgetRows(entries: StatusEntry[], now: number, totals?: StatusTo
543
625
  return formatEntryLines(entries[0]!, now, { head: "", detail: "" })
544
626
  .map((text) => ({ text }));
545
627
  }
546
- const rows: WidgetRow[] = [{ text: "● Async agents · herdr" }];
628
+ const rows: WidgetRow[] = [{ text: "● Async agents" }];
547
629
  for (const [index, entry] of entries.entries()) {
548
630
  const last = index === entries.length - 1;
549
631
  rows.push(
@@ -10,14 +10,40 @@ import {
10
10
  type TUI,
11
11
  } from "@earendil-works/pi-tui";
12
12
  import type { LegionEvent } from "../extension/event-bus.ts";
13
- import type { LegionNode, NodeStatus } from "../legion/nodes.ts";
13
+ import type { LegionNode } from "../legion/nodes.ts";
14
+ import { NODE_STATUSES } from "../legion/nodes.ts";
15
+ import { isTerminalState } from "../extension/liveness.ts";
16
+ import type { SpawnMode } from "../shared/types.ts";
14
17
  import { osc8NodeLink } from "./status.ts";
15
18
 
16
19
  const TREE_HEADER = "Legion tree · j/k move · enter toggle · / search · pgup/pgdn scroll · esc/q close";
17
20
  const DEFAULT_TERMINAL_ROWS = 24;
18
21
  const MAX_ACTIVITY_TOOLS = 2;
19
- const ACTIVE_STATUSES = new Set<NodeStatus>(["starting", "running", "blocked"]);
20
- const TERMINAL_STATUSES = new Set<NodeStatus>(["settled", "failed", "retired"]);
22
+ /**
23
+ * Active/terminal partition, derived from the liveness authority instead of
24
+ * hand-written here (contract `docs/contracts/display-liveness-v1.md` D1-a,
25
+ * D6 exit 4).
26
+ *
27
+ * The gain is anti-drift rather than a bug fix: `node.status` is `NodeStatus`,
28
+ * the six-value ledger set, and for those six the old local pair happened to
29
+ * agree with `isTerminalState`. But it was a fourth independent copy of the
30
+ * vocabulary, so the next ledger status would be terminal in the panel and
31
+ * non-terminal here — exactly the divergence D6 forbids.
32
+ *
33
+ * `idle`/`queued` are display states that cannot appear here (D9 keeps them out
34
+ * of `NODE_STATUSES`). They are unioned into the active side anyway so the two
35
+ * sets stay a partition of anything a snapshot row might carry: calling a
36
+ * pending row terminal would fold its subtree and freeze its clock for work
37
+ * that has not started.
38
+ */
39
+ const ACTIVE_STATUSES: ReadonlySet<string> = new Set<string>([
40
+ ...NODE_STATUSES.filter((status) => !isTerminalState(status)),
41
+ "idle",
42
+ "queued",
43
+ ]);
44
+ const TERMINAL_STATUSES: ReadonlySet<string> = new Set<string>(
45
+ NODE_STATUSES.filter((status) => isTerminalState(status)),
46
+ );
21
47
 
22
48
  export interface TreeNodeSnapshot extends LegionNode {
23
49
  /** Snapshot from legion.db usage table (≤30s granularity). */
@@ -30,6 +56,13 @@ export interface TreeNodeSnapshot extends LegionNode {
30
56
  lastTools?: readonly string[];
31
57
  /** Unix ms start of the current turn, when known (bus events or snapshots). */
32
58
  turnStartedAt?: number;
59
+ /**
60
+ * Arbitrated backend for this child (#209), merged from the tracked job at
61
+ * snapshot time — `legion.db` has no spawn_mode column. Absent for legacy
62
+ * records and for nodes never launched in this session; readers must render
63
+ * that as unknown, never as `rpc`.
64
+ */
65
+ spawnMode?: SpawnMode;
33
66
  }
34
67
 
35
68
  export interface TreeViewTheme {
@@ -17,6 +17,13 @@ export interface LegionLayout {
17
17
  /** Subagent wall/gen/other table (#151). */
18
18
  timing: Rect;
19
19
  log: Rect;
20
+ /**
21
+ * Agent status table (role/state/supervisor/ctx/rss/turns). Zero-area in
22
+ * portrait: 46×24 already packs four panes into 19 of 21 body rows, so the
23
+ * fifth cannot fit (D1). A zero-height rect keeps `frame()` from painting and
24
+ * `availablePanes()` (view.ts) keeps Tab off it.
25
+ */
26
+ status: Rect;
20
27
  footer: Rect;
21
28
  }
22
29
 
@@ -35,6 +42,35 @@ export const TIMING_BAND_MIN = 5;
35
42
  export const TIMING_BAND_MAX = 10;
36
43
  /** Below this bottom height, stacked prefers a timing strip over side-by-side. */
37
44
  export const STACKED_TIMING_SIDE_MIN = 8;
45
+ /** Status band: enough rows for a header plus a few agents, never a tall empty box. */
46
+ export const STATUS_BAND_MIN = 5;
47
+ export const STATUS_BAND_MAX = 12;
48
+ /**
49
+ * Rows the log keeps when a status band is carved out above it. Below this the
50
+ * band is dropped entirely rather than shrinking the log to a header-only stub —
51
+ * the same trade `timing` already makes against `log` in portrait.
52
+ */
53
+ export const STATUS_LOG_RESERVE = 4;
54
+
55
+ /**
56
+ * Height of a status band carved out of `avail` rows while leaving `reserve`
57
+ * rows for whatever sits below it. Returns 0 when the band cannot reach
58
+ * STATUS_BAND_MIN — callers then hand the whole area to the neighbouring pane,
59
+ * and the zero-height rect is what makes Tab skip it.
60
+ */
61
+ function statusBandHeight(avail: number, reserve: number, share: number): number {
62
+ const cap = Math.min(STATUS_BAND_MAX, avail - reserve);
63
+ if (cap < STATUS_BAND_MIN) return 0;
64
+ return Math.min(cap, Math.max(STATUS_BAND_MIN, Math.round(avail * share)));
65
+ }
66
+
67
+ /**
68
+ * A pane that could not be placed (D1). Zero-area, so `frame()` paints nothing
69
+ * and `availablePanes()` (view.ts) keeps Tab off it.
70
+ */
71
+ function noRect(): Rect {
72
+ return { x: 0, y: 0, w: 0, h: 0 };
73
+ }
38
74
 
39
75
  /** Terminal cells are ~2:1 (tall), so rows*2 >= cols means the window looks taller than wide. */
40
76
  export function isTall(cols: number, rows: number): boolean {
@@ -73,6 +109,9 @@ export function computeLayout(cols: number, rows: number, hints: LayoutHints = {
73
109
  Math.max(TIMING_BAND_MIN, Math.round(bodyH * 0.42)),
74
110
  Math.max(TIMING_BAND_MIN, bodyH - 6),
75
111
  );
112
+ // Right column: timing, then log, then the status band closes it (D1).
113
+ const statusH = statusBandHeight(bodyH - timingH, STATUS_LOG_RESERVE, 0.25);
114
+ const logH = Math.max(0, bodyH - timingH - statusH);
76
115
  return {
77
116
  kind,
78
117
  header,
@@ -80,7 +119,8 @@ export function computeLayout(cols: number, rows: number, hints: LayoutHints = {
80
119
  galaxy: { x: 0, y: bodyY, w: leftW, h: galaxyH },
81
120
  chart: { x: 0, y: bodyY + galaxyH, w: leftW, h: chartH },
82
121
  timing: { x: leftW, y: bodyY, w: rightW, h: timingH },
83
- log: { x: leftW, y: bodyY + timingH, w: rightW, h: Math.max(0, bodyH - timingH) },
122
+ log: { x: leftW, y: bodyY + timingH, w: rightW, h: logH },
123
+ status: statusH > 0 ? { x: leftW, y: bodyY + timingH + logH, w: rightW, h: statusH } : noRect(),
84
124
  };
85
125
  }
86
126
  if (kind === "stacked") {
@@ -91,6 +131,10 @@ export function computeLayout(cols: number, rows: number, hints: LayoutHints = {
91
131
  // Prefer timing|log side-by-side; strip when the bottom band is too short (#151).
92
132
  if (bottomH >= STACKED_TIMING_SIDE_MIN) {
93
133
  const timingW = Math.floor(W * 0.45);
134
+ const rightW = W - timingW;
135
+ // Right column of the bottom band: log on top, status closing it (D1).
136
+ const statusH = statusBandHeight(bottomH, STATUS_LOG_RESERVE, 0.35);
137
+ const logH = Math.max(0, bottomH - statusH);
94
138
  return {
95
139
  kind,
96
140
  header,
@@ -98,10 +142,14 @@ export function computeLayout(cols: number, rows: number, hints: LayoutHints = {
98
142
  galaxy: { x: 0, y: bodyY, w: galaxyW, h: topH },
99
143
  chart: { x: galaxyW, y: bodyY, w: W - galaxyW, h: topH },
100
144
  timing: { x: 0, y: bottomY, w: timingW, h: bottomH },
101
- log: { x: timingW, y: bottomY, w: W - timingW, h: bottomH },
145
+ log: { x: timingW, y: bottomY, w: rightW, h: logH },
146
+ status: statusH > 0 ? { x: timingW, y: bottomY + logH, w: rightW, h: statusH } : noRect(),
102
147
  };
103
148
  }
104
149
  const timingH = Math.min(TIMING_BAND_MAX, Math.max(TIMING_BAND_MIN, Math.round(bottomH * 0.4)), Math.max(3, bottomH - 3));
150
+ const afterTiming = Math.max(0, bottomH - timingH);
151
+ const statusH = statusBandHeight(afterTiming, STATUS_LOG_RESERVE, 0.35);
152
+ const logH = Math.max(0, afterTiming - statusH);
105
153
  return {
106
154
  kind,
107
155
  header,
@@ -109,7 +157,8 @@ export function computeLayout(cols: number, rows: number, hints: LayoutHints = {
109
157
  galaxy: { x: 0, y: bodyY, w: galaxyW, h: topH },
110
158
  chart: { x: galaxyW, y: bodyY, w: W - galaxyW, h: topH },
111
159
  timing: { x: 0, y: bottomY, w: W, h: timingH },
112
- log: { x: 0, y: bottomY + timingH, w: W, h: Math.max(0, bottomH - timingH) },
160
+ log: { x: 0, y: bottomY + timingH, w: W, h: logH },
161
+ status: statusH > 0 ? { x: 0, y: bottomY + timingH + logH, w: W, h: statusH } : noRect(),
113
162
  };
114
163
  }
115
164
  // Portrait: galaxy (aspect-limited), chart (fixed band), timing (compact), log gets the rest.
@@ -151,5 +200,7 @@ export function computeLayout(cols: number, rows: number, hints: LayoutHints = {
151
200
  chart: { x: 0, y: bodyY + galaxyH, w: W, h: chartH },
152
201
  timing: { x: 0, y: bodyY + galaxyH + chartH, w: W, h: Math.max(0, timingH) },
153
202
  log: { x: 0, y: bodyY + galaxyH + chartH + Math.max(0, timingH), w: W, h: Math.max(0, logH) },
203
+ // D1: portrait has no room for a fifth pane — zero-area, so frame()/Tab skip it.
204
+ status: noRect(),
154
205
  };
155
206
  }
@@ -2,13 +2,25 @@
2
2
  * Shared data model for the Legion visualization (railway strip + /legion view).
3
3
  *
4
4
  * Subagents spawn dynamically and nest, so nothing here assumes a fixed role
5
- * list. Node statuses collapse to four display states: `blocked` is a
6
- * transient in-memory approval flag and renders as running.
5
+ * list. Node statuses collapse to the display states of contract
6
+ * `docs/contracts/display-liveness-v1.md` D5: `blocked` is a transient
7
+ * in-memory approval flag and renders as running, while `idle`/`queued` are
8
+ * first-class display states (a child can hold a concurrency slot without
9
+ * working) and an unrecognised label is `unknown` rather than the old
10
+ * `retired` fallback.
7
11
  */
8
12
 
9
13
  import type { StatusEntry } from "../status.ts";
14
+ import { SPAWN_MODES, type SpawnMode } from "../../shared/types.ts";
15
+ import { displayStatusFromState, isTerminalState, type DisplayStatus } from "../../extension/liveness.ts";
10
16
 
11
- export type VizStatus = "running" | "settled" | "failed" | "retired";
17
+ /**
18
+ * D5's display vocabulary, imported from the single authority rather than
19
+ * re-declared. The name `VizStatus` stays because every viz consumer imports
20
+ * it; the values are `liveness.ts`'s `DisplayStatus` so the panel cannot drift
21
+ * from the list/tree.
22
+ */
23
+ export type VizStatus = DisplayStatus;
12
24
 
13
25
  export interface VizNode {
14
26
  id: string;
@@ -37,19 +49,44 @@ export interface VizNode {
37
49
  turns?: number;
38
50
  /** Unix ms of last visible activity for this seat (not run wall-clock start). */
39
51
  lastActivityAt?: number;
52
+ /**
53
+ * Arbitrated backend the child was spawned on (#209). Optional: a row restored
54
+ * from disk has none, and consumers must render `unknown` — never guess `rpc`.
55
+ */
56
+ spawnMode?: SpawnMode;
57
+ /** Own OS process of an `rpc` child; `inprocess` children have none by definition. */
58
+ pid?: number;
59
+ /** Prompt size of the last assistant message; absolute tokens, not a ratio. */
60
+ contextTokens?: number;
61
+ /**
62
+ * Resident memory of this child in MB. Only ever set for a child with its own
63
+ * process (D2): an `inprocess` child has no separate RSS, so it renders `—`
64
+ * rather than a share of the host figure.
65
+ */
66
+ rss?: number;
40
67
  }
41
68
 
42
- /** Same set the footer/busy label treat as active, so live counts agree everywhere. */
43
- const RUNNING = new Set(["starting", "working", "running", "blocked"]);
44
- const SETTLED = new Set(["settled", "success", "completed", "done"]);
45
- const FAILED = new Set(["failed", "aborted", "error", "truncated"]);
69
+ /**
70
+ * `StatusEntry.spawnMode` is a plain string (it can come off disk or from an old
71
+ * caller). Only a real mode reaches `VizNode`; anything else stays absent so the
72
+ * panel prints `unknown` instead of echoing an unvalidated backend name.
73
+ */
74
+ function asSpawnMode(value: string | undefined): SpawnMode | undefined {
75
+ return value !== undefined && (SPAWN_MODES as readonly string[]).includes(value) ? (value as SpawnMode) : undefined;
76
+ }
46
77
 
78
+ /**
79
+ * D5: raw lifecycle state → display state, delegated to `liveness.ts`.
80
+ *
81
+ * The three local sets that used to live here (`RUNNING`/`SETTLED`/`FAILED`)
82
+ * plus the `return "retired"` catch-all are gone: a fourth copy of the state
83
+ * vocabulary is exactly how the strip, the tree and the rail drifted apart. In
84
+ * particular `idle`/`queued` no longer collapse into `retired`, and anything
85
+ * unrecognised (including `undefined`) is `unknown` — an undecided child must
86
+ * not be painted as finished.
87
+ */
47
88
  export function displayStatus(state: string | undefined): VizStatus {
48
- const value = (state ?? "").toLowerCase();
49
- if (SETTLED.has(value)) return "settled";
50
- if (FAILED.has(value)) return "failed";
51
- if (RUNNING.has(value)) return "running";
52
- return "retired";
89
+ return displayStatusFromState(state);
53
90
  }
54
91
 
55
92
  /** Build viz nodes from status entries (root row included when present). */
@@ -60,7 +97,19 @@ export function nodesFromEntries(entries: readonly StatusEntry[]): VizNode[] {
60
97
  const id = entry.nodeId ?? entry.name;
61
98
  if (seen.has(id)) continue;
62
99
  seen.add(id);
63
- const status = id === "root" ? "running" : displayStatus(entry.herdrStatus ?? entry.state);
100
+ const raw = entry.herdrStatus ?? entry.state;
101
+ const status = id === "root" ? "running" : displayStatus(raw);
102
+ const spawnMode = asSpawnMode(entry.spawnMode);
103
+ // `endedAt` means "this lane's lifetime is over", and the rail/log/chart
104
+ // read it as such: the railway drops the lane, `buildLog` emits an end
105
+ // event, the timing pane freezes the wall clock. Only a *terminal* status
106
+ // may claim that. `idle`/`queued` are alive (D5: slot held, not started)
107
+ // and `unknown` is undecided — stamping an end on any of them would erase a
108
+ // live child from the strip and fake an end event for it.
109
+ //
110
+ // The root row is excluded because its display status is pinned to
111
+ // `running` above: an end stamp would contradict the status it carries.
112
+ const ended = id !== "root" && isTerminalState(raw);
64
113
  out.push({
65
114
  id,
66
115
  name: entry.name,
@@ -69,7 +118,7 @@ export function nodesFromEntries(entries: readonly StatusEntry[]): VizNode[] {
69
118
  depth: entry.depth ?? (id === "root" ? 0 : 1),
70
119
  status,
71
120
  startedAt: entry.startedAt,
72
- ...(status !== "running" ? { endedAt: entry.finishedAt ?? entry.startedAt } : {}),
121
+ ...(ended ? { endedAt: entry.finishedAt ?? entry.startedAt } : {}),
73
122
  tokens: Math.max(0, entry.tokensTotal ?? 0),
74
123
  tokensOut: Math.max(0, entry.tokensOut ?? 0),
75
124
  generationMs: Math.max(0, entry.generationMs ?? 0),
@@ -77,6 +126,10 @@ export function nodesFromEntries(entries: readonly StatusEntry[]): VizNode[] {
77
126
  ...(entry.currentTurnEndedAt !== undefined ? { currentTurnEndedAt: entry.currentTurnEndedAt } : {}),
78
127
  ...(entry.turns !== undefined && entry.turns > 0 ? { turns: entry.turns } : {}),
79
128
  ...(entry.lastActivityAt !== undefined ? { lastActivityAt: entry.lastActivityAt } : {}),
129
+ ...(spawnMode ? { spawnMode } : {}),
130
+ ...(entry.pid !== undefined ? { pid: entry.pid } : {}),
131
+ ...(entry.contextTokens !== undefined ? { contextTokens: entry.contextTokens } : {}),
132
+ ...(entry.rss !== undefined ? { rss: entry.rss } : {}),
80
133
  });
81
134
  }
82
135
  // Parents that are missing from the snapshot re-attach to the nearest known ancestor.
@@ -177,9 +177,20 @@ export function resolveRolePalette(theme: VizTheme, roles: Iterable<string>): Ma
177
177
  return out;
178
178
  }
179
179
 
180
+ /**
181
+ * Colour token for a node's status glyph.
182
+ *
183
+ * D5: `idle` is *alive but not working* — it holds a concurrency slot, so it
184
+ * must stay on screen — and an undecided (`unknown`) node is not active
185
+ * either. Both render `dim`, which is exactly "present, not doing anything".
186
+ * `queued` keeps its role colour: it has been admitted and is about to run, so
187
+ * greying it out would read as "nothing to see here" for work that is pending.
188
+ */
180
189
  export function statusToken(role: string, status: VizStatus): string {
181
190
  if (status === "failed") return "error";
182
191
  if (status === "retired") return "dim";
192
+ if (status === "idle") return "dim";
193
+ if (status === "unknown") return "dim";
183
194
  return roleColor(role);
184
195
  }
185
196