pi-legion 3.1.0 → 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 (62) hide show
  1. package/CHANGELOG.md +215 -2
  2. package/CONTRIBUTING.md +4 -17
  3. package/README.md +25 -21
  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/vexillatio-contract.md +3 -1
  15. package/index.ts +557 -76
  16. package/package.json +4 -2
  17. package/skills/pi-legion/SKILL.md +2 -3
  18. package/src/agents/agents.ts +17 -10
  19. package/src/agents/overrides.ts +0 -1
  20. package/src/agents/presets.ts +14 -40
  21. package/src/agents/settings.ts +26 -77
  22. package/src/agents/step-model.ts +7 -22
  23. package/src/api.ts +1 -35
  24. package/src/extension/event-bus.ts +80 -8
  25. package/src/extension/legion-commands.ts +41 -17
  26. package/src/extension/legion-doctor.ts +1 -1
  27. package/src/extension/legion-stop.ts +39 -13
  28. package/src/extension/legion-tools.ts +21 -4
  29. package/src/extension/liveness.ts +200 -0
  30. package/src/extension/playbook.ts +2 -2
  31. package/src/extension/recycle.ts +4 -0
  32. package/src/extension/runtime.ts +379 -15
  33. package/src/extension/slash.ts +0 -21
  34. package/src/extension/summary.ts +1 -1
  35. package/src/legion/db.ts +165 -1
  36. package/src/legion/events.ts +81 -16
  37. package/src/legion/nodes.ts +13 -2
  38. package/src/legion/rebuild.ts +7 -1
  39. package/src/legion/usage.ts +58 -4
  40. package/src/runs/orchestrator.ts +1165 -62
  41. package/src/runs/worktree.ts +190 -21
  42. package/src/shared/ignored-kind.ts +57 -0
  43. package/src/shared/progress.ts +11 -0
  44. package/src/shared/session.ts +199 -19
  45. package/src/shared/types.ts +50 -66
  46. package/src/supervisor/admission-queue.ts +516 -0
  47. package/src/supervisor/inprocess-supervisor.ts +108 -33
  48. package/src/supervisor/proc-memory.ts +67 -0
  49. package/src/supervisor/routing-supervisor.ts +58 -1
  50. package/src/tui/session-messages-file.ts +100 -0
  51. package/src/tui/status.ts +91 -18
  52. package/src/tui/tree-view.ts +28 -3
  53. package/src/tui/viz/layout.ts +54 -3
  54. package/src/tui/viz/model.ts +67 -14
  55. package/src/tui/viz/palette.ts +11 -0
  56. package/src/tui/viz/panels.ts +222 -8
  57. package/src/tui/viz/view.ts +110 -23
  58. package/src/backends/legacy/client.ts +0 -638
  59. package/src/backends/legacy/cursor-chat.ts +0 -348
  60. package/src/backends/legacy/runner.ts +0 -101
  61. package/src/backends/legacy/types.ts +0 -138
  62. 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
 
@@ -357,7 +359,18 @@ function treeForLiveSource(
357
359
  deps.bus.on(type, (event) => {
358
360
  if (event.nodeId !== nodeId) return;
359
361
  if (event.id === undefined && !processEventTypes.has(event.type)) return;
360
- 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();
361
374
  listener({
362
375
  ...(event.id === undefined ? {} : { id: event.id }),
363
376
  domain: event.id === undefined ? "process" : "stored",
@@ -431,6 +444,17 @@ function openLiveOverlay(
431
444
  nodeId: string,
432
445
  onStop?: (nodeId: string) => void,
433
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;
434
458
  // #193: the overlay must cover the floating frame only. A full-screen overlay
435
459
  // would make pi-tui discard every host cell (its composite has no transparency
436
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
+ }
@@ -15,8 +15,8 @@ export const PARENT_PLAYBOOK = [
15
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 = [
@@ -14,6 +14,10 @@ export function formatAlreadyRecycled(
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" ||