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
@@ -33,8 +33,15 @@ import {
33
33
  type WidgetTui,
34
34
  } from "../tui/status.ts";
35
35
  import { progressFromSessionFile } from "../shared/progress.ts";
36
+ import { readProcRss } from "../supervisor/proc-memory.ts";
36
37
  import { isTransientConnectionFailure } from "../shared/session.ts";
37
- import { DEFAULTS, type AgentKind, type RetireOptions } from "../shared/types.ts";
38
+ import { isTerminalState, liveness } from "./liveness.ts";
39
+ import {
40
+ DEFAULTS,
41
+ type AgentKind,
42
+ type RetireOptions,
43
+ type SpawnMode,
44
+ } from "../shared/types.ts";
38
45
  import type {
39
46
  EventSubscriptionOptions,
40
47
  SupervisorEvent,
@@ -45,6 +52,7 @@ import { createDbEventSource, type DbEventSource } from "./event-bus.ts";
45
52
  import { openLegionDb, type DatabaseSync } from "../legion/db.ts";
46
53
  import { getSubtree } from "../legion/nodes.ts";
47
54
  import { attachUsageTotals } from "../legion/usage.ts";
55
+ import { maxEventId } from "../legion/events.ts";
48
56
  import type { TreeNodeSnapshot } from "../tui/tree-view.ts";
49
57
 
50
58
  export interface CollectSnapshot {
@@ -76,6 +84,18 @@ export interface TrackedJobInput {
76
84
  model?: string;
77
85
  thinking?: string | false;
78
86
  worktreeBranch?: string;
87
+ /**
88
+ * Arbitrated backend for this child (`Handle.spawnMode`, #209). Optional:
89
+ * a job tracked from a legacy record has no value and must render as
90
+ * unknown rather than a guessed `rpc`.
91
+ */
92
+ spawnMode?: SpawnMode;
93
+ /**
94
+ * OS process of an `rpc` child (`Handle.pid`), for the memory probe only.
95
+ * Optional: an `inprocess` child has no process of its own, and a legacy
96
+ * record has no value. Never persisted — a pid is host-scoped.
97
+ */
98
+ pid?: number;
79
99
  /** Legion tree identity supplied by the host when the M2 ChildRecord is available. */
80
100
  nodeId?: string;
81
101
  parentId?: string | null;
@@ -151,6 +171,16 @@ export interface SessionRuntimeDeps {
151
171
  }
152
172
 
153
173
  const DEFAULT_REFRESH_MS = 500;
174
+ /**
175
+ * How long a ledger heartbeat stays trustworthy (D3/D7).
176
+ *
177
+ * Mirrors `DEFAULT_ORPHAN_STALE_MS` in `legion-tools.ts` and the `orphanStaleMs`
178
+ * setting default (`src/shared/types.ts`). Duplicated as a literal on purpose:
179
+ * the TUI refresh path must not read settings on every repaint, and this value is
180
+ * the horizon for "a row that stopped heartbeating", which is exactly the orphan
181
+ * definition. Keep the three in sync.
182
+ */
183
+ const DEFAULT_ORPHAN_STALE_MS = 90_000;
154
184
  /**
155
185
  * Hard cap on how long a completion notice may stay hidden behind a live
156
186
  * parent agent run (D-B). 80s = 2× the (1+3)×10s that the old join window
@@ -234,6 +264,20 @@ export interface LiveChildStatusInput {
234
264
  * by the owner marker instead. Only consulted when `alive` is false.
235
265
  */
236
266
  owner?: "live" | "dead" | "unknown";
267
+ /**
268
+ * Own OS process of an `rpc` child (`Handle.pid`), when this session owns it.
269
+ *
270
+ * Only a pid of THIS host is ever supplied — pids are not persisted, so a
271
+ * record from a previous session has none. `inprocess` children have none by
272
+ * definition: they share this process.
273
+ */
274
+ pid?: number;
275
+ /**
276
+ * Context size of the last assistant message. Explicit override; otherwise
277
+ * derived from `sessionFile` through `progressFromSessionFile` — the same
278
+ * `accumulateUsage` source of truth the widget reads.
279
+ */
280
+ contextTokens?: number;
237
281
  now?: number;
238
282
  }
239
283
 
@@ -302,6 +346,52 @@ function ownerLine(owner: "live" | "dead" | "unknown"): string {
302
346
  return `owner: ${owner}`;
303
347
  }
304
348
 
349
+ /** Compact token count: `950`, `24.3K`, `1.4M` — absolute, never a percentage. */
350
+ export function formatContextTokens(value: number): string {
351
+ if (!Number.isFinite(value) || value <= 0) return "0";
352
+ const unit = value >= 1_000_000 ? "M" : value >= 1_000 ? "K" : "";
353
+ const divisor = unit === "M" ? 1_000_000 : unit === "K" ? 1_000 : 1;
354
+ if (!unit) return String(Math.round(value));
355
+ return `${(value / divisor).toFixed(1).replace(/\.0$/, "")}${unit}`;
356
+ }
357
+
358
+ /** Context size of the child's own session, when it has one to read. */
359
+ function contextTokensFor(sessionFile: string): number | undefined {
360
+ if (!sessionFile) return undefined;
361
+ const live = progressFromSessionFile(sessionFile);
362
+ return live.contextTokens;
363
+ }
364
+
365
+ /**
366
+ * Memory line for one child, following D2: only an `rpc` child owns a process,
367
+ * so only it gets a per-child RSS. An `inprocess` child runs INSIDE this host
368
+ * process — apportioning the host RSS across children would be invented data,
369
+ * so it reports the shared host figure and says so.
370
+ *
371
+ * A memory figure describes a process that is running NOW, so a child that the
372
+ * liveness check reports gone gets no line at all (§6). Its pid may already
373
+ * belong to an unrelated process: reading `/proc/<pid>` then would print a
374
+ * stranger's RSS under this child's name — fabricated attribution that looks
375
+ * real, the exact thing D2 forbids. v1 keeps no last-known cache, so there is
376
+ * nothing honest left to show.
377
+ *
378
+ * `undefined` when there is nothing honest to print (child not alive, no pid,
379
+ * non-Linux host, pid already gone).
380
+ */
381
+ function memoryLine(input: LiveChildStatusInput): string | undefined {
382
+ // §6 pid-reuse race: never probe a pid whose process is already gone.
383
+ if (!input.alive) return undefined;
384
+ if (input.spawnMode === "inprocess") {
385
+ const host = readProcRss(process.pid);
386
+ if (!host) return undefined;
387
+ return `memory: host rss=${host.rssMb}m (shared by inprocess children)`;
388
+ }
389
+ if (input.pid === undefined) return undefined;
390
+ const own = readProcRss(input.pid);
391
+ if (!own) return undefined;
392
+ return `memory: rss=${own.rssMb}m${own.peakMb !== undefined ? ` peak=${own.peakMb}m` : ""}`;
393
+ }
394
+
305
395
  /**
306
396
  * Status text for one child (`/legion status <name>` and `subagent status`).
307
397
  *
@@ -354,6 +444,12 @@ export function renderLiveChildStatus(input: LiveChildStatusInput): string {
354
444
  `${acceptanceLabel}: ${input.acceptance.status}${input.acceptance.level ? ` (${input.acceptance.level})` : ""}`,
355
445
  );
356
446
  }
447
+ // Live resource facts. Appended as their own lines: every line above keeps
448
+ // its text and position, so existing readers of this block stay valid.
449
+ const contextTokens = input.contextTokens ?? contextTokensFor(input.sessionFile);
450
+ if (contextTokens !== undefined) lines.push(`context: ctx=${formatContextTokens(contextTokens)} tok`);
451
+ const memory = memoryLine(input);
452
+ if (memory) lines.push(memory);
357
453
  lines.push(...meta);
358
454
  if (stale) {
359
455
  const persistedExecution = input.execution
@@ -605,6 +701,35 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
605
701
  });
606
702
  join.setConfig(joinConfig);
607
703
 
704
+ /**
705
+ * D3/D8: the orphan horizon, matching `legion-tools.ts`'s DEFAULT_ORPHAN_STALE_MS.
706
+ *
707
+ * `runtime.ts` has no settings dependency (the TUI layer must not read
708
+ * settings per repaint), so this mirrors the documented default. A ledger row
709
+ * whose heartbeat is older than this cannot describe a live child.
710
+ */
711
+ const orphanStaleMs = (): number => DEFAULT_ORPHAN_STALE_MS;
712
+
713
+ /**
714
+ * D3/D8: the in-process facts for a node, keyed by tree id.
715
+ *
716
+ * A tracked job is both the strongest liveness evidence (rules 1/2: this very
717
+ * process holds the handle) and the scheduler record. Resolved once per node
718
+ * rather than by scanning `jobs` twice.
719
+ */
720
+ const inProcessFactsFor = (
721
+ nodeId: string,
722
+ ): { inMemory?: { state: string }; record?: { state: string } } => {
723
+ for (const job of jobs.values()) {
724
+ if (job.state === undefined) continue;
725
+ const matches =
726
+ job.nodeId === nodeId ||
727
+ (job.nodeId === undefined && deps.getTreeIdentity?.(job.name)?.nodeId === nodeId);
728
+ if (matches) return { inMemory: { state: job.state }, record: { state: job.state } };
729
+ }
730
+ return {};
731
+ };
732
+
608
733
  const snapshotNodes = (): TreeNodeSnapshot[] => {
609
734
  const ledger = ledgerDb;
610
735
  const provided = deps.getTreeNodes
@@ -613,23 +738,100 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
613
738
  if (provided !== undefined) {
614
739
  tree.clear();
615
740
  for (const node of provided) {
741
+ // D3: no timestamp race. `override.ts` comes from THIS process's event
742
+ // bus while `node.updatedAt` comes from the CHILD's heartbeat, and a
743
+ // dead child stops heartbeating (`legion-tools.ts` only heartbeats
744
+ // `isAlive` children), so its `updatedAt` freezes. Comparing the two
745
+ // therefore ranked two different clocks, and when the override looked
746
+ // older the code DELETED it and trusted the frozen row — which pulled
747
+ // finished nodes back to `running`. That was the whole reported bug.
748
+ //
749
+ // D8: render is a projection of facts, not a replay of events. The
750
+ // lifecycle verdict now comes from `liveness()`; the override only
751
+ // contributes fields that are NOT a lifecycle claim (`phase`), plus an
752
+ // optimistic status that must still agree with `liveness()`.
616
753
  const override = eventOverrides.get(node.id);
617
- if (override && override.ts >= node.updatedAt) {
618
- tree.set(node.id, {
619
- ...node,
620
- ...(override.status === undefined ? {} : { status: override.status }),
621
- ...(override.phase === undefined ? {} : { phase: override.phase }),
622
- updatedAt: override.ts,
623
- });
624
- } else {
625
- if (override) eventOverrides.delete(node.id);
626
- tree.set(node.id, { ...node });
627
- }
754
+ const verdict = liveness({
755
+ ledger: { status: node.status, updatedAt: node.updatedAt },
756
+ ...inProcessFactsFor(node.id),
757
+ now: now(),
758
+ staleMs: orphanStaleMs(),
759
+ });
760
+ // What the verdict decides, and what it does NOT decide:
761
+ //
762
+ // - `dead` wins over a non-terminal row. A row that stopped
763
+ // heartbeating cannot describe a live child, so it is rendered as
764
+ // failed rather than left looking alive.
765
+ // - `unknown` never promotes a row to `running` and never claims death.
766
+ // - `live` says the child IS alive; it does not say WHICH alive state.
767
+ // The ledger row only advances on heartbeat (coarse), while an event
768
+ // says exactly what happened (`node_launched` → running). So a live
769
+ // verdict keeps the MORE SPECIFIC of the two: an override status from
770
+ // this process's own bus wins over the row, because it is the same
771
+ // lifecycle seen at a finer grain. Only a terminal row outranks it —
772
+ // a settled/failed row is a fact that no later "running" may erase.
773
+ //
774
+ // Two override cases outrank the row, and they cover both directions
775
+ // the coarse heartbeat cannot express:
776
+ // - the override is TERMINAL (`node_settled`) while the row has not
777
+ // caught up: the event ends the child now, not on the next heartbeat;
778
+ // - both are non-terminal and the event is more specific
779
+ // (`node_launched` → running vs a stale `starting` row).
780
+ // A terminal ROW always wins over a non-terminal override: once the
781
+ // ledger says settled/failed, no later "running" may erase that.
782
+ const overrideStatus = override?.status;
783
+ const rowTerminal = isTerminalState(node.status);
784
+ const overrideTerminal = overrideStatus !== undefined && isTerminalState(overrideStatus);
785
+ const status =
786
+ verdict === "dead" && !rowTerminal
787
+ ? "failed"
788
+ : overrideStatus !== undefined && !rowTerminal && (overrideTerminal || verdict === "live")
789
+ ? overrideStatus
790
+ : node.status;
791
+ tree.set(node.id, {
792
+ ...node,
793
+ status,
794
+ ...(override?.phase === undefined ? {} : { phase: override.phase }),
795
+ });
796
+ }
797
+ // Only overrides whose optimistic STATUS has now been superseded are
798
+ // dropped: the tree row above is the authoritative projection, so keeping
799
+ // a lifecycle claim around would resurrect it on the next event. `phase`
800
+ // is not a lifecycle claim and must survive — it carries no
801
+ // live/dead meaning and has no other source between events.
802
+ for (const [nodeId, override] of eventOverrides) {
803
+ if (!tree.has(nodeId)) continue;
804
+ if (override.status === undefined) continue;
805
+ const current = tree.get(nodeId);
806
+ if (current !== undefined && current.status !== override.status) eventOverrides.delete(nodeId);
628
807
  }
629
808
  for (const nodeId of activity.keys()) if (!tree.has(nodeId)) activity.delete(nodeId);
630
809
  for (const nodeId of eventOverrides.keys()) if (!tree.has(nodeId)) eventOverrides.delete(nodeId);
631
810
  }
632
- return [...tree.values()].map((node) => ({ ...node }));
811
+ const nodes = [...tree.values()].map((node) => ({ ...node }));
812
+ return mergeSpawnModes(nodes);
813
+ };
814
+
815
+ /**
816
+ * #209/D5: the arbitrated backend lives on the tracked job, never in
817
+ * legion.db (no column, no migration), so it is merged onto the matching
818
+ * snapshot. Unmatched jobs contribute nothing rather than a guess.
819
+ *
820
+ * Shared by `snapshotNodes()` and `treeNodes()`: the latter synthesizes
821
+ * nodes of its own (a job with identity but no DB row yet) and returns that
822
+ * array directly, so a merge that only ran in `snapshotNodes()` would drop
823
+ * the mode in the pre-DB-row window and in a plain session where
824
+ * `getTreeNodes()` yields `[]`.
825
+ */
826
+ const mergeSpawnModes = (
827
+ nodes: TreeNodeSnapshot[],
828
+ ): TreeNodeSnapshot[] => {
829
+ for (const job of jobs.values()) {
830
+ if (job.spawnMode === undefined) continue;
831
+ const match = storedNodeForJob(job, nodes);
832
+ if (match) match.spawnMode = job.spawnMode;
833
+ }
834
+ return nodes;
633
835
  };
634
836
 
635
837
  const storedNodeForJob = (
@@ -726,16 +928,29 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
726
928
  const data = event.data && typeof event.data === "object" && !Array.isArray(event.data)
727
929
  ? event.data as Record<string, unknown>
728
930
  : undefined;
729
- if (node && event.ts >= node.updatedAt) {
931
+ if (node) {
932
+ // D3: no cross-writer timestamp race.
933
+ //
934
+ // `event.ts` is stamped by THIS process's bus; `node.updatedAt` is stamped
935
+ // by the CHILD's heartbeat. A dead child stops heartbeating, so its
936
+ // `updatedAt` freezes and a later real event would fail `event.ts >=
937
+ // updatedAt` — silently dropped, leaving the panel stuck. Only the
938
+ // SAME-writer ordering is still meaningful: an event must not undo a
939
+ // NEWER event from the same bus (that is `previous.ts`, below).
940
+ //
941
+ // D8: this is an optimistic update only. `snapshotNodes()` recomputes every
942
+ // row from facts via `liveness()`, so a stale optimistic status cannot
943
+ // survive a refresh — which is what makes dropping the clock guard safe.
730
944
  const previous = eventOverrides.get(event.nodeId);
731
945
  if (!previous || event.ts >= previous.ts) {
732
946
  const override = { ...previous, ts: event.ts };
733
- const updated = { ...node, updatedAt: event.ts };
947
+ const updated = { ...node };
734
948
  if (event.type === "node_launched" || event.type === "node_resumed") updated.status = override.status = "running";
735
949
  else if (event.type === "node_settled") updated.status = override.status = "settled";
736
950
  else if (event.type === "node_failed") updated.status = override.status = "failed";
737
951
  else if (event.type === "node_retired" && node.status !== "settled" && node.status !== "failed") updated.status = override.status = "retired";
738
952
  else if (event.type === "node_blocked") updated.status = override.status = "blocked";
953
+ else if (event.type === "node_abandoned") updated.status = override.status = "failed";
739
954
  else if (event.type === "phase_change" && typeof data?.to === "string") updated.phase = override.phase = data.to;
740
955
  eventOverrides.set(event.nodeId, override);
741
956
  tree.set(event.nodeId, updated);
@@ -761,10 +976,156 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
761
976
  }
762
977
  };
763
978
 
979
+ /**
980
+ * True between the first event of a burst and its trailing flush. It is both
981
+ * the "a flush is already scheduled" guard and the "a burst is open" marker
982
+ * that tells `onWidgetEvent` whether this event needs the leading snapshot.
983
+ */
984
+ let burstPending = false;
985
+
986
+ /**
987
+ * #238: a burst of bus events in one tick costs ONE tree snapshot + paint
988
+ * instead of 2×N. `snapshotNodes()` is a synchronous recursive-CTE subtree
989
+ * scan plus a usage batch query, and `refreshUi()` → `statusEntries()` ran it
990
+ * a second time per event.
991
+ *
992
+ * What stays synchronous, per event, in arrival order (D2): `applyTreeEvent`
993
+ * is an incremental state machine, not a render. `event.ts >= node.updatedAt`
994
+ * lets a later event veto an earlier one, `turn_start` increments the turn and
995
+ * clears the frozen endpoint, and `agent_settled` freezes it — so batching or
996
+ * reordering these would change the state they compute. They run in the bus
997
+ * callback exactly as before.
998
+ *
999
+ * What is coalesced: the trailing snapshot + paint only. The *leading*
1000
+ * snapshot is deliberately NOT coalesced: `applyTreeEvent` reads `tree`, which
1001
+ * only `snapshotNodes()` populates, so the first event of a burst must see a
1002
+ * current tree or it would project this tick's status/phase delta against
1003
+ * whatever the previous tick left behind (a node launched since then is simply
1004
+ * absent, and its event is dropped). A burst therefore costs a leading snapshot
1005
+ * plus one flush — a constant 2, down from 2×N.
1006
+ *
1007
+ * Semantics of the mid-burst window — a DOCUMENTED LIMITATION, not a claim of
1008
+ * equivalence. Events 2..N are vetted against the tree as of burst start
1009
+ * instead of being re-read per event (what `master` does).
1010
+ *
1011
+ * The divergence class is: ANY mid-burst write that changes a row in this
1012
+ * subtree. Not just a write to the event's own node — `snapshotNodes()` clears
1013
+ * and rebuilds the whole `tree` while `eventOverrides` survives, so a sibling
1014
+ * process committing any row here can change whether a later event's
1015
+ * `event.ts >= node.updatedAt` guard passes. Reachable in production because
1016
+ * `getTreeNodes()` re-reads SQLite on every call, so a concurrent writer's
1017
+ * commit IS visible to a per-event read but not to a burst; sibling bus
1018
+ * subscribers also read `treeNodes()` inside the burst
1019
+ * (`legion-commands.ts`).
1020
+ *
1021
+ * Sampled rather than exhaustively enumerated: a differential harness over
1022
+ * burst/row combinations (row status x row `updatedAt` x mid-burst bump x event
1023
+ * pair) showed the coalesced paint matching a per-event re-read in every case
1024
+ * with no mid-burst write, and diverging in both directions once there is one —
1025
+ * the coalesced path can miss a veto `master` applied, and can also apply one
1026
+ * `master` correctly skipped. So neither "re-read at the flush" nor "re-read at
1027
+ * burst start" fixes it: reproducing a SEQUENCE of per-event reads is not
1028
+ * possible with a constant number of reads, which is why this is bounded and
1029
+ * documented instead of repaired. `test/unit/runtime.test.ts` pins one concrete
1030
+ * instance (the "KNOWN LIMITATION" test) so the behaviour is asserted rather
1031
+ * than described.
1032
+ *
1033
+ * It IS sticky, and this is not fixed: the override wins on `ts`, exactly as
1034
+ * `master`'s own override does, so the next `snapshotNodes()` does NOT re-vet it
1035
+ * away. It lasts until the row moves again. Fixing it needs per-event re-reads,
1036
+ * i.e. reverting the optimisation.
1037
+ *
1038
+ * One sub-case IS closed, at no cost to the common burst: an event for a node
1039
+ * that is absent from `tree` but belongs to THIS subtree (its row only landed
1040
+ * mid-burst) takes a fresh snapshot before applying, so `applyTreeEvent`'s
1041
+ * `tree.get()` miss cannot silently drop it. Foreign-subtree node ids — the
1042
+ * normal shape of a shared-ledger poll batch — cost zero extra reads.
1043
+ *
1044
+ * Microtask, not macrotask: the burst this targets is one synchronous emit
1045
+ * loop (`legion-tools.ts` emits N `mail_sent` events back-to-back; the db event
1046
+ * source drains a poll batch the same way), and a microtask drains the whole
1047
+ * burst with no timer seam, no extra flush API, and no added latency before the
1048
+ * TUI's next frame. A macrotask would need an explicit `flush()` seam or a
1049
+ * timer and would delay the paint by a tick for no coalescing benefit.
1050
+ */
1051
+ /**
1052
+ * The subtree this runtime renders, derived from the FIRST populated snapshot.
1053
+ * `snapshotNodes()` calls `deps.getTreeNodes()`, which the extension supplies
1054
+ * as `getSubtree(db, identity.nodeId)` — so the shallowest node id in that
1055
+ * result IS this session's subtree root, and every node it can ever show is
1056
+ * that id or a strict descendant of it (`parent.child`).
1057
+ *
1058
+ * This is what bounds the unknown-node snapshot below (#238 review): the
1059
+ * DbEventSource emits EVERY row on the shared legion.db, so a sibling
1060
+ * subtree's batch routinely lands on this bus with `nodeId`s this session can
1061
+ * never render. Paying a snapshot per such event would be backwards — a
1062
+ * 40-event foreign batch cost 41 reads, worse than master's 80/2 ratio implied.
1063
+ * `undefined` means "not yet known" (empty tree / a host with no tree), in
1064
+ * which case an unknown node is treated as possibly-ours and gets the read.
1065
+ */
1066
+ const subtreeRootId = (): string | undefined => {
1067
+ let best: string | undefined;
1068
+ for (const id of tree.keys()) {
1069
+ if (best === undefined || id.length < best.length) best = id;
1070
+ }
1071
+ return best;
1072
+ };
1073
+
1074
+ /** True when `nodeId` is the subtree root itself or a strict descendant of it. */
1075
+ const isInOwnSubtree = (nodeId: string): boolean => {
1076
+ const rootId = subtreeRootId();
1077
+ if (rootId === undefined) return true;
1078
+ return nodeId === rootId || nodeId.startsWith(`${rootId}.`);
1079
+ };
1080
+
1081
+ const scheduleTreeFlush = (): void => {
1082
+ if (burstPending) return;
1083
+ burstPending = true;
1084
+ queueMicrotask(() => {
1085
+ burstPending = false;
1086
+ // The old inline path ran `refreshUi()` INSIDE the bus handler, so a throw
1087
+ // from `getTreeNodes()`/`board.paint()` was contained by
1088
+ // `createLegionEventBus`'s per-subscriber try/catch. A microtask has no
1089
+ // such wrapper, so an uncaught throw here would kill the host process. The
1090
+ // bus's job is to isolate a subscriber's failure; report through the same
1091
+ // channel a throwing handler uses instead of escaping onto the microtask
1092
+ // queue. No `disposed` guard: `refreshUi()` already returns early when
1093
+ // disposed, and this callback calls no other paint-path code.
1094
+ try {
1095
+ refreshUi();
1096
+ } catch (error) {
1097
+ // Reporting must never itself break delivery, and must never re-enter
1098
+ // the microtask queue as an uncaught throw: `reportError` is optional
1099
+ // precisely so structural bus stand-ins keep working, and such a stand-in
1100
+ // would otherwise reintroduce the host-killing crash this guard exists
1101
+ // to prevent. Swallowing is the lesser evil — a lost repaint report
1102
+ // never costs the process.
1103
+ try {
1104
+ deps.eventBus?.reportError?.(error, "deferred-handler");
1105
+ } catch {
1106
+ // Isolated reporting failure: the board simply skips this frame.
1107
+ }
1108
+ }
1109
+ });
1110
+ };
1111
+
764
1112
  const onWidgetEvent = (event: LegionEvent): void => {
765
- snapshotNodes();
1113
+ // Leading snapshot, once per burst (see `scheduleTreeFlush`).
1114
+ if (!burstPending) snapshotNodes();
1115
+ // A node whose row only landed mid-burst is absent from `tree`, and
1116
+ // `applyTreeEvent` returns early on a `tree.get()` miss, so the event is
1117
+ // dropped and the flush cannot repair it: a just-launched child's ledger row
1118
+ // still says whatever the pre-launch write left (`starting` or nothing), so
1119
+ // re-reading cannot produce the `running` transition that a STORED
1120
+ // `node_launched` event carries. Pay one snapshot for exactly this case.
1121
+ //
1122
+ // Gated on subtree ownership: foreign-subtree events are the normal shape of
1123
+ // a shared-ledger poll batch and can never create a node here, so they cost
1124
+ // ZERO extra reads. Without the gate a 40-event foreign batch cost 41 reads;
1125
+ // with it, 2 (leading + flush).
1126
+ else if (!tree.has(event.nodeId) && isInOwnSubtree(event.nodeId)) snapshotNodes();
766
1127
  applyTreeEvent(event);
767
- refreshUi();
1128
+ scheduleTreeFlush();
768
1129
  };
769
1130
  const busUnsubscribers = deps.eventBus
770
1131
  ? LEGION_EVENT_TYPES
@@ -776,7 +1137,15 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
776
1137
  try {
777
1138
  ledgerDb = openLegionDb(process.env.PI_LEGION_DB);
778
1139
  snapshotNodes();
1140
+ // Seed at the tail for the same reason as the extension's attach path
1141
+ // (`attachLegionRuntime` in index.ts, #238): this branch attaches to a ledger
1142
+ // written by another process, and replaying its history only to reach a state
1143
+ // `snapshotNodes()` already read is pure overhead. It matters for embedders
1144
+ // using the public `createSessionRuntime` with PI_LEGION_DB and no
1145
+ // `getTreeNodes` — unreachable from the extension itself, which always passes
1146
+ // `getTreeNodes` — so keep the two call sites consistent.
779
1147
  dbEventSource = createDbEventSource(ledgerDb, {
1148
+ cursor: maxEventId(ledgerDb),
780
1149
  onError: deps.onDbEventSourceError,
781
1150
  });
782
1151
  if (deps.eventBus) dbEventSource.start(deps.eventBus);
@@ -969,6 +1338,9 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
969
1338
  existing.model = input.model;
970
1339
  existing.thinking = input.thinking;
971
1340
  existing.worktreeBranch = input.worktreeBranch;
1341
+ // Only a known value overwrites: a re-follow without the handle must
1342
+ // not erase the mode an earlier launch established.
1343
+ if (input.spawnMode !== undefined) existing.spawnMode = input.spawnMode;
972
1344
  if (input.nodeId !== undefined || identity.nodeId !== undefined) existing.nodeId = input.nodeId ?? identity.nodeId;
973
1345
  if (input.parentId !== undefined || identity.parentId !== undefined) existing.parentId = input.parentId ?? identity.parentId;
974
1346
  if (input.depth !== undefined || identity.depth !== undefined) existing.depth = input.depth ?? identity.depth;
@@ -1308,7 +1680,10 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
1308
1680
  if (index >= 0) result[index] = next;
1309
1681
  else result.push(next);
1310
1682
  }
1311
- return result;
1683
+ // The synthesized nodes above are built from job facts, not from a DB
1684
+ // row, so they carry no `spawnMode` of their own: run the same merge
1685
+ // the snapshot path uses before handing the array out (#209/D5).
1686
+ return mergeSpawnModes(result);
1312
1687
  },
1313
1688
 
1314
1689
  statusEntries() {
@@ -1412,6 +1787,28 @@ function latestActivityAt(...candidates: Array<number | undefined>): number | un
1412
1787
  return best;
1413
1788
  }
1414
1789
 
1790
+ /**
1791
+ * Resident memory of a tracked job in MB, or `undefined` when there is no
1792
+ * honest number to report (D2).
1793
+ *
1794
+ * Only an `rpc` child owns a process. An `inprocess` child runs inside this
1795
+ * host, so it has no RSS of its own: apportioning the host figure across
1796
+ * children would invent data the user then decides on. Those rows stay blank
1797
+ * and the host figure is reported once, by name, elsewhere.
1798
+ *
1799
+ * §6 pid-reuse race: a pid outlives nothing — once the child exits, the kernel
1800
+ * may hand the same number to an unrelated process, and its RSS would land on
1801
+ * this row as the child's memory. So the pid is only read while the caller's
1802
+ * liveness check says the child is still up; a job with no `isAlive` hook
1803
+ * (legacy / re-followed record) has unverifiable liveness and stays blank too.
1804
+ */
1805
+ function rssFor(job: TrackedJob): number | undefined {
1806
+ if (job.spawnMode === "inprocess" || job.pid === undefined) return undefined;
1807
+ // `isAlive` undefined is not permission to read: unknown is not alive.
1808
+ if (!job.isAlive || !job.isAlive()) return undefined;
1809
+ return readProcRss(job.pid)?.rssMb;
1810
+ }
1811
+
1415
1812
  function statusEntryFromJob(
1416
1813
  job: TrackedJob,
1417
1814
  identity?: LegionTreeIdentity,
@@ -1429,6 +1826,7 @@ function statusEntryFromJob(
1429
1826
  : executionStatus === "failed" || executionStatus === "truncated" || executionStatus === undefined ? "failed"
1430
1827
  : "retired";
1431
1828
  const finishedAt = terminalNode ? node.updatedAt : job.settledAt;
1829
+ const rss = rssFor(job);
1432
1830
  const lastActivityAt = latestActivityAt(
1433
1831
  node?.updatedAt,
1434
1832
  live.currentTurnStartedAt,
@@ -1461,6 +1859,10 @@ function statusEntryFromJob(
1461
1859
  ? { tokensOut: live.tokensOut }
1462
1860
  : node?.tokensOut ? { tokensOut: node.tokensOut } : {}),
1463
1861
  ...(live.generationMs ? { generationMs: live.generationMs } : {}),
1862
+ ...(live.contextTokens ? { contextTokens: live.contextTokens } : {}),
1863
+ ...(job.pid ? { pid: job.pid } : {}),
1864
+ ...(job.spawnMode ? { spawnMode: job.spawnMode } : {}),
1865
+ ...(rss !== undefined ? { rss } : {}),
1464
1866
  ...(lastActivityAt !== undefined ? { lastActivityAt } : {}),
1465
1867
  ...(identity ? {
1466
1868
  nodeId: identity.nodeId,
@@ -1497,9 +1899,11 @@ function statusEntryFromNode(
1497
1899
  ...(node.phase ? { phase: node.phase } : {}),
1498
1900
  ...(node.kind ? { kind: node.kind } : {}),
1499
1901
  ...(node.model ? { model: node.model } : {}),
1902
+ ...(node.spawnMode ? { spawnMode: node.spawnMode } : {}),
1500
1903
  ...(live.usageTotal ? { tokensTotal: live.usageTotal } : node.tokensTotal ? { tokensTotal: node.tokensTotal } : {}),
1501
1904
  ...(tokensOut ? { tokensOut } : {}),
1502
1905
  ...(live.generationMs ? { generationMs: live.generationMs } : {}),
1906
+ ...(live.contextTokens ? { contextTokens: live.contextTokens } : {}),
1503
1907
  ...(activity?.turn !== undefined ? { turns: activity.turn } : {}),
1504
1908
  ...(activity?.turnStartedAt !== undefined ? { currentTurnStartedAt: activity.turnStartedAt } : {}),
1505
1909
  ...(activity?.turnEndedAt !== undefined ? { currentTurnEndedAt: activity.turnEndedAt } : {}),
@@ -14,7 +14,6 @@ import type {
14
14
  } from "../shared/types.ts";
15
15
  import { classifyModelOrigin, resolveStepModel } from "../agents/step-model.ts";
16
16
  import { checkModelScope } from "../agents/model-scope.ts";
17
- import { nativeModelFor } from "../runs/kind.ts";
18
17
  import {
19
18
  DEFAULT_TEAM,
20
19
  TEAM_ENV,
@@ -199,7 +198,6 @@ function renderOneAgent(
199
198
  lines.push(` ${agent.description}`);
200
199
 
201
200
  const details: string[] = [];
202
- if (agent.kind !== "pi") details.push(`kind=${agent.kind}`);
203
201
  if (agent.alias?.length) details.push(`alias: ${agent.alias.join(", ")}`);
204
202
 
205
203
  // Model resolution mirrors the launch path (resolveStepModel) so the line
@@ -247,9 +245,6 @@ function renderOneAgent(
247
245
  * params are empty).
248
246
  * - `checkModelScope` — an out-of-scope model is flagged exactly like
249
247
  * `launchStep` would flag it (error-severity for explicit models).
250
- * - `nativeModelFor` — a model the target kind cannot express is either a
251
- * launch refusal (explicit origin, mirroring `planModelCandidates`) or a
252
- * documented drop to the CLI's own default (inherited origin).
253
248
  */
254
249
  function modelLine(
255
250
  agent: AgentConfig,
@@ -275,22 +270,6 @@ function modelLine(
275
270
  );
276
271
  }
277
272
 
278
- // Kind/model coherence: a model the target CLI cannot express.
279
- if (agent.kind !== "pi") {
280
- const native = nativeModelFor(agent.kind, model, agent.thinking);
281
- if (native === undefined) {
282
- if (origin === "explicit") {
283
- parts.push(
284
- `✗ cannot be used with kind '${agent.kind}' — a launch would refuse this`,
285
- );
286
- } else {
287
- parts.push(
288
- `dropped at launch: kind '${agent.kind}' runs on its own default`,
289
- );
290
- }
291
- }
292
- }
293
-
294
273
  return parts.join(" · ");
295
274
  }
296
275