pi-legion 3.0.3 → 3.1.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.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,54 @@ summary so you can see **2.x vs 0.16** without leaving the tree.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [3.1.0] — 2026-10-19
15
+
16
+ ### Added
17
+
18
+ - **The arbitrated launch backend is now visible (#209).** In-process has been
19
+ the default since #197, but the backend a launch actually resolved to only
20
+ ever appeared as a substring of one result line, so a user could not tell
21
+ `inprocess` from `rpc`. Three surfaces now name it, always from
22
+ `Handle.spawnMode` (the arbitration result) and never re-derived:
23
+ - `subagent` and `vexillatio` results carry `details.spawns[]` /
24
+ `handles[].spawnMode`, and a shared `renderResult`
25
+ (`src/extension/spawn-render.ts`) prepends `spawn: scout-0=inprocess` to the
26
+ tool card. The line lives in `details`, not `content`, so it stays out of
27
+ the LLM context.
28
+ - The `/legion` node modal footer appends `spawn <mode>` through
29
+ `TrackedJobInput` → `TreeNodeSnapshot` → `LiveOverlayNode` → `metaPlain()`.
30
+ No `legion.db` column and no schema-version bump; the value is threaded from
31
+ the live job.
32
+ - An unknown or legacy mode is **omitted**, never guessed as `rpc`.
33
+
34
+ `pi` does not render a tool result's `details` on its own — `renderResult` is
35
+ its only consumer — so the field alone would have been invisible. The
36
+ renderer is hardened accordingly: a hostile `details` (throwing getter) is
37
+ treated as a missing fact instead of throwing into the host's silent
38
+ fallback, and `\r` / binary control bytes are normalized before the collapsed
39
+ line cap is counted, matching the host fallback's own sanitization.
40
+
41
+ ### Changed
42
+
43
+ - **Parent-facing guidance no longer calls every child an "RPC child".** The
44
+ playbook, the collect/retire notices and the async tail now say "child" and
45
+ describe in-process as the default, naming the rpc backend only where it is
46
+ genuinely meant. The internal settle reasons, the auto-doctor classifier
47
+ regex and the frozen interrupted head line are unchanged. The
48
+ `--no-extensions` guidance is backend-neutral again: in-process children do
49
+ not inherit host extensions either.
50
+
51
+ - **The railway lane only shows "last activity" once it is worth a word (#231).**
52
+ A lane used to end with `· 1s ago` on every running agent — the tallest,
53
+ least informative field in the label, and it crowded out the role/clocks
54
+ that actually distinguish lanes. Ages under a minute are now dropped
55
+ entirely; from 60s up the field reads `1m ago` / `2h ago` / `3d ago` as
56
+ before, and a missing timestamp still shows `—`. `formatActivityAgo`
57
+ returns `""` for the dropped case, so the field simply disappears from the
58
+ detail candidates instead of leaving a dangling separator.
59
+
60
+ Contract: `docs/spawn-mode-visibility-v1.md` (D1–D8, with the round-1 errata).
61
+
14
62
  ## [3.0.3] — 2026-10-19
15
63
 
16
64
  ### Changed
package/README.md CHANGED
@@ -244,7 +244,10 @@ commands without an LLM. Passing verification advances the squad to `done`.
244
244
  header shows the live count and tok/s (trailing 60s average). The lane
245
245
  label reads `role · <time since the last task started> / <lifetime> · <age
246
246
  of last activity>`; the leading clock keeps ticking through the quiet gap
247
- after a turn settles and freezes only when the agent itself ends. The lane
247
+ after a turn settles and freezes only when the agent itself ends. The
248
+ last-activity age only appears once it is at least a minute old — a lane
249
+ that just saw activity is already visibly live, so `1s ago` on every
250
+ running agent was noise; a missing timestamp still shows `—`. The lane
248
251
  column is measured from what the labels need and the time axis takes the
249
252
  rest (down to a floor), so a row always fills the line. Hidden when no
250
253
  subagent is live. Set `PI_LEGION_STRIP=tree` for the classic roster.
@@ -0,0 +1,206 @@
1
+ # Spawn-mode visibility + RPC copy cleanup — frozen contract v1
2
+
3
+ Status: frozen for implementation. Owner decisions are D1–D7; do not re-litigate.
4
+
5
+ ## Why
6
+
7
+ `DEFAULT_SPAWN_MODE = "inprocess"` (`src/supervisor/spawn-mode.ts:18`) has shipped,
8
+ but the parent-facing guidance still says every child is an "RPC child", and the
9
+ actual backend (`handle.spawnMode`) is only visible as a substring of a result
10
+ text line. Users cannot tell inprocess from rpc from the UI.
11
+
12
+ Facts established by recon (do not re-verify):
13
+
14
+ - pi never renders tool-result `details` on its own. The only consumer is
15
+ `renderResult` (`tool-execution.js:243`); with no renderer the host falls back
16
+ to `content` text only. Adding a `details` field alone is invisible.
17
+ - `subagent` / `vexillatio` register **no** renderer (`index.ts:708`, `index.ts:973`).
18
+ - `Handle.spawnMode: SpawnMode` is non-optional and is the arbitrated backend
19
+ (`src/shared/types.ts:482-503`, produced at `src/runs/orchestrator.ts:809-818`).
20
+ - `LaunchSession.handles` is `string[]` (`index.ts:1831`) — the mode is dropped there.
21
+ - The `/legion` node modal footer is `metaPlain()` (`src/tui/live-overlay.ts:737-756`)
22
+ rendering `role · model · turn N (Xs) · elapsed · status`.
23
+ - `legion.nodes` has **no** spawn_mode column (`src/legion/db.ts:47-67`).
24
+
25
+ ## D1 — Wording (backend-neutral) for parent guidance
26
+
27
+ Children are **in-process by default**; a separate `pi` process is used only when
28
+ the launch needs a worktree (centurion) or the role pins `spawnMode: rpc`.
29
+
30
+ Replace "RPC child / headless RPC children / started via RPC / RPC launch" with
31
+ backend-neutral phrasing in these exact places:
32
+
33
+ | File:line | Current |
34
+ | --- | --- |
35
+ | `src/extension/playbook.ts:12` | `Child processes are headless RPC children; no pane or tab is created.` |
36
+ | `src/extension/playbook.ts:13` | `when each RPC child finishes` |
37
+ | `src/extension/playbook.ts:15` | `A collect timeout with a live RPC child` |
38
+ | `src/extension/playbook.ts:25` | `children use --no-extensions` (keep only if still true for the rpc path; phrase as `rpc children use --no-extensions`) |
39
+ | `index.ts:1669` | `playbook: … → RPC launch → watch` |
40
+ | `index.ts:1966` | `playbook: … started via RPC` |
41
+ | `index.ts:1801` | `when each RPC child finishes` |
42
+ | `index.ts:1616` | `(live RPC child steered)` |
43
+ | `index.ts:1574` | `RPC process stopped; session kept for resume:` |
44
+ | `src/extension/recycle.ts:10` | `the RPC child is already stopped` |
45
+ | `src/extension/notify.ts:200` | `RPC child retired.` |
46
+ | `src/extension/notify.ts:237` | `(RPC child is not alive)` |
47
+ | `src/extension/notify.ts:315` | `(RPC child retired)` |
48
+
49
+ Use `child` as the noun. Suggested replacement vocabulary (one word list, keep it
50
+ consistent): `child`, `in-process child`, `rpc child` (lowercase, only when the
51
+ rpc backend is meant), `session`, `watch`.
52
+
53
+ ## D2 — Out of scope for D1 (do NOT touch)
54
+
55
+ These are either internal identifiers or the genuine rpc backend; changing them
56
+ breaks failure classification or contracts:
57
+
58
+ - `src/supervisor/rpc-supervisor.ts` reasons (`Pi RPC process exited …`, etc.)
59
+ - `src/extension/auto-doctor.ts:50` regex (matches `Pi RPC process exited`)
60
+ - `src/runs/orchestrator.ts:146` `TRANSIENT_CONNECTION_REASON` value
61
+ - `src/runs/orchestrator.ts:579/782`, `src/runs/hygiene.ts`, `--no-extensions`
62
+ plumbing, `rpc-child-*` temp dir prefixes, `subagents.rpc.*` keys
63
+ - the frozen head line `interrupted (parent turn ended; children still running)`
64
+ - `docs/contracts/**`
65
+
66
+ ## D3 — `details` carries the mode (machine facts)
67
+
68
+ Additive only; never replace an existing field, never emit `isError`.
69
+
70
+ `subagent` launch-family success + interrupted results gain:
71
+
72
+ ```ts
73
+ spawns?: Array<{ name: string; agent: string; spawnMode: "rpc" | "inprocess" }>
74
+ ```
75
+
76
+ - Emitted only when at least one handle resolved (omitted otherwise, matching the
77
+ existing `stillRunning?` "present only when non-empty" convention).
78
+ - Values come from `handle.spawnMode` (`Handle`, never re-derived, never guessed).
79
+ - Sites: `index.ts:1754` (interrupted) and `index.ts:1807` (normal).
80
+ The interrupted path runs after `handles` is cleared, so keep the resolved
81
+ entries in a parallel field on `LaunchSession` (precedent: `timeoutByName`).
82
+
83
+ `vexillatio` gains `spawnMode` on each handle object:
84
+
85
+ ```ts
86
+ handles: Array<{ name: string; agent: string; sessionFile: string; spawnMode?: SpawnMode }>
87
+ ```
88
+
89
+ `spawnMode` is optional so `ChildRecord.spawnMode?: SpawnMode` legacy runs
90
+ (`src/shared/types.ts:365-373`) render as absent, never as `"rpc"`.
91
+ `docs/vexillatio-contract.md:57` freezes top-level details keys only, so this is
92
+ allowed; do not change that doc.
93
+
94
+ ## D4 — TUI renderer for both tools (this is what makes D3 visible)
95
+
96
+ New file `src/extension/spawn-render.ts`:
97
+
98
+ ```ts
99
+ export function spawnModeSummary(details: unknown): string | undefined
100
+ export function renderSpawnResult(
101
+ result: AgentToolResult<unknown>,
102
+ opts: { expanded: boolean; isPartial: boolean },
103
+ theme: Theme,
104
+ ): Component
105
+ ```
106
+
107
+ Behaviour:
108
+
109
+ 1. `spawnModeSummary` reads `details.spawns` and/or `details.handles[].spawnMode`
110
+ defensively (unknown → undefined). Output: `spawn: scout-0=inprocess`
111
+ or `spawn: worker-0=rpc, scout-1=inprocess`. Entries with an unknown mode are
112
+ skipped; if none survive, return `undefined`.
113
+ 2. `renderSpawnResult` builds the text from `content` text blocks only
114
+ (`stripTerminalSequences`), prepends the summary line when defined, then
115
+ collapsed → first 10 lines + `... (N more lines, ctrl+o to expand)`,
116
+ expanded → all lines. Returns `new Text(text, 0, 0)`.
117
+ 3. It must never throw: wrap the details access in optional chaining; a throw is
118
+ silently swallowed by the host and would blank the card.
119
+
120
+ Attach `renderResult: (result, opts, theme) => renderSpawnResult(result, opts, theme)`
121
+ to both `subagent` (`index.ts:973`) and `vexillatio` (`index.ts:708`).
122
+ No `renderCall`, no `renderShell: "self"`.
123
+
124
+ Rationale: `content` is model-facing, `details` is TUI-facing (`docs/design.md:258`).
125
+ The summary line goes through `details` so it does not enter the LLM context.
126
+
127
+ ## D5 — `/legion` node modal footer shows the same value
128
+
129
+ Thread the arbitrated mode to the overlay without a DB migration:
130
+
131
+ 1. `TrackedJobInput` (`src/extension/runtime.ts:68`) gains `spawnMode?: SpawnMode`.
132
+ 2. Launch sites pass `handle.spawnMode` (`index.ts` ~1914 and the follow sites
133
+ ~2709-2790 that already carry the handle/record).
134
+ 3. `TreeNodeSnapshot` (`src/tui/tree-view.ts:22`) gains `spawnMode?: SpawnMode`;
135
+ `snapshotNodes()` (`src/extension/runtime.ts:608`) merges the job's value onto
136
+ the node snapshot (`job.spawnMode`, matched by nodeId/name, no guessing).
137
+ 4. `liveNode()` (`src/extension/legion-commands.ts:298`) maps it through
138
+ `LiveOverlayNode` (`src/tui/live-overlay.ts:25`).
139
+ 5. `metaPlain()` appends `spawn <mode>` **only when known**; legacy/unknown →
140
+ omit the segment entirely (never print `rpc` as a default).
141
+
142
+ Do **not** add a `legion.db` column and do **not** bump `LEGION_DB_USER_VERSION`.
143
+
144
+ ## D6 — Tests (must be updated/added by the same change)
145
+
146
+ Update (currently lock the old wording):
147
+
148
+ - `test/unit/playbook.test.ts:17` `/headless RPC children/`, `:76` `/RPC child is a progress signal/`
149
+ - `test/unit/notify.test.ts:52,163,169,398,410-411`
150
+ - `test/unit/recycle.test.ts:11`
151
+ - `test/unit/runtime.test.ts:1252`
152
+
153
+ Add:
154
+
155
+ - `spawnModeSummary` returns `undefined` for `{}`/unknown/legacy, and the
156
+ documented string for both launch-family and vexillatio details shapes.
157
+ - `renderSpawnResult` collapsed keeps the summary line and caps at 10 lines +
158
+ hint; expanded prints everything.
159
+ - launch details expose `spawns` with the arbitrated value (extend an existing
160
+ harness that already inspects `details`, e.g. `test/unit/teams.test.ts:1320`).
161
+ - `metaPlain` includes `spawn rpc` / `spawn inprocess` when set and omits it when
162
+ unknown (`test/unit/legion-live-overlay.test.ts`).
163
+
164
+ ## Errata (review round 1, accepted)
165
+
166
+ - **D3/D6 (line numbers)**: the D1 table merged two `index.ts` copy sites into
167
+ `playbook.ts` rows. The real sites are `playbook.ts:12/14/15` plus
168
+ `index.ts:1669/1801/1966/1616/1574`. Behaviour unchanged.
169
+ - **D1 (`--no-extensions`)**: the suggested wording `rpc children use
170
+ --no-extensions` was wrong — in-process children run with
171
+ `noExtensions = !childInheritsExtensions(...)` too
172
+ (`src/supervisor/inprocess-supervisor.ts:145`). The sentence must stay
173
+ backend-neutral; do not name `rpc` there.
174
+ - **D4 (never-throw)**: shape checks are not enough. A `details` object with a
175
+ throwing getter still throws, and the host silently swallows it
176
+ (`tool-execution.js:150-160`). `spawnModeSummary` and `renderSpawnResult` must
177
+ both be wrapped in `try/catch`. Covered by a getter/Proxy test.
178
+ - **D4 (line cap)**: the collapsed cap must count rendered lines, so `\r` and
179
+ binary control bytes are stripped **before** splitting — parity with the host
180
+ fallback (`render-utils.js:40`: `sanitizeBinaryOutput` + `.replace(/\r/g,"")`).
181
+ Without it a `\r` progress stream renders 21 lines and never shows the hint.
182
+ - **D4 (dedupe)**: one `name=mode` entry per child, first occurrence wins.
183
+ - **D5 (`treeNodes()`)**: `snapshotNodes()` alone is not enough. `treeNodes()`
184
+ synthesizes a `TreeNodeSnapshot` for a job with identity but no DB row
185
+ (`runtime.ts:1311-1331`), and that literal dropped `spawnMode`, so the modal
186
+ footer lost the segment in the plain-session / pre-DB-row window. Merge in
187
+ `treeNodes()` (shared helper), not only in `snapshotNodes()`.
188
+ - **D5 (width)**: `spawn <mode>` appended last is truncated to `spawn inpr...`
189
+ at the default 80-column layout (`resolveModalLayout(24,80)` → `innerWidth=78`).
190
+ Put the segment before `model` so the role+status survive, and test with a
191
+ realistic model id.
192
+
193
+ ## D8 — Commit ownership
194
+
195
+ Implementation lands as a single commit **created by the parent agent**.
196
+ Sub-agents must not `git commit`, `git push`, or merge. Round 1 violated this
197
+ (`80890ba` was authored by the worker); it is amended by the parent in round 2.
198
+
199
+ ## D7 — Acceptance
200
+
201
+ - `npm run typecheck`
202
+ - `npm test`
203
+ - `npm run test:integration`
204
+ - No new `RPC child` occurrences in parent-visible strings: the D1 table is empty
205
+ afterwards, and `rg -n 'RPC child|headless RPC|via RPC' src index.ts` returns
206
+ only the files listed in D2.
package/index.ts CHANGED
@@ -83,6 +83,7 @@ import {
83
83
  SUBAGENT_NOTIFY_TYPE,
84
84
  } from "./src/extension/notify.ts";
85
85
  import { renderSubagentNotice } from "./src/extension/notice-renderer.ts";
86
+ import { renderSpawnResult } from "./src/extension/spawn-render.ts";
86
87
  import { PARENT_PLAYBOOK, TOOL_DESCRIPTION } from "./src/extension/playbook.ts";
87
88
  import { configureSessionErrors, recordSessionError } from "./src/extension/session-errors.ts";
88
89
  import { getAgentDir } from "./src/agents/paths.ts";
@@ -710,6 +711,9 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
710
711
  label: "Vexillatio",
711
712
  description: "Launch only read-only utility agents and return their collected output.",
712
713
  parameters: VexillatioParams,
714
+ // D4: pi never renders `details` on its own, so without this the
715
+ // arbitrated spawnMode in `details.handles[].spawnMode` is invisible.
716
+ renderResult: (result, opts, theme) => renderSpawnResult(result, opts, theme),
713
717
  async execute(_id, params, signal, _onUpdate, ctx: ExtensionContext): Promise<AgentToolResult<unknown>> {
714
718
  if (signal?.aborted) {
715
719
  return ok("interrupted (parent turn ended; children still running)");
@@ -975,6 +979,9 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
975
979
  description: TOOL_DESCRIPTION,
976
980
  parameters: SubagentParams,
977
981
 
982
+ // D4: the same renderer as `vexillatio` — it reads both detail shapes.
983
+ renderResult: (result, opts, theme) => renderSpawnResult(result, opts, theme),
984
+
978
985
  // The parameter list is fixed by Pi's `ExtensionAPI.registerTool`
979
986
  // contract — an options object is not ours to choose.
980
987
  // pi-lens-ignore: long-parameter-list
@@ -1121,7 +1128,18 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
1121
1128
  onUpdate,
1122
1129
  });
1123
1130
  if (signal?.aborted) {
1124
- return ok("interrupted (parent turn ended; children still running)");
1131
+ // The frozen head line still leads, but the machine facts must
1132
+ // survive: `launchFamily` already returns its own interrupted result
1133
+ // (frozen head + `runId` + `spawns`) when it observes the abort; this
1134
+ // outer net only catches an abort seen after that check. Replacing the
1135
+ // whole result with a bare `ok()` used to drop `details.spawns` — the
1136
+ // only record of the arbitrated backend once `handles` is cleared (#209/D3).
1137
+ const details = result.details;
1138
+ const carried =
1139
+ details !== null && typeof details === "object" && !Array.isArray(details)
1140
+ ? (details as Record<string, unknown>)
1141
+ : undefined;
1142
+ return ok("interrupted (parent turn ended; children still running)", carried);
1125
1143
  }
1126
1144
  return result;
1127
1145
  },
@@ -1554,7 +1572,7 @@ async function collectChild(
1554
1572
  }
1555
1573
  }
