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 +48 -0
- package/README.md +4 -1
- package/docs/spawn-mode-visibility-v1.md +206 -0
- package/index.ts +80 -12
- package/package.json +1 -1
- package/src/extension/legion-commands.ts +2 -0
- package/src/extension/notify.ts +6 -6
- package/src/extension/playbook.ts +4 -4
- package/src/extension/recycle.ts +2 -2
- package/src/extension/runtime.ts +43 -3
- package/src/extension/spawn-render.ts +183 -0
- package/src/tui/live-overlay.ts +14 -0
- package/src/tui/status.ts +12 -3
- package/src/tui/tree-view.ts +8 -0
- package/src/tui/viz/railway.ts +28 -25
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
|
|
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
|
-
|
|
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
|
|
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
|
-
`
|
|
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
|
|
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(", ")} →
|
|
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
|
|
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
|
|
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 }) => ({
|
|
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
|
+
"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
|
|
package/src/extension/notify.ts
CHANGED
|
@@ -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
|
-
: "
|
|
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} (
|
|
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
|
|
279
|
-
* carry an inline "(
|
|
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
|
|
315
|
-
const recycled = entry.recycled === false ? "" : " (
|
|
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
|
|
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.
|
|
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
|
|
15
|
-
"5. A collect timeout with a live
|
|
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).",
|
package/src/extension/recycle.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Helpers for collect/retire calls made after
|
|
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
|
|
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
|
}
|
package/src/extension/runtime.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/tui/live-overlay.ts
CHANGED
|
@@ -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
|
-
*
|
|
111
|
-
*
|
|
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 <
|
|
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);
|
package/src/tui/tree-view.ts
CHANGED
|
@@ -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 {
|
package/src/tui/viz/railway.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
185
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|