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
@@ -48,9 +48,9 @@ import {
48
48
  import type { StatusVizSnapshot } from "../tui/status.ts";
49
49
  import { createLegionViewFactory } from "../tui/viz/view.ts";
50
50
  import {
51
- parseSessionMessages,
52
51
  type ChatItemSettings,
53
52
  } from "../tui/chat-items.ts";
53
+ import { readSessionMessagesFile } from "../tui/session-messages-file.ts";
54
54
  import {
55
55
  createTreeViewFactory,
56
56
  type TreeNodeSnapshot,
@@ -96,8 +96,8 @@ export interface LegionCommandDeps {
96
96
  bus: LegionEventBus;
97
97
  runtime: LegionCommandRuntime;
98
98
  supervisor: RpcEventSource;
99
- /** Graceful retire via the subagent `stop` action (see legion-stop.ts). */
100
- stop?: (params: { name?: string; all?: boolean }) => Promise<{ ok: boolean; text: string }>;
99
+ /** Retire via the subagent `stop` action (see legion-stop.ts); pass `force: true` for hard retire. */
100
+ stop?: (params: { name?: string; all?: boolean; force?: boolean }) => Promise<{ ok: boolean; text: string }>;
101
101
  /**
102
102
  * Persisted child snapshot used by `subagent({ action: "status" })`.
103
103
  * Undefined when this process has no run-store record for `name`.
@@ -255,13 +255,13 @@ function readSessionBackfill(
255
255
  sessionFile: string,
256
256
  _maxBackfillTurns: number,
257
257
  ): LiveOverlayBackfillResult | undefined {
258
- // Take the size before parsing: if the append-only file grows during the
259
- // parse, the next poll sees that newer size and can safely re-read it.
260
- const fileSize = readSessionSize(sessionFile);
261
- if (fileSize === undefined) return undefined;
258
+ // Incremental reader (#234): cold cache reads from byte 0 once; later polls
259
+ // seek past the fingerprint offset. Take size via the reader so a concurrent
260
+ // append is visible on the next tick.
262
261
  try {
263
- const messages = parseSessionMessages(readFileSync(sessionFile, "utf8"));
264
- return { messages, cursor: sessionCursor(messages, fileSize) };
262
+ const loaded = readSessionMessagesFile(sessionFile);
263
+ if (!loaded) return undefined;
264
+ return { messages: loaded.messages, cursor: sessionCursor(loaded.messages, loaded.fileSize) };
265
265
  } catch {
266
266
  return undefined;
267
267
  }
@@ -271,27 +271,29 @@ function readSessionDelta(
271
271
  sessionFile: string,
272
272
  cursor: LiveOverlayBackfillCursor,
273
273
  ): LiveOverlayBackfillResult | undefined {
274
- // The append-only JSONL file is only reparsed after its byte size changes.
274
+ // Size gate first: unchanged append-only file ⇒ no open/parse (#234).
275
275
  const fileSize = readSessionSize(sessionFile);
276
276
  if (fileSize === undefined || fileSize === cursor.fileSize) return undefined;
277
- let messages: ReturnType<typeof parseSessionMessages>;
277
+ let loaded: ReturnType<typeof readSessionMessagesFile>;
278
278
  try {
279
- messages = parseSessionMessages(readFileSync(sessionFile, "utf8"));
279
+ loaded = readSessionMessagesFile(sessionFile);
280
280
  } catch {
281
281
  return undefined;
282
282
  }
283
- const nextCursor = sessionCursor(messages, fileSize);
283
+ if (!loaded) return undefined;
284
+ const messages = loaded.messages;
285
+ const nextCursor = sessionCursor(messages, loaded.fileSize);
284
286
  if (
285
287
  nextCursor.messageCount < cursor.messageCount ||
286
288
  nextCursor.turnCount < cursor.turnCount ||
287
289
  nextCursor.assistantMessageCount < cursor.assistantMessageCount ||
288
- fileSize < cursor.fileSize
290
+ loaded.fileSize < cursor.fileSize
289
291
  ) {
290
292
  // Do not move a monotonic cursor backwards during a concurrent partial write.
291
293
  return undefined;
292
294
  }
293
- // Return the full parsed transcript; the viewer compares the message cursor
294
- // and appends only new entries. This covers assistant appends within a turn.
295
+ // Return the accumulated transcript; the viewer slices by message cursor.
296
+ // Hot path only parsed bytes past the shared session.ts fingerprint offset.
295
297
  return { messages, cursor: nextCursor };
296
298
  }
297
299
 
@@ -312,6 +314,8 @@ function liveNode(
312
314
  ...(node.turns === undefined ? {} : { turns: node.turns }),
313
315
  sessionFile: node.sessionFile,
314
316
  worktreePath: node.worktreePath,
317
+ // #209/D5: absent for legacy nodes; the footer omits the segment entirely.
318
+ ...(node.spawnMode === undefined ? {} : { spawnMode: node.spawnMode }),
315
319
  };
316
320
  }
317
321
 
@@ -355,7 +359,18 @@ function treeForLiveSource(
355
359
  deps.bus.on(type, (event) => {
356
360
  if (event.nodeId !== nodeId) return;
357
361
  if (event.id === undefined && !processEventTypes.has(event.type)) return;
358
- refresh();
362
+ // #261: `text_delta` is one streamed token. The token carries no tree state, so
363
+ // the snapshot below would only re-read what it already has — and
364
+ // `deps.runtime.treeNodes()` is a synchronous SQLite subtree scan (~0.95–1.09 ms
365
+ // p50 at 120 nodes), once per token for the whole life of the modal. Usage rows
366
+ // land via other paths, so a skipped snapshot defers their pickup to the next
367
+ // non-delta event (the backfill poll keeps the transcript current). Same
368
+ // exclusion the /legion tree view's subscription in this file and the status
369
+ // board in runtime.ts already apply.
370
+ //
371
+ // The listener below still fires: this event's only job here is the transcript,
372
+ // so do not re-add a refresh on the `text_delta` branch.
373
+ if (event.type !== "text_delta") refresh();
359
374
  listener({
360
375
  ...(event.id === undefined ? {} : { id: event.id }),
361
376
  domain: event.id === undefined ? "process" : "stored",
@@ -429,6 +444,17 @@ function openLiveOverlay(
429
444
  nodeId: string,
430
445
  onStop?: (nodeId: string) => void,
431
446
  ): void {
447
+ // #261/H1: every caller path funnels here (the view's `o`, the classic tree's
448
+ // `o`, a mouse click, `/legion <node>`, the status widget). Opening the same
449
+ // node twice used to mount a second host overlay while the first stayed
450
+ // pending: `controller.open()` closes the previous session but cannot resolve
451
+ // the previous `ui.custom()` promise, whose `done` is passed by pi into the
452
+ // factory. The orphaned `/legion` command promise never settled, and pi queues
453
+ // every later prompt behind it — the modal was there but frozen. Guarding the
454
+ // already-open node keeps that promise alive so closing it resolves normally.
455
+ // `controller.getCurrent()` is set synchronously by `open()`, so this also
456
+ // catches a double-open arriving in the same stdin chunk.
457
+ if (controller.getCurrent()?.nodeId === nodeId) return;
432
458
  // #193: the overlay must cover the floating frame only. A full-screen overlay
433
459
  // would make pi-tui discard every host cell (its composite has no transparency
434
460
  // option), so the area around the frame collapsed to the terminal's default
@@ -80,7 +80,7 @@ export const LEGION_SLASH_HELP_LINES = [
80
80
  "/legion summary [--all] [<name>]",
81
81
  "/legion timing [--json] [<name>] — wall / generation / other timing for children",
82
82
  "/legion doctor [section]",
83
- "/legion stop [name|all]",
83
+ "/legion stop [--force|-f] [name|all]",
84
84
  "/legion --tree | /legion <node>",
85
85
  "/legion help",
86
86
  ] as const;
@@ -3,6 +3,8 @@
3
3
  * - `/legion stop` (all) / `/legion stop <name>` — thin wrapper over the
4
4
  * subagent tool's `stop` action (graceful retire; a retired parent's own
5
5
  * session shutdown retires its subtree, so stopping a parent cascades).
6
+ * - `/legion stop --force` / `-f` — same targets with `force: true` (hard
7
+ * retire; see #179 / StopDeps.force).
6
8
  * - `x` on the selected agent in the /legion view → confirm → stop it.
7
9
  * Esc no longer stops children (esc-no-retire-v3); only `/legion stop` or
8
10
  * process exit retires. Everything here is pure/injectable for unit tests.
@@ -49,6 +51,18 @@ function jobForNode(node: StopNode, live: readonly StopJob[]): StopJob | undefin
49
51
  return live.find((job) => job.nodeId === node.id) ?? (node.depth <= 1 ? live.find((job) => !job.nodeId && job.name === node.name) : undefined);
50
52
  }
51
53
 
54
+ /** Strip `--force` / `-f` from `/legion stop` args; remainder is the name|all query. */
55
+ export function parseStopArg(arg: string): { query: string; force: boolean } {
56
+ const tokens = arg.trim().split(/\s+/).filter(Boolean);
57
+ let force = false;
58
+ const rest: string[] = [];
59
+ for (const token of tokens) {
60
+ if (token === "--force" || token === "-f") force = true;
61
+ else rest.push(token);
62
+ }
63
+ return { query: rest.join(" "), force };
64
+ }
65
+
52
66
  export function resolveStopTarget(arg: string, jobs: readonly StopJob[], nodes: readonly StopNode[]): StopTarget {
53
67
  const query = arg.trim();
54
68
  if (query === "" || query === "all") return { kind: "all" };
@@ -69,21 +83,32 @@ export function resolveStopTarget(arg: string, jobs: readonly StopJob[], nodes:
69
83
  return { kind: "notFound", query };
70
84
  }
71
85
 
72
- /** Completions for `/legion stop <prefix>`: `all`, live child names, then nested live agents as parent/name. */
86
+ /** Completions for `/legion stop <prefix>`: optional `--force`, `all`, live names, nested parent/name. */
73
87
  export function stopCompletions(prefix: string, jobs: readonly StopJob[], nodes: readonly StopNode[]): Array<{ value: string; label: string }> {
88
+ const { query, force } = parseStopArg(prefix);
89
+ const flagPrefix = force ? "--force " : "";
74
90
  const live = liveJobs(jobs);
75
91
  const byId = new Map(nodes.map((node) => [node.id, node]));
76
- const out: Array<{ value: string; label: string }> = [{ value: "all", label: `all (${live.length} live)` }];
77
- for (const job of live) out.push({ value: job.name, label: job.name });
92
+ const out: Array<{ value: string; label: string }> = [{ value: `${flagPrefix}all`, label: `all (${live.length} live)` }];
93
+ for (const job of live) out.push({ value: `${flagPrefix}${job.name}`, label: job.name });
78
94
  const seen = new Set(out.map((item) => item.value));
79
95
  for (const node of nodes) {
80
96
  if (node.depth <= 1 || !isLiveNode(node)) continue;
81
97
  const label = nodeLabel(node, byId);
82
- if (seen.has(label)) continue;
83
- seen.add(label);
84
- out.push({ value: label, label: `${label} (nested)` });
98
+ const value = `${flagPrefix}${label}`;
99
+ if (seen.has(value)) continue;
100
+ seen.add(value);
101
+ out.push({ value, label: `${label} (nested)` });
85
102
  }
86
- return out.filter((item) => item.value.startsWith(prefix));
103
+ const trimmed = prefix.trim();
104
+ const offerForce =
105
+ !force && (trimmed === "" || "--force".startsWith(trimmed) || trimmed === "-f" || (trimmed.startsWith("-") && "-f".startsWith(trimmed)));
106
+ const names = out.filter((item) => {
107
+ const bare = force ? item.value.slice(flagPrefix.length) : item.value;
108
+ return bare.startsWith(query);
109
+ });
110
+ if (offerForce) names.unshift({ value: "--force", label: "--force (hard retire)" });
111
+ return names;
87
112
  }
88
113
 
89
114
  export interface StopDeps {
@@ -91,16 +116,16 @@ export interface StopDeps {
91
116
  nodes(): readonly StopNode[];
92
117
  /**
93
118
  * Retire via the subagent `stop` action; resolves to its summary text.
94
- * Nova follow-up (#179): `/legion stop --force` may pass `force: true`
95
- * (hard retire). Default remains graceful.
119
+ * `/legion stop --force` passes `force: true` (hard retire). Default remains graceful.
96
120
  */
97
121
  stop(params: { name?: string; all?: boolean; force?: boolean }): Promise<{ ok: boolean; text: string }>;
98
122
  notify(message: string, type: "info" | "warning" | "error"): void;
99
123
  }
100
124
 
101
- /** `/legion stop [name|all]`. Returns the notified message (for tests). */
125
+ /** `/legion stop [--force|-f] [name|all]`. Returns the notified message (for tests). */
102
126
  export async function runLegionStop(arg: string, deps: StopDeps): Promise<string> {
103
- const target = resolveStopTarget(arg, deps.jobs(), deps.nodes());
127
+ const { query, force } = parseStopArg(arg);
128
+ const target = resolveStopTarget(query, deps.jobs(), deps.nodes());
104
129
  const say = (message: string, type: "info" | "warning" | "error" = "info"): string => {
105
130
  deps.notify(message, type);
106
131
  return message;
@@ -113,10 +138,11 @@ export async function runLegionStop(arg: string, deps: StopDeps): Promise<string
113
138
  return say(`${target.label} is owned by ${target.owner}; stop ${target.owner} to retire it with its subtree (/legion stop ${target.owner}).`, "warning");
114
139
  }
115
140
  if (target.kind === "all" && liveJobs(deps.jobs()).length === 0) return say("Nothing running: no live subagents.");
116
- const result = await deps.stop(target.kind === "all" ? { all: true } : { name: target.name });
141
+ const base = target.kind === "all" ? { all: true as const } : { name: target.name };
142
+ const result = await deps.stop(force ? { ...base, force: true } : base);
117
143
  return say(result.text, result.ok ? "info" : "error");
118
144
  }
119
145
 
120
146
  /** Dim hint shown in the status strip while children are live (aboveEditor / tree). */
121
- export const IDLE_STOP_HINT = "/legion stop [name|all]";
147
+ export const IDLE_STOP_HINT = "/legion stop [--force] [name|all]";
122
148
  export const IDLE_STOP_HINT_SHORT = "/legion stop";
@@ -11,7 +11,7 @@ import { Type } from "typebox";
11
11
  import type { LegionSettings } from "../shared/types.ts";
12
12
  import { extractVerdict, parseSessionFile } from "../shared/session.ts";
13
13
  import type { LegionSupervisor } from "../supervisor/types.ts";
14
- import { sqliteHasRow, type DatabaseSync } from "../legion/db.ts";
14
+ import { sqliteHasRow, withTransaction, type DatabaseSync } from "../legion/db.ts";
15
15
  import type { LegionEventBus } from "./event-bus.ts";
16
16
  import { appendEvent } from "../legion/events.ts";
17
17
  import {
@@ -137,9 +137,13 @@ export class LegionPostman {
137
137
  }
138
138
 
139
139
  private refreshHeartbeats(children: readonly LegionNode[]): void {
140
- for (const child of children) {
141
- if (this.supervisor.isAlive(child.name)) heartbeatNode(this.db, child.id, this.now);
142
- }
140
+ const alive = children.filter((child) => this.supervisor.isAlive(child.name));
141
+ if (alive.length === 0) return;
142
+ // One transaction for the whole tick: avoids per-child implicit
143
+ // autocommit fsync under PRAGMA synchronous=FULL (#236 P1).
144
+ withTransaction(this.db, () => {
145
+ for (const child of alive) heartbeatNode(this.db, child.id, this.now);
146
+ });
143
147
  }
144
148
 
145
149
  private async deliverChildren(children: readonly LegionNode[]): Promise<void> {
@@ -244,6 +248,19 @@ export class LegionPostman {
244
248
  const firstRootChild = this.firstRootChild(node);
245
249
  if (firstRootChild && this.supervisor.isAlive(firstRootChild.name)) continue;
246
250
  if (wasOrphanReportedRecently(this.db, node.id, timestamp - ORPHAN_REPORT_WINDOW_MS)) continue;
251
+ // D7 (display-liveness v1): converge the ledger, do not merely report.
252
+ // `orphan_detected` alone only described a suspicion, so a dead child stayed
253
+ // `running` forever and every display exit had to special-case it. Commit the
254
+ // fact first and let the event stream describe a state that already exists:
255
+ // `liveness()` then reads a terminal row and answers `dead` on its own. `node`
256
+ // is the pre-convergence snapshot — `updateNode` does not mutate it — so
257
+ // `node.updatedAt` still names the frozen heartbeat this scan judged.
258
+ updateNode(this.db, node.id, { status: "failed" }, () => timestamp);
259
+ appendEvent(this.db, node.id, "node_abandoned", {
260
+ reason: "no_active_supervisor",
261
+ staleMs,
262
+ updatedAt: node.updatedAt,
263
+ }, { now: () => timestamp });
247
264
  const event = appendEvent(this.db, node.id, "orphan_detected", {
248
265
  parentId: node.parentId,
249
266
  updatedAt: node.updatedAt,
@@ -0,0 +1,200 @@
1
+ /**
2
+ * The single authority for "is this child alive?" (contract
3
+ * `docs/contracts/display-liveness-v1.md`, D1 / D2 / D5).
4
+ *
5
+ * Before this module every display surface answered the question its own way:
6
+ * the status list looked at `child.state`, the text renderer additionally
7
+ * computed an owner verdict, and the railway strip arbitrated an event
8
+ * timestamp against a ledger heartbeat written by a *different* process. The
9
+ * same child could therefore be `live` (list), `failed` (text) and `running`
10
+ * (rail) at the same instant. `liveness()` replaces all three with one ordered
11
+ * projection over the facts available at the moment of rendering.
12
+ *
13
+ * ## Order, not union (D1)
14
+ *
15
+ * The eight rules below form a *total order* of evidence strength, not a set of
16
+ * predicates OR'd together. A union would let a leftover `running` ledger row
17
+ * outvote "this process already knows the child is gone"; the order makes the
18
+ * stronger evidence win:
19
+ *
20
+ * 1. `inMemory` present and non-terminal → `live` (we are holding it)
21
+ * 2. `inMemory` present and terminal → `dead`
22
+ * 3. `pidAlive === true` → `live` (a real process runs)
23
+ * 4. `pidAlive === false` → `dead` (D4: no heartbeat grace)
24
+ * 5. `ledger` non-terminal, heartbeat fresh → `live`
25
+ * 6. `ledger` non-terminal, heartbeat stale → `dead` (orphan; D7 converges it)
26
+ * 7. `record` terminal → `dead`
27
+ * 8. everything else (incl. all-undefined) → `unknown`
28
+ *
29
+ * ## Three states, not two (D2)
30
+ *
31
+ * `unknown` exists and must never be rendered as `live`. Treating unknown as
32
+ * live is exactly the bug that printed `Status · 1 live child` next to
33
+ * `owner: dead (pid gone)`; treating it as dead would kill children that are
34
+ * merely starting. The third state is the only honest answer.
35
+ *
36
+ * ## Purity (D1, §7-7)
37
+ *
38
+ * Pure by construction: no fs, no path, no database, no timer, and **no clock
39
+ * read**. `now` is a parameter because a module that reads its own clock cannot
40
+ * be asked "what would this render as at time T" — and two reads at slightly
41
+ * different times would reintroduce the off-by-a-tick behaviour R2 describes.
42
+ * `test/unit/liveness.test.ts` asserts these properties by reading this file's
43
+ * source text.
44
+ *
45
+ * ## No timestamp arbitration (D3)
46
+ *
47
+ * Nothing here compares timestamps that came from different writers. The only
48
+ * time comparison is `now - ledger.updatedAt < staleMs`, where `now` is the
49
+ * caller's render clock and `updatedAt` is that same ledger row's own heartbeat —
50
+ * one clock family, and the comparison decides freshness rather than "whose
51
+ * conclusion wins". Event-bus timestamps are never consulted: events only
52
+ * trigger a recompute (D8).
53
+ */
54
+
55
+ /** D2: the three truthful answers. `unknown` is a state, not a synonym for live. */
56
+ export type Liveness = "live" | "dead" | "unknown";
57
+
58
+ /**
59
+ * Facts for one child at one instant. Every source is optional because every
60
+ * source can be absent: a child may exist only in the in-memory registry, only
61
+ * in the ledger, only in `run.json`, or in none of them (replayed history).
62
+ */
63
+ export interface LivenessFacts {
64
+ /** Entry in this process's in-memory child registry, if any. */
65
+ readonly inMemory?: { readonly state: string } | undefined;
66
+ /** Ledger row plus the heartbeat timestamp its own process last wrote. */
67
+ readonly ledger?: { readonly status: string; readonly updatedAt: number } | undefined;
68
+ /** `run.json` child record (scheduling layer). */
69
+ readonly record?: { readonly state: string } | undefined;
70
+ /** Pid-marker verdict; `undefined` means there is no marker to consult. */
71
+ readonly pidAlive?: boolean | undefined;
72
+ /** Render clock, supplied by the caller — never read inside this module. */
73
+ readonly now: number;
74
+ /** Age after which a non-terminal ledger row is an orphan (default 90s). */
75
+ readonly staleMs: number;
76
+ }
77
+
78
+ /**
79
+ * States that mean "this lifecycle is over". Shared by W1/W2/W4 so the seven
80
+ * divergent sets listed in R3 collapse into one.
81
+ *
82
+ * Note what is *absent*: `idle` and `queued` are non-terminal (D5). An `idle`
83
+ * child is still alive and still holds a concurrency slot, and a `queued` child
84
+ * has been admitted but not started — calling either one terminal is what made
85
+ * the strip paint them as `retired`.
86
+ */
87
+ export const TERMINAL_STATES: ReadonlySet<string> = new Set([
88
+ "settled",
89
+ "success",
90
+ "completed",
91
+ "done",
92
+ "failed",
93
+ "aborted",
94
+ "error",
95
+ "truncated",
96
+ "retired",
97
+ ]);
98
+
99
+ /** States known to be in flight. `idle`/`queued` are alive: slot held, not working. */
100
+ export const NON_TERMINAL: ReadonlySet<string> = new Set([
101
+ "starting",
102
+ "working",
103
+ "running",
104
+ "blocked",
105
+ "idle",
106
+ "queued",
107
+ ]);
108
+
109
+ /**
110
+ * A state is terminal only when it is in `TERMINAL_STATES`. Anything unknown —
111
+ * including `""` — is *not* terminal: we do not get to declare a lifecycle over
112
+ * because we failed to recognise the label.
113
+ */
114
+ export function isTerminalState(state: string | undefined): boolean {
115
+ return state !== undefined && TERMINAL_STATES.has(state.toLowerCase());
116
+ }
117
+
118
+ /**
119
+ * D1: one ordered projection over the facts of a single instant.
120
+ *
121
+ * Total and side-effect free: the same `facts` always produce the same answer,
122
+ * which is what makes "render is a projection, not an event replay" (D8)
123
+ * checkable — see A6.
124
+ */
125
+ export function liveness(facts: LivenessFacts): Liveness {
126
+ // 1 + 2: the strongest evidence is a live handle in this very process.
127
+ const inMemory = facts.inMemory;
128
+ if (inMemory !== undefined) {
129
+ return isTerminalState(inMemory.state) ? "dead" : "live";
130
+ }
131
+
132
+ // 3 + 4: an actual OS process. D4 accepts the seconds-wide window where a
133
+ // just-exited pid still reads alive rather than inventing a grace period.
134
+ if (facts.pidAlive === true) return "live";
135
+ if (facts.pidAlive === false) return "dead";
136
+
137
+ // 5 + 6: last-written heartbeat. A frozen `updatedAt` from a dead writer is
138
+ // exactly the orphan case, and staleness is the only timestamp comparison
139
+ // here — same row, same writer, measuring age instead of ranking writers.
140
+ //
141
+ // "Non-terminal" is read as the complement of `TERMINAL_STATES` (D1's wording)
142
+ // rather than as membership in `NON_TERMINAL`: a row carrying a label we do
143
+ // not recognise has not told us the lifecycle is over, and rule 8 still keeps
144
+ // an *absent* ledger out of this branch.
145
+ const ledger = facts.ledger;
146
+ if (ledger !== undefined && !isTerminalState(ledger.status)) {
147
+ return facts.now - ledger.updatedAt < facts.staleMs ? "live" : "dead";
148
+ }
149
+
150
+ // 7 + 8: a terminal verdict from either writer. The ledger row coming first is
151
+ // D1-a's 2026-10-24 amendment: a ledger row that reached `settled`/`failed`
152
+ // states a finished lifecycle even when the scheduler record is already gone,
153
+ // and leaving that case at `unknown` would make the status panel call a
154
+ // finished child undecided.
155
+ if (ledger !== undefined && isTerminalState(ledger.status)) return "dead";
156
+ if (facts.record !== undefined && isTerminalState(facts.record.state)) return "dead";
157
+
158
+ // 9: never guess (D2).
159
+ return "unknown";
160
+ }
161
+
162
+ /**
163
+ * The display states a node can take. Declared here rather than in
164
+ * `src/tui/viz/model.ts` so this module stays dependency-free and every W can
165
+ * import the mapping without importing the TUI; W4 feeds `VizStatus` with
166
+ * these values. `idle` and `queued` are new first-class states (D5), and
167
+ * `unknown` replaces the old `retired` fallback — an unrecognised state must
168
+ * not be painted as "retired".
169
+ */
170
+ export type DisplayStatus =
171
+ | "running"
172
+ | "idle"
173
+ | "queued"
174
+ | "settled"
175
+ | "failed"
176
+ | "retired"
177
+ | "unknown";
178
+
179
+ const DISPLAY_RUNNING: ReadonlySet<string> = new Set(["starting", "working", "running", "blocked"]);
180
+ const DISPLAY_SETTLED: ReadonlySet<string> = new Set(["settled", "success", "completed", "done"]);
181
+ const DISPLAY_FAILED: ReadonlySet<string> = new Set(["failed", "aborted", "error", "truncated"]);
182
+
183
+ /**
184
+ * D5: raw lifecycle state → display state.
185
+ *
186
+ * `idle` maps to `idle` and `queued` to `queued` (rendered grey / `queued(n)`),
187
+ * the four in-flight states collapse to `running`, and everything unrecognised
188
+ * — including `undefined` — becomes `unknown` rather than the old `retired`
189
+ * fallback that made a starting child look finished.
190
+ */
191
+ export function displayStatusFromState(state: string | undefined): DisplayStatus {
192
+ const value = (state ?? "").toLowerCase();
193
+ if (value === "idle") return "idle";
194
+ if (value === "queued") return "queued";
195
+ if (DISPLAY_SETTLED.has(value)) return "settled";
196
+ if (DISPLAY_FAILED.has(value)) return "failed";
197
+ if (DISPLAY_RUNNING.has(value)) return "running";
198
+ if (value === "retired") return "retired";
199
+ return "unknown";
200
+ }
@@ -197,7 +197,7 @@ export function formatCompletionNotice(input: CompletionInput): CompletionNotice
197
197
  input.sessionFile ? `Session file: ${input.sessionFile}` : undefined,
198
198
  input.recycled === false
199
199
  ? undefined
200
- : "RPC child retired. Resume from the session file if you need this child again.",
200
+ : "Child retired. Resume from the session file if you need this child again.",
201
201
  ]
202
202
  .filter((line) => line !== undefined)
203
203
  .join("\n");
@@ -234,7 +234,7 @@ export function collectFailureInput(
234
234
  // Live children never reach here: watch() re-arms a connection blip.
235
235
  // A dead child's notice names that, instead of a bare "Connection error."
236
236
  const reason = isTransientConnectionFailure(raw)
237
- ? `${raw} (RPC child is not alive)`
237
+ ? `${raw} (child is not alive)`
238
238
  : raw;
239
239
  return {
240
240
  name,
@@ -275,8 +275,8 @@ export interface GroupedCompletionInput {
275
275
  * Still running: slow-2 (notifies separately when it finishes)
276
276
  *
277
277
  * Retirement is per-entry (a wait()-released sibling can race the flush, so a
278
- * blanket footer could be inaccurate): entries whose RPC child is retired
279
- * carry an inline "(RPC child retired)" marker.
278
+ * blanket footer could be inaccurate): entries whose child is retired
279
+ * carry an inline "(child retired)" marker.
280
280
  * Aggregate status: any failed → "failed"; else any stopped → "stopped";
281
281
  * else "completed". `display` follows the single-notice rule.
282
282
  */
@@ -311,8 +311,8 @@ export function formatGroupedNotice(
311
311
  ? ` — acceptance: ${entry.acceptance.status}${entry.acceptance.level ? ` (${entry.acceptance.level})` : ""}`
312
312
  : "";
313
313
  // Retirement is per-entry truth: a wait()-consumed sibling may already
314
- // be released while another RPC child is still alive, so no blanket footer.
315
- const recycled = entry.recycled === false ? "" : " (RPC child retired)";
314
+ // be released while another child is still alive, so no blanket footer.
315
+ const recycled = entry.recycled === false ? "" : " (child retired)";
316
316
  lines.push(`- ${label}: ${entry.status}${reason}${acceptance}${recycled}`);
317
317
  // Prefer the durable full-text artifact over the session file as the
318
318
  // recovery pointer: one `read` beats re-deriving from jsonl/chat store.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Parent-side RPC dispatch guidance shared by tool descriptions and prompts.
2
+ * Parent-side child dispatch guidance shared by tool descriptions and prompts.
3
3
  */
4
4
 
5
5
  export const PLAYBOOK_NAME = "pi-legion-playbook";
@@ -9,14 +9,14 @@ export const PARENT_PLAYBOOK = [
9
9
  "1. Call the `subagent` tool immediately with the matching role from the roster in this prompt. Do not substitute bash/curl/web fetch for a search or research role.",
10
10
  ' One child: subagent({ agent: "<role>", task: "...", name?: "scout-a" }) // optional name= registers that handle (not auto scout-N)',
11
11
  " Parallel: subagent({ tasks: [{ agent, task }, { agent, task }] })",
12
- "2. Prefer tasks[] for independent parallel work. Child processes are headless RPC children; no pane or tab is created.",
12
+ "2. Prefer tasks[] for independent parallel work. Children are in-process by default; a separate `pi` process is used only when the launch needs a worktree (centurion) or the role pins `spawnMode: rpc`. No pane or tab is created.",
13
13
  "3. Then return control. A completion message wakes this session when idle; if it is still working, the notice waits until the current turn ends. Use `wait` with `name` or `all: true` when you need blocking results, and `collect` to inspect a child's session JSONL. Esc / wait interrupt only ends the parent turn's wait — children keep running and settle still notifies; only an explicit `stop` or session exit retires them. A wait interrupted by Esc returns `interrupted (parent turn ended; children still running)`. In `pi --print` or JSON mode, session shutdown waits for live async children to settle (up to each child's turn timeout) before retiring them. Use same-turn `wait`/`collect` or `async: false` when that print turn must include the child's result.",
14
- "4. Later control is only through `subagent({ action, name?, message? })`; `status` with no name (or `all: true`) overviews live children, with `name` for one child; use `stop` to gracefully retire one child or all tracked children (`force: true` for hard retire when soft stop is insufficient). A `wait` timeout does not stop children — the result lists decisions (`extend` / `steer_again` / `stop` / `force_stop`) for the parent to choose. If a child exits oddly (Model not found, missing tools, memory refused, …), call `subagent({ action: \"diagnose\" })` or ask the user to run `/legion doctor` / `/legion doctor models` — children use `--no-extensions`, so provider packages must be passed explicitly. Do not tell a child to message or prompt the parent directly.",
15
- "5. A collect timeout with a live RPC child is a progress signal, not a verdict. Check status or session JSONL before deciding to steer or resume.",
14
+ "4. Later control is only through `subagent({ action, name?, message? })`; `status` with no name (or `all: true`) overviews live children, with `name` for one child; use `stop` to gracefully retire one child or all tracked children (`force: true` for hard retire when soft stop is insufficient). A `wait` timeout does not stop children — the result lists decisions (`extend` / `steer_again` / `stop` / `force_stop`) for the parent to choose. If a child exits oddly (Model not found, missing tools, memory refused, …), call `subagent({ action: \"diagnose\" })` or ask the user to run `/legion doctor` / `/legion doctor models` — children run without host extensions unless the role inherits them, so a provider package the child needs must be passed explicitly. Do not tell a child to message or prompt the parent directly.",
15
+ "5. A collect timeout with a live child is a progress signal, not a verdict. Check status or session JSONL before deciding to steer or resume.",
16
16
  "6. Isolation belongs to centurion. For N parallel isolated writers, dispatch N centurions (each owns one worktree); members stay `worktree: false` in-process. Passing `worktree: true` on a non-centurion warns once and is forced false.",
17
17
  "7. Launch with `verify: [\"npm run typecheck\", \"npm test\"]` when the project has a mechanical verifier. The extension runs those commands itself in the child worktree at collect time; the child does not need to paste test output.",
18
- "Utility agents (`utility: true`, e.g. scout) can be dispatched by ANY role via the separate `vexillatio` tool — read-only helpers, always leaf, no depth budget. The `subagent` tool stays for the command chain (worker/centurion).",
19
- "Forbidden: direct bash access to legion.db, hand-launching child Pi processes, or using herdr pane/tab/agent commands for dispatch.",
18
+ "Utility agents (`utility: true`, e.g. scout) can be dispatched by ANY role via the separate `vexillatio` tool — read-only helpers, always leaf, no depth budget. Its request shape matches `subagent`: either `vexillatio({ task, agent? })` or `vexillatio({ tasks: [{ agent, task }, …] })` (no top-level `task` needed; passing both only labels the parent run). The `subagent` tool stays for the command chain (worker/centurion).",
19
+ "Forbidden: direct bash access to legion.db, or launching child Pi processes by hand (use the subagent tool).",
20
20
  ].join("\n");
21
21
 
22
22
  export const TOOL_DESCRIPTION = [
@@ -1,4 +1,4 @@
1
- /** Helpers for collect/retire calls made after an RPC child is auto-retired. */
1
+ /** Helpers for collect/retire calls made after a child is auto-retired. */
2
2
 
3
3
  import type { TaskState } from "../shared/types.ts";
4
4
 
@@ -7,13 +7,17 @@ export function formatAlreadyRecycled(
7
7
  sessionFile: string,
8
8
  ): string {
9
9
  return (
10
- `Already retired ${name}. This is a no-op — the RPC child is already stopped. ` +
10
+ `Already retired ${name}. This is a no-op — the child is already stopped. ` +
11
11
  `Session kept for resume: ${sessionFile}`
12
12
  );
13
13
  }
14
14
 
15
15
  /** States where collect should return the snapshot already on the child. */
16
16
  export function canUseCachedCollect(state: TaskState): boolean {
17
+ // `queued` is deliberately NOT cacheable (#266 D3/§4.4): the record has no
18
+ // execution, no acceptance and only a PLACEHOLDER session file, so a cached
19
+ // collect would hand back a stale/empty snapshot as if the launch had finished.
20
+ // A queued child must go through `collect`'s wait-for-admission path (D8).
17
21
  return (
18
22
  state === "retired" ||
19
23
  state === "awaiting" ||