1556
1574
 
1557
- /** Retire the RPC child while preserving its outcome and session for resume. */
1575
+ /** Retire the child while preserving its outcome and session for resume. */
1558
1576
  async function retireChild(
1559
1577
  ctx: ChildContext,
1560
1578
  ): Promise<AgentToolResult<unknown>> {
@@ -1571,7 +1589,7 @@ async function retireChild(
1571
1589
  await persistChild(ctx.store, ctx.found.runId, ctx.orchestrator, ctx.name);
1572
1590
  return ok(
1573
1591
  `Retired ${ctx.name} (execution=${child.execution?.status ?? "unknown"}). ` +
1574
- `RPC process stopped; session kept for resume: ${child.sessionFile}`,
1592
+ `Child stopped; session kept for resume: ${child.sessionFile}`,
1575
1593
  );
1576
1594
  } catch (error) {
1577
1595
  return fail(`retire failed: ${String(error)}`, ErrorCodes.RETIRE_FAILED);
@@ -1613,7 +1631,7 @@ async function reviveChild(
1613
1631
  await orchestrator.steer(name, params.message);
1614
1632
  followChild(ctx, { watch: true });
1615
1633
  return ok(
1616
- `${action === "resume" ? "Resumed" : "Continued"} ${name} (live RPC child steered).`,
1634
+ `${action === "resume" ? "Resumed" : "Continued"} ${name} (live child steered).`,
1617
1635
  );
1618
1636
  }
1619
1637
 
@@ -1666,7 +1684,7 @@ async function launchFamily(input: {
1666
1684
  content: [
1667
1685
  {
1668
1686
  type: "text",
1669
- text: `playbook: ${plan.steps.map((s) => s.agent).join(", ")} → RPC launch → watch`,
1687
+ text: `playbook: ${plan.steps.map((s) => s.agent).join(", ")} → launch → watch`,
1670
1688
  },
1671
1689
  ],
1672
1690
  details: {},
@@ -1742,16 +1760,26 @@ async function launchFamily(input: {
1742
1760
  results: [],
1743
1761
  handles: [],
1744
1762
  timeoutByName: new Map(),
1763
+ // #209/D3: the arbitrated mode survives `handles` being cleared on an
1764
+ // abort (interrupted results run after that), so it is kept per name
1765
+ // here — same precedent as `timeoutByName`.
1766
+ spawnByName: new Map(),
1745
1767
  ...(nameGate.name ? { launchName: nameGate.name } : {}),
1746
1768
  };
1747
1769
 
1770
+ /** `details.spawns` for the handles this request actually resolved. */
1771
+ const spawnDetails = () => {
1772
+ const spawns = [...session.spawnByName.values()];
1773
+ return spawns.length > 0 ? { spawns } : {};
1774
+ };
1775
+
1748
1776
  const interruptedResult = async (): Promise<AgentToolResult<unknown> | undefined> => {
1749
1777
  if (!input.signal?.aborted) return undefined;
1750
1778
  for (const name of session.handles) await abandonLaunchedChild(session, name);
1751
1779
  session.handles.length = 0;
1752
1780
  return {
1753
1781
  content: [{ type: "text", text: session.results.join("\n") || "interrupted (parent turn ended; children still running)" }],
1754
- details: { runId: run.runId, handles: [], async: params.async ?? true },
1782
+ details: { runId: run.runId, handles: [], async: params.async ?? true, ...spawnDetails() },
1755
1783
  };
1756
1784
  };
1757
1785
 
@@ -1798,13 +1826,13 @@ async function launchFamily(input: {
1798
1826
 
1799
1827
  if (isAsync && session.handles.length > 0) {
1800
1828
  session.results.push(
1801
- "↳ async: a completion message is queued when each RPC child finishes. It wakes this session when idle; otherwise it is delivered after the current turn. Use wait or collect for results. Do not tell children to message the parent.",
1829
+ "↳ async: a completion message is queued when each child finishes. It wakes this session when idle; otherwise it is delivered after the current turn. Use wait or collect for results. Do not tell children to message the parent.",
1802
1830
  );
1803
1831
  }
1804
1832
 
1805
1833
  return {
1806
1834
  content: [{ type: "text", text: session.results.join("\n") }],
1807
- details: { runId: run.runId, handles: session.handles, async: isAsync },
1835
+ details: { runId: run.runId, handles: session.handles, async: isAsync, ...spawnDetails() },
1808
1836
  };
1809
1837
  }
1810
1838
 
@@ -1828,6 +1856,8 @@ interface LaunchSession {
1828
1856
  results: string[];
1829
1857
  handles: string[];
1830
1858
  timeoutByName: Map<string, number>;
1859
+ /** Arbitrated backend per resolved handle; survives an abort clearing `handles`. */
1860
+ spawnByName: Map<string, { name: string; agent: string; spawnMode: SpawnMode }>;
1831
1861
  /** Explicit child name for this single-step launch; omit to auto-allocate. */
1832
1862
  launchName?: string;
1833
1863
  }
@@ -1939,6 +1969,13 @@ async function launchStep(
1939
1969
  await session.store.addChild(session.runId, handle.child);
1940
1970
  }
1941
1971
  session.handles.push(handle.name);
1972
+ // Recorded before the abort check below: an interrupted result runs after
1973
+ // `handles` is cleared and still has to name the arbitrated backend.
1974
+ session.spawnByName.set(handle.name, {
1975
+ name: handle.name,
1976
+ agent: effective.name,
1977
+ spawnMode: handle.spawnMode,
1978
+ });
1942
1979
  if (effective.timeoutMs !== undefined) {
1943
1980
  session.timeoutByName.set(handle.name, effective.timeoutMs);
1944
1981
  }
@@ -1963,7 +2000,7 @@ async function launchStep(
1963
2000
  content: [
1964
2001
  {
1965
2002
  type: "text",
1966
- text: `playbook: ${handle.name} started via RPC`,
2003
+ text: `playbook: ${handle.name} started (supervisor=${handle.spawnMode})`,
1967
2004
  },
1968
2005
  ],
1969
2006
  details: {},
@@ -2343,6 +2380,7 @@ async function launchUtilityFamily(input: {
2343
2380
  name: handle.name,
2344
2381
  agent: item.agent.name,
2345
2382
  sessionFile: handle.sessionFile,
2383
+ spawnMode: handle.spawnMode,
2346
2384
  orchestrator,
2347
2385
  retire,
2348
2386
  },
@@ -2358,6 +2396,7 @@ async function launchUtilityFamily(input: {
2358
2396
  name: string;
2359
2397
  agent: string;
2360
2398
  sessionFile: string;
2399
+ spawnMode: SpawnMode;
2361
2400
  orchestrator: Orchestrator;
2362
2401
  retire: (opts?: RetireOptions) => Promise<void>;
2363
2402
  }> = [];
@@ -2373,6 +2412,8 @@ async function launchUtilityFamily(input: {
2373
2412
  agent: handle.agent,
2374
2413
  sessionFile: handle.sessionFile,
2375
2414
  timeoutMs,
2415
+ // Arbitrated at launch; never re-derived here (#209).
2416
+ spawnMode: handle.spawnMode,
2376
2417
  orchestrator: handle.orchestrator,
2377
2418
  store,
2378
2419
  watch,
@@ -2426,7 +2467,7 @@ async function launchUtilityFamily(input: {
2426
2467
  content: [{ type: "text", text: results.join("\n\n") || "No utility agents were launched." }],
2427
2468
  details: {
2428
2469
  runId,
2429
- handles: handles.map(({ name, agent, sessionFile }) => ({ name, agent, sessionFile })),
2470
+ handles: handles.map(({ name, agent, sessionFile, spawnMode }) => ({ name, agent, sessionFile, spawnMode })),
2430
2471
  async: false,
2431
2472
  ...(stillRunning.size > 0
2432
2473
  ? { stillRunning: handles.filter(({ name }) => stillRunning.has(name)).map(({ name }) => name) }
@@ -2448,7 +2489,7 @@ async function launchUtilityFamily(input: {
2448
2489
  */
2449
2490
  function interruptedUtilityResult(input: {
2450
2491
  runId: string;
2451
- handles: Array<{ name: string; agent: string; sessionFile: string }>;
2492
+ handles: Array<{ name: string; agent: string; sessionFile: string; spawnMode?: SpawnMode }>;
2452
2493
  stillRunning: Set<string>;
2453
2494
  results: string[];
2454
2495
  collected: string[];
@@ -2462,7 +2503,13 @@ function interruptedUtilityResult(input: {
2462
2503
  // real consumer instead of being a dead signature (#227 review F3).
2463
2504
  return ok(segments.length > 0 ? `${head}\n\n${segments.join("\n\n")}` : head, {
2464
2505
  runId: input.runId,
2465
- handles: input.handles.map(({ name, agent, sessionFile }) => ({ name, agent, sessionFile })),
2506
+ handles: input.handles.map(({ name, agent, sessionFile, spawnMode }) => ({
2507
+ name,
2508
+ agent,
2509
+ sessionFile,
2510
+ // Optional on purpose: a legacy handle must render as absent, never `rpc`.
2511
+ ...(spawnMode ? { spawnMode } : {}),
2512
+ })),
2466
2513
  async: false,
2467
2514
  interrupted: true,
2468
2515
  ...(stillRunning.length > 0 ? { stillRunning } : {}),
@@ -2614,12 +2661,17 @@ function followJob(
2614
2661
  watch: boolean;
2615
2662
  retire?: (opts?: RetireOptions) => Promise<void>;
2616
2663
  treeIdentity?: { nodeId: string; parentId: string | null; depth: number };
2664
+ /** Arbitrated backend from `Handle.spawnMode`; omitted when unknown (#209). */
2665
+ spawnMode?: SpawnMode;
2617
2666
  },
2618
2667
  ): void {
2619
2668
  const child = input.orchestrator
2620
2669
  .childrenSnapshot()
2621
2670
  .find((entry) => entry.name === input.name);
2622
2671
  const existing = runtime.get(input.name);
2672
+ // The caller's Handle owns the arbitration; the persisted ChildRecord records
2673
+ // the same decision. Neither is re-derived here — no handle, no value.
2674
+ const spawnMode = input.spawnMode ?? child?.spawnMode;
2623
2675
  runtime.track({
2624
2676
  name: input.name,
2625
2677
  runId: input.runId,
@@ -2630,6 +2682,7 @@ function followJob(
2630
2682
  ...(child?.model ? { model: child.model } : {}),
2631
2683
  ...(child?.thinking !== undefined ? { thinking: child.thinking } : {}),
2632
2684
  ...(child?.worktreeBranch ? { worktreeBranch: child.worktreeBranch } : {}),
2685
+ ...(spawnMode ? { spawnMode } : {}),
2633
2686
  ...(input.treeIdentity ? input.treeIdentity : child?.nodeId ? {
2634
2687
  nodeId: child.nodeId,
2635
2688
  parentId: child.nodeId.includes(".") ? child.nodeId.slice(0, child.nodeId.lastIndexOf(".")) : null,
@@ -2706,6 +2759,19 @@ function followChild(ctx: ChildContext, opts: { watch: boolean }): void {
2706
2759
  });
2707
2760
  }
2708
2761
 
2762
+ /**
2763
+ * The arbitrated backend recorded for one handled child of this request.
2764
+ *
2765
+ * Spreads to nothing when unknown, so the caller never has to invent a value.
2766
+ */
2767
+ function spawnModeOf(
2768
+ session: LaunchSession,
2769
+ name: string,
2770
+ ): { spawnMode?: SpawnMode } {
2771
+ const recorded = session.spawnByName.get(name);
2772
+ return recorded ? { spawnMode: recorded.spawnMode } : {};
2773
+ }
2774
+
2709
2775
  function followLaunched(
2710
2776
  session: LaunchSession,
2711
2777
  name: string,
@@ -2723,6 +2789,8 @@ function followLaunched(
2723
2789
  orchestrator: session.orchestrator,
2724
2790
  store: session.store,
2725
2791
  watch: opts.watch,
2792
+ // Handle-derived, recorded before an abort can clear `handles` (#209).
2793
+ ...spawnModeOf(session, name),
2726
2794
  ...(child?.nodeId ? {
2727
2795
  treeIdentity: {
2728
2796
  nodeId: child.nodeId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-legion",
3
- "version": "3.0.3",
3
+ "version": "3.1.0",
4
4
  "description": "Pi Legion v2: recursive centurion trees on headless Pi RPC; shared SQLite WAL legion.db. Four-stage planning/implementing/reviewing/verifying state machine, parent/child/sibling legion_mail, three-layer /legion UI, upward MR trees, squad resume.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -312,6 +312,8 @@ function liveNode(
312
312
  ...(node.turns === undefined ? {} : { turns: node.turns }),
313
313
  sessionFile: node.sessionFile,
314
314
  worktreePath: node.worktreePath,
315
+ // #209/D5: absent for legacy nodes; the footer omits the segment entirely.
316
+ ...(node.spawnMode === undefined ? {} : { spawnMode: node.spawnMode }),
315
317
  };
316
318
  }
317
319
 
@@ -197,7 +197,7 @@ export function formatCompletionNotice(input: CompletionInput): CompletionNotice
197
197
  input.sessionFile ? `Session file: ${input.sessionFile}` : undefined,
198
198
  input.recycled === false
199
199
  ? undefined
200
- : "RPC child retired. Resume from the session file if you need this child again.",
200
+ : "Child retired. Resume from the session file if you need this child again.",
201
201
  ]
202
202
  .filter((line) => line !== undefined)
203
203
  .join("\n");
@@ -234,7 +234,7 @@ export function collectFailureInput(
234
234
  // Live children never reach here: watch() re-arms a connection blip.
235
235
  // A dead child's notice names that, instead of a bare "Connection error."
236
236
  const reason = isTransientConnectionFailure(raw)
237
- ? `${raw} (RPC child is not alive)`
237
+ ? `${raw} (child is not alive)`
238
238
  : raw;
239
239
  return {
240
240
  name,
@@ -275,8 +275,8 @@ export interface GroupedCompletionInput {
275
275
  * Still running: slow-2 (notifies separately when it finishes)
276
276
  *
277
277
  * Retirement is per-entry (a wait()-released sibling can race the flush, so a
278
- * blanket footer could be inaccurate): entries whose RPC child is retired
279
- * carry an inline "(RPC child retired)" marker.
278
+ * blanket footer could be inaccurate): entries whose child is retired
279
+ * carry an inline "(child retired)" marker.
280
280
  * Aggregate status: any failed → "failed"; else any stopped → "stopped";
281
281
  * else "completed". `display` follows the single-notice rule.
282
282
  */
@@ -311,8 +311,8 @@ export function formatGroupedNotice(
311
311
  ? ` — acceptance: ${entry.acceptance.status}${entry.acceptance.level ? ` (${entry.acceptance.level})` : ""}`
312
312
  : "";
313
313
  // Retirement is per-entry truth: a wait()-consumed sibling may already
314
- // be released while another RPC child is still alive, so no blanket footer.
315
- const recycled = entry.recycled === false ? "" : " (RPC child retired)";
314
+ // be released while another child is still alive, so no blanket footer.
315
+ const recycled = entry.recycled === false ? "" : " (child retired)";
316
316
  lines.push(`- ${label}: ${entry.status}${reason}${acceptance}${recycled}`);
317
317
  // Prefer the durable full-text artifact over the session file as the
318
318
  // recovery pointer: one `read` beats re-deriving from jsonl/chat store.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Parent-side RPC dispatch guidance shared by tool descriptions and prompts.
2
+ * Parent-side child dispatch guidance shared by tool descriptions and prompts.
3
3
  */
4
4
 
5
5
  export const PLAYBOOK_NAME = "pi-legion-playbook";
@@ -9,10 +9,10 @@ export const PARENT_PLAYBOOK = [
9
9
  "1. Call the `subagent` tool immediately with the matching role from the roster in this prompt. Do not substitute bash/curl/web fetch for a search or research role.",
10
10
  ' One child: subagent({ agent: "<role>", task: "...", name?: "scout-a" }) // optional name= registers that handle (not auto scout-N)',
11
11
  " Parallel: subagent({ tasks: [{ agent, task }, { agent, task }] })",
12
- "2. Prefer tasks[] for independent parallel work. Child processes are headless RPC children; no pane or tab is created.",
12
+ "2. Prefer tasks[] for independent parallel work. Children are in-process by default; a separate `pi` process is used only when the launch needs a worktree (centurion) or the role pins `spawnMode: rpc`. No pane or tab is created.",
13
13
  "3. Then return control. A completion message wakes this session when idle; if it is still working, the notice waits until the current turn ends. Use `wait` with `name` or `all: true` when you need blocking results, and `collect` to inspect a child's session JSONL. Esc / wait interrupt only ends the parent turn's wait — children keep running and settle still notifies; only an explicit `stop` or session exit retires them. A wait interrupted by Esc returns `interrupted (parent turn ended; children still running)`. In `pi --print` or JSON mode, session shutdown waits for live async children to settle (up to each child's turn timeout) before retiring them. Use same-turn `wait`/`collect` or `async: false` when that print turn must include the child's result.",
14
- "4. Later control is only through `subagent({ action, name?, message? })`; `status` with no name (or `all: true`) overviews live children, with `name` for one child; use `stop` to gracefully retire one child or all tracked children (`force: true` for hard retire when soft stop is insufficient). A `wait` timeout does not stop children — the result lists decisions (`extend` / `steer_again` / `stop` / `force_stop`) for the parent to choose. If a child exits oddly (Model not found, missing tools, memory refused, …), call `subagent({ action: \"diagnose\" })` or ask the user to run `/legion doctor` / `/legion doctor models` — children use `--no-extensions`, so provider packages must be passed explicitly. Do not tell a child to message or prompt the parent directly.",
15
- "5. A collect timeout with a live RPC child is a progress signal, not a verdict. Check status or session JSONL before deciding to steer or resume.",
14
+ "4. Later control is only through `subagent({ action, name?, message? })`; `status` with no name (or `all: true`) overviews live children, with `name` for one child; use `stop` to gracefully retire one child or all tracked children (`force: true` for hard retire when soft stop is insufficient). A `wait` timeout does not stop children — the result lists decisions (`extend` / `steer_again` / `stop` / `force_stop`) for the parent to choose. If a child exits oddly (Model not found, missing tools, memory refused, …), call `subagent({ action: \"diagnose\" })` or ask the user to run `/legion doctor` / `/legion doctor models` — children run without host extensions unless the role inherits them, so a provider package the child needs must be passed explicitly. Do not tell a child to message or prompt the parent directly.",
15
+ "5. A collect timeout with a live child is a progress signal, not a verdict. Check status or session JSONL before deciding to steer or resume.",
16
16
  "6. Isolation belongs to centurion. For N parallel isolated writers, dispatch N centurions (each owns one worktree); members stay `worktree: false` in-process. Passing `worktree: true` on a non-centurion warns once and is forced false.",
17
17
  "7. Launch with `verify: [\"npm run typecheck\", \"npm test\"]` when the project has a mechanical verifier. The extension runs those commands itself in the child worktree at collect time; the child does not need to paste test output.",
18
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).",
@@ -1,4 +1,4 @@
1
- /** Helpers for collect/retire calls made after an RPC child is auto-retired. */
1
+ /** Helpers for collect/retire calls made after a child is auto-retired. */
2
2
 
3
3
  import type { TaskState } from "../shared/types.ts";
4
4
 
@@ -7,7 +7,7 @@ export function formatAlreadyRecycled(
7
7
  sessionFile: string,
8
8
  ): string {
9
9
  return (
10
- `Already retired ${name}. This is a no-op — the RPC child is already stopped. ` +
10
+ `Already retired ${name}. This is a no-op — the child is already stopped. ` +
11
11
  `Session kept for resume: ${sessionFile}`
12
12
  );
13
13
  }
@@ -34,7 +34,12 @@ import {
34
34
  } from "../tui/status.ts";
35
35
  import { progressFromSessionFile } from "../shared/progress.ts";
36
36
  import { isTransientConnectionFailure } from "../shared/session.ts";
37
- import { DEFAULTS, type AgentKind, type RetireOptions } from "../shared/types.ts";
37
+ import {
38
+ DEFAULTS,
39
+ type AgentKind,
40
+ type RetireOptions,
41
+ type SpawnMode,
42
+ } from "../shared/types.ts";
38
43
  import type {
39
44
  EventSubscriptionOptions,
40
45
  SupervisorEvent,
@@ -76,6 +81,12 @@ export interface TrackedJobInput {
76
81
  model?: string;
77
82
  thinking?: string | false;
78
83
  worktreeBranch?: string;
84
+ /**
85
+ * Arbitrated backend for this child (`Handle.spawnMode`, #209). Optional:
86
+ * a job tracked from a legacy record has no value and must render as
87
+ * unknown rather than a guessed `rpc`.
88
+ */
89
+ spawnMode?: SpawnMode;
79
90
  /** Legion tree identity supplied by the host when the M2 ChildRecord is available. */
80
91
  nodeId?: string;
81
92
  parentId?: string | null;
@@ -629,7 +640,30 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
629
640
  for (const nodeId of activity.keys()) if (!tree.has(nodeId)) activity.delete(nodeId);
630
641
  for (const nodeId of eventOverrides.keys()) if (!tree.has(nodeId)) eventOverrides.delete(nodeId);
631
642
  }
632
- return [...tree.values()].map((node) => ({ ...node }));
643
+ const nodes = [...tree.values()].map((node) => ({ ...node }));
644
+ return mergeSpawnModes(nodes);
645
+ };
646
+
647
+ /**
648
+ * #209/D5: the arbitrated backend lives on the tracked job, never in
649
+ * legion.db (no column, no migration), so it is merged onto the matching
650
+ * snapshot. Unmatched jobs contribute nothing rather than a guess.
651
+ *
652
+ * Shared by `snapshotNodes()` and `treeNodes()`: the latter synthesizes
653
+ * nodes of its own (a job with identity but no DB row yet) and returns that
654
+ * array directly, so a merge that only ran in `snapshotNodes()` would drop
655
+ * the mode in the pre-DB-row window and in a plain session where
656
+ * `getTreeNodes()` yields `[]`.
657
+ */
658
+ const mergeSpawnModes = (
659
+ nodes: TreeNodeSnapshot[],
660
+ ): TreeNodeSnapshot[] => {
661
+ for (const job of jobs.values()) {
662
+ if (job.spawnMode === undefined) continue;
663
+ const match = storedNodeForJob(job, nodes);
664
+ if (match) match.spawnMode = job.spawnMode;
665
+ }
666
+ return nodes;
633
667
  };
634
668
 
635
669
  const storedNodeForJob = (
@@ -969,6 +1003,9 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
969
1003
  existing.model = input.model;
970
1004
  existing.thinking = input.thinking;
971
1005
  existing.worktreeBranch = input.worktreeBranch;
1006
+ // Only a known value overwrites: a re-follow without the handle must
1007
+ // not erase the mode an earlier launch established.
1008
+ if (input.spawnMode !== undefined) existing.spawnMode = input.spawnMode;
972
1009
  if (input.nodeId !== undefined || identity.nodeId !== undefined) existing.nodeId = input.nodeId ?? identity.nodeId;
973
1010
  if (input.parentId !== undefined || identity.parentId !== undefined) existing.parentId = input.parentId ?? identity.parentId;
974
1011
  if (input.depth !== undefined || identity.depth !== undefined) existing.depth = input.depth ?? identity.depth;
@@ -1308,7 +1345,10 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
1308
1345
  if (index >= 0) result[index] = next;
1309
1346
  else result.push(next);
1310
1347
  }
1311
- return result;
1348
+ // The synthesized nodes above are built from job facts, not from a DB
1349
+ // row, so they carry no `spawnMode` of their own: run the same merge
1350
+ // the snapshot path uses before handing the array out (#209/D5).
1351
+ return mergeSpawnModes(result);
1312
1352
  },
1313
1353
 
1314
1354
  statusEntries() {
@@ -0,0 +1,183 @@
1
+ /**
2
+ * TUI renderer for the `subagent` / `vexillatio` tool results.
3
+ *
4
+ * Pi never renders a tool result's `details` on its own: `renderResult` is the
5
+ * only consumer, and a tool registered without one falls back to the plain
6
+ * `content` text. So the arbitrated `spawnMode` (#209) stays invisible until a
7
+ * renderer names it — that is all this file does.
8
+ *
9
+ * The summary line is derived from `details`, never from `content`: `content`
10
+ * is model-facing, `details` is TUI-facing (`docs/design.md:258`). Keeping the
11
+ * line out of `content` keeps it out of the LLM context.
12
+ *
13
+ * Renderers are called on every repaint, so nothing here may throw: the host
14
+ * swallows a throwing renderer and falls back to the default block, silently
15
+ * erasing the very fact this file exists to show. Every `details` read is
16
+ * therefore optional-chained and shape-checked, AND both entry points are
17
+ * wrapped in `try/catch`: a shape check is no defence against a getter or a
18
+ * Proxy trap that throws (host: `tool-execution.js` catch → fallback card).
19
+ */
20
+
21
+ import { Text } from "@earendil-works/pi-tui";
22
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
23
+ import type { Theme } from "@earendil-works/pi-coding-agent";
24
+ import { stripTerminalSequences } from "@earendil-works/pi-tui";
25
+
26
+ /** How many `content` lines a collapsed card keeps before the expand hint. */
27
+ const COLLAPSED_LINES = 10;
28
+
29
+ const SPAWN_MODES = new Set(["rpc", "inprocess"]);
30
+
31
+ /**
32
+ * Binary control bytes that must never reach the terminal renderer.
33
+ *
34
+ * Same set as the host fallback (`shell.js:sanitizeBinaryOutput`, used by
35
+ * `render-utils.js:getTextOutput`): TAB, LF and CR are excluded on purpose —
36
+ * the carriage return is a line break here, see {@link resultLines}.
37
+ */
38
+ const BINARY_CONTROL = /[\x00-\x08\x0B\x0C\x0E-\x1F\uFFF9-\uFFFB]/g;
39
+
40
+ export interface SpawnRenderOptions {
41
+ expanded: boolean;
42
+ isPartial: boolean;
43
+ }
44
+
45
+ function isSpawnMode(value: unknown): value is "rpc" | "inprocess" {
46
+ return typeof value === "string" && SPAWN_MODES.has(value);
47
+ }
48
+
49
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
50
+ return typeof value === "object" && value !== null
51
+ ? (value as Record<string, unknown>)
52
+ : undefined;
53
+ }
54
+
55
+ /** One `name=mode` entry from a `spawns[]` array (launch-family details). */
56
+ function entryFromSpawn(value: unknown): string | undefined {
57
+ const spawn = asRecord(value);
58
+ if (!spawn) return undefined;
59
+ const name = spawn.name;
60
+ const mode = spawn.spawnMode;
61
+ if (typeof name !== "string" || !name) return undefined;
62
+ // An unknown/absent mode is skipped, never printed as a guess: a legacy
63
+ // handle must render as "no fact", not as `rpc`.
64
+ if (!isSpawnMode(mode)) return undefined;
65
+ return `${name}=${mode}`;
66
+ }
67
+
68
+ /** One `name=mode` entry from a `handles[]` object (vexillatio details). */
69
+ function entryFromHandle(value: unknown): string | undefined {
70
+ const handle = asRecord(value);
71
+ if (!handle) return undefined;
72
+ return entryFromSpawn(handle);
73
+ }
74
+
75
+ /**
76
+ * `spawn: scout-0=inprocess` or `spawn: worker-0=rpc, scout-1=inprocess`.
77
+ *
78
+ * Reads both documented detail shapes defensively:
79
+ * - launch family: `details.spawns[]` (`{ name, agent, spawnMode }`)
80
+ * - vexillatio: `details.handles[]` (`{ name, agent, sessionFile, spawnMode? }`)
81
+ *
82
+ * One entry per child, first occurrence wins: a result that carries the same
83
+ * handle in both arrays must not print `w=rpc, w=rpc`.
84
+ *
85
+ * Returns `undefined` when nothing survives — no details, wrong shape, or every
86
+ * entry's mode unknown. Unknown modes are omitted rather than defaulted.
87
+ *
88
+ * Never throws: a `details` object with a throwing getter (or a Proxy) would
89
+ * otherwise take the whole card down, because the host swallows renderer
90
+ * exceptions and paints its fallback instead.
91
+ */
92
+ export function spawnModeSummary(details: unknown): string | undefined {
93
+ try {
94
+ const record = asRecord(details);
95
+ if (!record) return undefined;
96
+ const entries: string[] = [];
97
+ const seen = new Set<string>();
98
+ const push = (entry: string | undefined): void => {
99
+ if (!entry || seen.has(entry)) return;
100
+ seen.add(entry);
101
+ entries.push(entry);
102
+ };
103
+ const spawns = record.spawns;
104
+ if (Array.isArray(spawns)) {
105
+ for (const spawn of spawns) push(entryFromSpawn(spawn));
106
+ }
107
+ // `handles` may be a `string[]` (launch-family) or an object array
108
+ // (vexillatio); string entries simply never match, so both shapes pass.
109
+ const handles = record.handles;
110
+ if (Array.isArray(handles)) {
111
+ for (const handle of handles) push(entryFromHandle(handle));
112
+ }
113
+ if (entries.length === 0) return undefined;
114
+ return `spawn: ${entries.join(", ")}`;
115
+ } catch {
116
+ // A hostile/throwing getter is not a rendering failure: no fact, no line.
117
+ return undefined;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * The model-facing text of a result, normalized the way it will be painted.
123
+ *
124
+ * Line counting has to happen on the text that actually renders, so
125
+ * normalizing runs BEFORE the split: a carriage return is how a progress
126
+ * stream redraws one line, not 20 rows — but `Text` treats `\r` as a line
127
+ * break, so leaving it in lets 20 segments paint 20 rows and bypass
128
+ * {@link COLLAPSED_LINES} with no hint. `\r` therefore becomes `\n` (so all
129
+ * 20 segments stay readable instead of being concatenated into one blob) and
130
+ * binary control bytes are dropped, matching the host fallback's intent
131
+ * (`render-utils.js:40` sanitizes binary output and removes raw `\r`).
132
+ */
133
+ function resultLines(result: AgentToolResult<unknown> | undefined): string[] {
134
+ const content = result?.content;
135
+ if (!Array.isArray(content)) return [];
136
+ const text = content
137
+ .flatMap((part) => {
138
+ if (asRecord(part)?.type !== "text") return [];
139
+ const value = asRecord(part)?.text;
140
+ return typeof value === "string" ? [value] : [];
141
+ })
142
+ .join("\n");
143
+ const normalized = stripTerminalSequences(text)
144
+ .replace(/\r\n/g, "\n")
145
+ .replace(/\r/g, "\n")
146
+ .replace(BINARY_CONTROL, "");
147
+ return normalized.split("\n");
148
+ }
149
+
150
+ /**
151
+ * Build the card: the spawn summary line (when known) followed by the result
152
+ * text, capped at {@link COLLAPSED_LINES} lines until `ctrl+o` expands it.
153
+ *
154
+ * Never throws by design; see the file comment.
155
+ */
156
+ export function renderSpawnResult(
157
+ result: AgentToolResult<unknown>,
158
+ opts: SpawnRenderOptions,
159
+ _theme: Theme,
160
+ ) {
161
+ const lines: string[] = [];
162
+ // Double insurance: `spawnModeSummary` is already total, but this call site
163
+ // must not depend on the callee's implementation staying that way (the host
164
+ // silences a throw and drops the card). The `details` READ is inside the
165
+ // guard too — a Proxy can throw on the property access itself.
166
+ let summary: string | undefined;
167
+ try {
168
+ summary = spawnModeSummary(result?.details);
169
+ } catch {
170
+ summary = undefined;
171
+ }
172
+ if (summary) lines.push(summary);
173
+ lines.push(...resultLines(result));
174
+ // `opts` is host-supplied; optional-chained so a partial/absent options
175
+ // object degrades to the collapsed view instead of throwing.
176
+ if (opts?.expanded || lines.length <= COLLAPSED_LINES) {
177
+ return new Text(lines.join("\n"), 0, 0);
178
+ }
179
+ const hidden = lines.length - COLLAPSED_LINES;
180
+ const visible = lines.slice(0, COLLAPSED_LINES);
181
+ visible.push(`... (${hidden} more lines, ctrl+o to expand)`);
182
+ return new Text(visible.join("\n"), 0, 0);
183
+ }
@@ -16,6 +16,7 @@ import {
16
16
  import type { KeybindingsManager } from "@earendil-works/pi-coding-agent";
17
17
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
18
18
  import type { LegionNode } from "../legion/nodes.ts";
19
+ import type { SpawnMode } from "../shared/types.ts";
19
20
  import {
20
21
  createChatItemFactory,
21
22
  type ChatItem,
@@ -30,6 +31,11 @@ export interface LiveOverlayNode extends Pick<LegionNode, "id" | "name" | "role"
30
31
  turns?: number;
31
32
  sessionFile?: string | null;
32
33
  worktreePath?: string | null;
34
+ /**
35
+ * Arbitrated backend for this child (#209). Absent for legacy nodes and for
36
+ * the root — the footer omits the segment rather than printing a default.
37
+ */
38
+ spawnMode?: SpawnMode;
33
39
  }
34
40
 
35
41
  /** A normalized tree-bus or RPC event associated with one Legion node. */
@@ -748,6 +754,14 @@ function createSession(
748
754
  const turnLabel = `turn ${turnNumber}${turnElapsed !== undefined ? ` (${turnElapsed})` : ""}`;
749
755
  return [
750
756
  safeText(node.role),
757
+ // #209/D5: only when the arbitrated backend is known. A legacy or
758
+ // tree-only node has no value — omit the segment, never print `rpc`.
759
+ //
760
+ // Placed before `model` on purpose: the band is clipped by `fitLine`
761
+ // to the modal inner width (78 cols at the default 24x80), and a real
762
+ // model id (e.g. `workbuddy/deepseek-v4.1-flash`) used to push the
763
+ // trailing segment into `spawn inpr...`. role + spawn survive now.
764
+ ...(node.spawnMode ? [`spawn ${node.spawnMode}`] : []),
751
765
  ...(node.model ? [safeText(node.model)] : []),
752
766
  turnLabel,
753
767
  elapsed,
package/src/tui/status.ts CHANGED
@@ -107,13 +107,22 @@ export function formatElapsed(ms: number): string {
107
107
  }
108
108
 
109
109
  /**
110
- * Relative age for railway "last activity" (`3s ago` / `2m ago` / `1h ago`).
111
- * Missing timestamp → `—` (placeholder; see #159).
110
+ * Age floor for the railway "last activity" field. Below it the field is
111
+ * dropped entirely instead of reading `1s ago` on every live lane.
112
+ */
113
+ export const ACTIVITY_AGO_MIN_SECS = 60;
114
+
115
+ /**
116
+ * Relative age for railway "last activity" (`2m ago` / `1h ago`).
117
+ *
118
+ * Missing timestamp → `—` (placeholder; see #159). Anything fresher than a
119
+ * minute → `""`, meaning "say nothing": a lane that just printed activity is
120
+ * already visibly live, so `1s ago` was pure noise on every running agent.
112
121
  */
113
122
  export function formatActivityAgo(lastActivityAt: number | undefined, now: number): string {
114
123
  if (lastActivityAt === undefined || !Number.isFinite(lastActivityAt)) return "—";
115
124
  const secs = Math.max(0, Math.floor((now - lastActivityAt) / 1000));
116
- if (secs < 60) return `${secs}s ago`;
125
+ if (secs < ACTIVITY_AGO_MIN_SECS) return "";
117
126
  const mins = Math.floor(secs / 60);
118
127
  if (mins < 60) return `${mins}m ago`;
119
128
  const hours = Math.floor(mins / 60);
@@ -11,6 +11,7 @@ import {
11
11
  } from "@earendil-works/pi-tui";
12
12
  import type { LegionEvent } from "../extension/event-bus.ts";
13
13
  import type { LegionNode, NodeStatus } from "../legion/nodes.ts";
14
+ import type { SpawnMode } from "../shared/types.ts";
14
15
  import { osc8NodeLink } from "./status.ts";
15
16
 
16
17
  const TREE_HEADER = "Legion tree · j/k move · enter toggle · / search · pgup/pgdn scroll · esc/q close";
@@ -30,6 +31,13 @@ export interface TreeNodeSnapshot extends LegionNode {
30
31
  lastTools?: readonly string[];
31
32
  /** Unix ms start of the current turn, when known (bus events or snapshots). */
32
33
  turnStartedAt?: number;
34
+ /**
35
+ * Arbitrated backend for this child (#209), merged from the tracked job at
36
+ * snapshot time — `legion.db` has no spawn_mode column. Absent for legacy
37
+ * records and for nodes never launched in this session; readers must render
38
+ * that as unknown, never as `rpc`.
39
+ */
40
+ spawnMode?: SpawnMode;
33
41
  }
34
42
 
35
43
  export interface TreeViewTheme {
@@ -156,8 +156,8 @@ export function formatRate(rate: number): string {
156
156
 
157
157
 
158
158
  /**
159
- * Wide-lane detail after the name: role, turn/lifetime clocks, and
160
- * last-activity age.
159
+ * Wide-lane detail after the name: role, turn/lifetime clocks, and — once it
160
+ * is at least a minute old — the last-activity age.
161
161
  *
162
162
  * The leading clock is "time since the last task started": it keeps ticking
163
163
  * through the quiet gap after a turn settles (the old behavior froze at that
@@ -170,34 +170,37 @@ export function formatRate(rate: number): string {
170
170
  * has a real start time and a real lifetime; show both.
171
171
  */
172
172
  export function formatRailLaneDetail(node: VizNode, now: number): string {
173
- const parts: string[] = [node.role];
174
- parts.push(formatAgentElapsed({
175
- state: node.status,
176
- startedAt: node.startedAt,
177
- ...(node.endedAt !== undefined ? { finishedAt: node.endedAt } : {}),
178
- ...(node.currentTurnStartedAt !== undefined ? { currentTurnStartedAt: node.currentTurnStartedAt } : {}),
179
- }, now));
180
- parts.push(formatActivityAgo(node.lastActivityAt, now));
181
- return parts.join(" · ");
173
+ return laneDetailParts(node, now).join(" · ");
182
174
  }
183
175
 
184
- /** Lane detail candidates, richest first (role → clocks → ago). */
185
- export function laneDetailCandidates(node: VizNode, now: number): string[] {
176
+ /**
177
+ * Lane detail fields, richest first: role, clocks, then last-activity age —
178
+ * the age is omitted entirely while it is under a minute, because a lane that
179
+ * just saw activity is already visibly live and `1s ago` was pure noise.
180
+ */
181
+ function laneDetailParts(node: VizNode, now: number): string[] {
186
182
  const ago = formatActivityAgo(node.lastActivityAt, now);
187
- const clocks = formatAgentElapsed({
188
- state: node.status,
189
- startedAt: node.startedAt,
190
- ...(node.endedAt !== undefined ? { finishedAt: node.endedAt } : {}),
191
- ...(node.currentTurnStartedAt !== undefined ? { currentTurnStartedAt: node.currentTurnStartedAt } : {}),
192
- }, now);
193
183
  return [
194
- `${node.role} · ${clocks} · ${ago}`,
195
- `${clocks} · ${ago}`,
196
- clocks,
197
- ago,
184
+ node.role,
185
+ formatAgentElapsed({
186
+ state: node.status,
187
+ startedAt: node.startedAt,
188
+ ...(node.endedAt !== undefined ? { finishedAt: node.endedAt } : {}),
189
+ ...(node.currentTurnStartedAt !== undefined ? { currentTurnStartedAt: node.currentTurnStartedAt } : {}),
190
+ }, now),
191
+ ...(ago ? [ago] : []),
198
192
  ];
199
193
  }
200
194
 
195
+ /** Lane detail candidates, richest first (role → clocks → ago). */
196
+ export function laneDetailCandidates(node: VizNode, now: number): string[] {
197
+ const parts = laneDetailParts(node, now);
198
+ // Drop fields from the left: the role goes first, the clocks are the last
199
+ // thing to give up. A fresh lane has no age field, so it ends at the clocks
200
+ // instead of paying for a `1s ago` suffix it never shows.
201
+ return parts.map((_, start) => parts.slice(start).join(" · "));
202
+ }
203
+
201
204
  /**
202
205
  * Width of the lane-name column: as wide as the longest name, clamped to
203
206
  * `RAIL_NAME_MAX_COLS` and never below `RAIL_NAME_MIN_COLS`.
@@ -255,7 +258,7 @@ export function railColumnPlan(
255
258
  if (portrait) {
256
259
  const label = Math.max(...shown.map((n) => {
257
260
  const ago = formatActivityAgo(n.lastActivityAt, now);
258
- return Array.from(ago === "—" ? n.name : `${n.name} · ${ago}`).length;
261
+ return Array.from(ago === "" || ago === "—" ? n.name : `${n.name} · ${ago}`).length;
259
262
  }));
260
263
  return { nameW, grade: 0, needed: label + 2 + reserve };
261
264
  }
@@ -423,7 +426,7 @@ export function renderRailway(input: RailInput): RailRender | undefined {
423
426
  const avail = width - (nameX + 2) - overflow.length;
424
427
  if (portrait) {
425
428
  const ago = formatActivityAgo(node.lastActivityAt, now);
426
- const label = ago === "—" ? node.name : `${node.name} · ${ago}`;
429
+ const label = ago === "" || ago === "—" ? node.name : `${node.name} · ${ago}`;
427
430
  canvas.text(nameX + 2, y, ellipsize(label, avail), col, { bold: true });
428
431
  } else {
429
432
  // Paint with the same width the plan measured (clamped to what is