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
@@ -33,7 +33,9 @@ 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";
38
+ import { isTerminalState, liveness } from "./liveness.ts";
37
39
  import {
38
40
  DEFAULTS,
39
41
  type AgentKind,
@@ -50,6 +52,7 @@ import { createDbEventSource, type DbEventSource } from "./event-bus.ts";
50
52
  import { openLegionDb, type DatabaseSync } from "../legion/db.ts";
51
53
  import { getSubtree } from "../legion/nodes.ts";
52
54
  import { attachUsageTotals } from "../legion/usage.ts";
55
+ import { maxEventId } from "../legion/events.ts";
53
56
  import type { TreeNodeSnapshot } from "../tui/tree-view.ts";
54
57
 
55
58
  export interface CollectSnapshot {
@@ -87,6 +90,12 @@ export interface TrackedJobInput {
87
90
  * unknown rather than a guessed `rpc`.
88
91
  */
89
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;
90
99
  /** Legion tree identity supplied by the host when the M2 ChildRecord is available. */
91
100
  nodeId?: string;
92
101
  parentId?: string | null;
@@ -162,6 +171,16 @@ export interface SessionRuntimeDeps {
162
171
  }
163
172
 
164
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;
165
184
  /**
166
185
  * Hard cap on how long a completion notice may stay hidden behind a live
167
186
  * parent agent run (D-B). 80s = 2× the (1+3)×10s that the old join window
@@ -245,6 +264,20 @@ export interface LiveChildStatusInput {
245
264
  * by the owner marker instead. Only consulted when `alive` is false.
246
265
  */
247
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;
248
281
  now?: number;
249
282
  }
250
283
 
@@ -313,6 +346,52 @@ function ownerLine(owner: "live" | "dead" | "unknown"): string {
313
346
  return `owner: ${owner}`;
314
347
  }
315
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
+
316
395
  /**
317
396
  * Status text for one child (`/legion status <name>` and `subagent status`).
318
397
  *
@@ -365,6 +444,12 @@ export function renderLiveChildStatus(input: LiveChildStatusInput): string {
365
444
  `${acceptanceLabel}: ${input.acceptance.status}${input.acceptance.level ? ` (${input.acceptance.level})` : ""}`,
366
445
  );
367
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);
368
453
  lines.push(...meta);
369
454
  if (stale) {
370
455
  const persistedExecution = input.execution
@@ -616,6 +701,35 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
616
701
  });
617
702
  join.setConfig(joinConfig);
618
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
+
619
733
  const snapshotNodes = (): TreeNodeSnapshot[] => {
620
734
  const ledger = ledgerDb;
621
735
  const provided = deps.getTreeNodes
@@ -624,18 +738,72 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
624
738
  if (provided !== undefined) {
625
739
  tree.clear();
626
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()`.
627
753
  const override = eventOverrides.get(node.id);
628
- if (override && override.ts >= node.updatedAt) {
629
- tree.set(node.id, {
630
- ...node,
631
- ...(override.status === undefined ? {} : { status: override.status }),
632
- ...(override.phase === undefined ? {} : { phase: override.phase }),
633
- updatedAt: override.ts,
634
- });
635
- } else {
636
- if (override) eventOverrides.delete(node.id);
637
- tree.set(node.id, { ...node });
638
- }
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);
639
807
  }
640
808
  for (const nodeId of activity.keys()) if (!tree.has(nodeId)) activity.delete(nodeId);
641
809
  for (const nodeId of eventOverrides.keys()) if (!tree.has(nodeId)) eventOverrides.delete(nodeId);
@@ -760,16 +928,29 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
760
928
  const data = event.data && typeof event.data === "object" && !Array.isArray(event.data)
761
929
  ? event.data as Record<string, unknown>
762
930
  : undefined;
763
- 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.
764
944
  const previous = eventOverrides.get(event.nodeId);
765
945
  if (!previous || event.ts >= previous.ts) {
766
946
  const override = { ...previous, ts: event.ts };
767
- const updated = { ...node, updatedAt: event.ts };
947
+ const updated = { ...node };
768
948
  if (event.type === "node_launched" || event.type === "node_resumed") updated.status = override.status = "running";
769
949
  else if (event.type === "node_settled") updated.status = override.status = "settled";
770
950
  else if (event.type === "node_failed") updated.status = override.status = "failed";
771
951
  else if (event.type === "node_retired" && node.status !== "settled" && node.status !== "failed") updated.status = override.status = "retired";
772
952
  else if (event.type === "node_blocked") updated.status = override.status = "blocked";
953
+ else if (event.type === "node_abandoned") updated.status = override.status = "failed";
773
954
  else if (event.type === "phase_change" && typeof data?.to === "string") updated.phase = override.phase = data.to;
774
955
  eventOverrides.set(event.nodeId, override);
775
956
  tree.set(event.nodeId, updated);
@@ -795,10 +976,156 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
795
976
  }
796
977
  };
797
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
+
798
1112
  const onWidgetEvent = (event: LegionEvent): void => {
799
- 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();
800
1127
  applyTreeEvent(event);
801
- refreshUi();
1128
+ scheduleTreeFlush();
802
1129
  };
803
1130
  const busUnsubscribers = deps.eventBus
804
1131
  ? LEGION_EVENT_TYPES
@@ -810,7 +1137,15 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
810
1137
  try {
811
1138
  ledgerDb = openLegionDb(process.env.PI_LEGION_DB);
812
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.
813
1147
  dbEventSource = createDbEventSource(ledgerDb, {
1148
+ cursor: maxEventId(ledgerDb),
814
1149
  onError: deps.onDbEventSourceError,
815
1150
  });
816
1151
  if (deps.eventBus) dbEventSource.start(deps.eventBus);
@@ -1452,6 +1787,28 @@ function latestActivityAt(...candidates: Array<number | undefined>): number | un
1452
1787
  return best;
1453
1788
  }
1454
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
+
1455
1812
  function statusEntryFromJob(
1456
1813
  job: TrackedJob,
1457
1814
  identity?: LegionTreeIdentity,
@@ -1469,6 +1826,7 @@ function statusEntryFromJob(
1469
1826
  : executionStatus === "failed" || executionStatus === "truncated" || executionStatus === undefined ? "failed"
1470
1827
  : "retired";
1471
1828
  const finishedAt = terminalNode ? node.updatedAt : job.settledAt;
1829
+ const rss = rssFor(job);
1472
1830
  const lastActivityAt = latestActivityAt(
1473
1831
  node?.updatedAt,
1474
1832
  live.currentTurnStartedAt,
@@ -1501,6 +1859,10 @@ function statusEntryFromJob(
1501
1859
  ? { tokensOut: live.tokensOut }
1502
1860
  : node?.tokensOut ? { tokensOut: node.tokensOut } : {}),
1503
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 } : {}),
1504
1866
  ...(lastActivityAt !== undefined ? { lastActivityAt } : {}),
1505
1867
  ...(identity ? {
1506
1868
  nodeId: identity.nodeId,
@@ -1537,9 +1899,11 @@ function statusEntryFromNode(
1537
1899
  ...(node.phase ? { phase: node.phase } : {}),
1538
1900
  ...(node.kind ? { kind: node.kind } : {}),
1539
1901
  ...(node.model ? { model: node.model } : {}),
1902
+ ...(node.spawnMode ? { spawnMode: node.spawnMode } : {}),
1540
1903
  ...(live.usageTotal ? { tokensTotal: live.usageTotal } : node.tokensTotal ? { tokensTotal: node.tokensTotal } : {}),
1541
1904
  ...(tokensOut ? { tokensOut } : {}),
1542
1905
  ...(live.generationMs ? { generationMs: live.generationMs } : {}),
1906
+ ...(live.contextTokens ? { contextTokens: live.contextTokens } : {}),
1543
1907
  ...(activity?.turn !== undefined ? { turns: activity.turn } : {}),
1544
1908
  ...(activity?.turnStartedAt !== undefined ? { currentTurnStartedAt: activity.turnStartedAt } : {}),
1545
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
 
@@ -617,7 +617,7 @@ export function formatSubagentDetail(input: SummaryDetailInput): string {
617
617
  if (child.thinking !== undefined) stateBits.push(`thinking: ${child.thinking}`);
618
618
  const lines = [title, ` ${stateBits.join(" · ")}`];
619
619
 
620
- const runBits = [`run: ${run.runId}`, `supervisor: ${run.herdr.supervisor ?? "legacy"}`];
620
+ const runBits = [`run: ${run.runId}`, `supervisor: ${run.herdr.supervisor ?? "unknown"}`];
621
621
  lines.push(` ${runBits.join(" · ")}`);
622
622
 
623
623
  const startedAtMs = parseTimestamp(child.spawnedAt);