pi-legion 3.0.3 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +263 -2
- package/CONTRIBUTING.md +4 -17
- package/README.md +29 -22
- package/agents/worker.md +1 -1
- package/docs/contracts/display-liveness-v1.md +373 -0
- package/docs/contracts/drop-non-pi-kinds-v4.md +198 -0
- package/docs/contracts/esc-abort-v1.md +4 -4
- package/docs/contracts/esc-no-retire-v3.md +2 -2
- package/docs/contracts/launch-admission-queue-v1.md +839 -0
- package/docs/contracts/status-panel-v1.md +174 -0
- package/docs/inprocess-supervisor.md +37 -4
- package/docs/legion-v2-contract.md +18 -2
- package/docs/performance-2026-10-19.md +199 -0
- package/docs/spawn-mode-visibility-v1.md +206 -0
- package/docs/vexillatio-contract.md +3 -1
- package/index.ts +633 -84
- package/package.json +4 -2
- package/skills/pi-legion/SKILL.md +2 -3
- package/src/agents/agents.ts +17 -10
- package/src/agents/overrides.ts +0 -1
- package/src/agents/presets.ts +14 -40
- package/src/agents/settings.ts +26 -77
- package/src/agents/step-model.ts +7 -22
- package/src/api.ts +1 -35
- package/src/extension/event-bus.ts +80 -8
- package/src/extension/legion-commands.ts +43 -17
- package/src/extension/legion-doctor.ts +1 -1
- package/src/extension/legion-stop.ts +39 -13
- package/src/extension/legion-tools.ts +21 -4
- package/src/extension/liveness.ts +200 -0
- package/src/extension/notify.ts +6 -6
- package/src/extension/playbook.ts +6 -6
- package/src/extension/recycle.ts +6 -2
- package/src/extension/runtime.ts +422 -18
- package/src/extension/slash.ts +0 -21
- package/src/extension/spawn-render.ts +183 -0
- package/src/extension/summary.ts +1 -1
- package/src/legion/db.ts +165 -1
- package/src/legion/events.ts +81 -16
- package/src/legion/nodes.ts +13 -2
- package/src/legion/rebuild.ts +7 -1
- package/src/legion/usage.ts +58 -4
- package/src/runs/orchestrator.ts +1165 -62
- package/src/runs/worktree.ts +190 -21
- package/src/shared/ignored-kind.ts +57 -0
- package/src/shared/progress.ts +11 -0
- package/src/shared/session.ts +199 -19
- package/src/shared/types.ts +50 -66
- package/src/supervisor/admission-queue.ts +516 -0
- package/src/supervisor/inprocess-supervisor.ts +108 -33
- package/src/supervisor/proc-memory.ts +67 -0
- package/src/supervisor/routing-supervisor.ts +58 -1
- package/src/tui/live-overlay.ts +14 -0
- package/src/tui/session-messages-file.ts +100 -0
- package/src/tui/status.ts +103 -21
- package/src/tui/tree-view.ts +36 -3
- package/src/tui/viz/layout.ts +54 -3
- package/src/tui/viz/model.ts +67 -14
- package/src/tui/viz/palette.ts +11 -0
- package/src/tui/viz/panels.ts +222 -8
- package/src/tui/viz/railway.ts +28 -25
- package/src/tui/viz/view.ts +110 -23
- package/src/backends/legacy/client.ts +0 -638
- package/src/backends/legacy/cursor-chat.ts +0 -348
- package/src/backends/legacy/runner.ts +0 -101
- package/src/backends/legacy/types.ts +0 -138
- package/src/runs/kind.ts +0 -260
package/src/tui/status.ts
CHANGED
|
@@ -16,6 +16,11 @@ import {
|
|
|
16
16
|
visibleWidth,
|
|
17
17
|
} from "@earendil-works/pi-tui";
|
|
18
18
|
import { isThinkingLevel } from "../shared/types.ts";
|
|
19
|
+
import {
|
|
20
|
+
NON_TERMINAL,
|
|
21
|
+
TERMINAL_STATES as LIVENESS_TERMINAL_STATES,
|
|
22
|
+
displayStatusFromState,
|
|
23
|
+
} from "../extension/liveness.ts";
|
|
19
24
|
import { nodesFromEntries, type VizNode } from "./viz/model.ts";
|
|
20
25
|
import { RateSampler } from "./viz/rate.ts";
|
|
21
26
|
import { railwayLines, type RailScroll } from "./viz/railway.ts";
|
|
@@ -66,6 +71,25 @@ export interface StatusEntry {
|
|
|
66
71
|
nodeId?: string;
|
|
67
72
|
parentId?: string | null;
|
|
68
73
|
depth?: number;
|
|
74
|
+
/**
|
|
75
|
+
* Backend the child was actually spawned on (#209). Absent for legacy
|
|
76
|
+
* records and for tree-only rows — consumers render `unknown`, never `rpc`.
|
|
77
|
+
*/
|
|
78
|
+
spawnMode?: string;
|
|
79
|
+
/**
|
|
80
|
+
* Own OS process of an `rpc` child. Never persisted (pids are host-scoped),
|
|
81
|
+
* so a row restored from disk has none. `inprocess` children have none by
|
|
82
|
+
* definition — they share this process.
|
|
83
|
+
*/
|
|
84
|
+
pid?: number;
|
|
85
|
+
/** Prompt size of the last assistant message; absolute tokens, not a ratio. */
|
|
86
|
+
contextTokens?: number;
|
|
87
|
+
/**
|
|
88
|
+
* Resident memory of this child in MB. Only ever set for a child with its own
|
|
89
|
+
* process (D2): an `inprocess` child has no separate RSS and must render
|
|
90
|
+
* blank rather than a share of the host figure.
|
|
91
|
+
*/
|
|
92
|
+
rss?: number;
|
|
69
93
|
}
|
|
70
94
|
|
|
71
95
|
/** Session-wide totals for the tree header: every launched agent (incl. aged-out), root excluded. */
|
|
@@ -107,13 +131,22 @@ export function formatElapsed(ms: number): string {
|
|
|
107
131
|
}
|
|
108
132
|
|
|
109
133
|
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
134
|
+
* Age floor for the railway "last activity" field. Below it the field is
|
|
135
|
+
* dropped entirely instead of reading `1s ago` on every live lane.
|
|
136
|
+
*/
|
|
137
|
+
export const ACTIVITY_AGO_MIN_SECS = 60;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Relative age for railway "last activity" (`2m ago` / `1h ago`).
|
|
141
|
+
*
|
|
142
|
+
* Missing timestamp → `—` (placeholder; see #159). Anything fresher than a
|
|
143
|
+
* minute → `""`, meaning "say nothing": a lane that just printed activity is
|
|
144
|
+
* already visibly live, so `1s ago` was pure noise on every running agent.
|
|
112
145
|
*/
|
|
113
146
|
export function formatActivityAgo(lastActivityAt: number | undefined, now: number): string {
|
|
114
147
|
if (lastActivityAt === undefined || !Number.isFinite(lastActivityAt)) return "—";
|
|
115
148
|
const secs = Math.max(0, Math.floor((now - lastActivityAt) / 1000));
|
|
116
|
-
if (secs <
|
|
149
|
+
if (secs < ACTIVITY_AGO_MIN_SECS) return "";
|
|
117
150
|
const mins = Math.floor(secs / 60);
|
|
118
151
|
if (mins < 60) return `${mins}m ago`;
|
|
119
152
|
const hours = Math.floor(mins / 60);
|
|
@@ -140,8 +173,8 @@ function formatTokensCompact(value: number): string {
|
|
|
140
173
|
* is part of "time since the last task started". It freezes only when the
|
|
141
174
|
* agent ends: a terminal entry clamps at `finishedAt` so the clock does not
|
|
142
175
|
* run on after the agent exits. Undefined when the turn start is unknown, or
|
|
143
|
-
* when the entry is
|
|
144
|
-
* meaningful running clock
|
|
176
|
+
* when the entry is alive but not doing work — an `idle`/`queued`/`awaiting`
|
|
177
|
+
* seat has no meaningful running clock.
|
|
145
178
|
*/
|
|
146
179
|
function currentTurnElapsedMs(
|
|
147
180
|
entry: Pick<StatusEntry, "state" | "herdrStatus" | "finishedAt" | "currentTurnStartedAt">,
|
|
@@ -151,7 +184,11 @@ function currentTurnElapsedMs(
|
|
|
151
184
|
const start = entry.currentTurnStartedAt;
|
|
152
185
|
const status = entry.herdrStatus ?? entry.state;
|
|
153
186
|
if (TERMINAL_STATES.has(status)) return Math.max(0, (entry.finishedAt ?? now) - start);
|
|
154
|
-
|
|
187
|
+
// Only a *busy* entry has a live turn clock. An `idle` seat (and a `queued`
|
|
188
|
+
// one, and an `awaiting` centurion waiting on its squad) is visible but not
|
|
189
|
+
// working, so it keeps no running clock: the doc above says so, and letting
|
|
190
|
+
// it tick would show time accruing against nothing.
|
|
191
|
+
if (!BUSY_STATES.has(status)) return undefined;
|
|
155
192
|
return Math.max(0, now - start);
|
|
156
193
|
}
|
|
157
194
|
|
|
@@ -198,7 +235,7 @@ export function formatAgentElapsed(
|
|
|
198
235
|
|
|
199
236
|
export function activeEntries(entries: StatusEntry[]): StatusEntry[] {
|
|
200
237
|
return entries.filter((entry) =>
|
|
201
|
-
entry.parentId !== null && entry.depth !== 0 &&
|
|
238
|
+
entry.parentId !== null && entry.depth !== 0 && BUSY_STATES.has(entry.herdrStatus ?? entry.state),
|
|
202
239
|
);
|
|
203
240
|
}
|
|
204
241
|
|
|
@@ -279,7 +316,8 @@ function formatWorktreeBranch(branch: string): string {
|
|
|
279
316
|
|
|
280
317
|
function headlineBits(entry: StatusEntry, now: number): string[] {
|
|
281
318
|
const bits = [`${entry.name}${roleSuffix(entry)}`];
|
|
282
|
-
|
|
319
|
+
// `entry.kind` is deliberately not rendered: v4 has one kind (`pi`), so the
|
|
320
|
+
// label would repeat a constant on every row.
|
|
283
321
|
const model = formatModel(entry);
|
|
284
322
|
if (model) bits.push(model);
|
|
285
323
|
bits.push(formatAgentElapsed(entry, now));
|
|
@@ -329,18 +367,56 @@ function widgetRow(text: string, nodeId?: string): WidgetRow {
|
|
|
329
367
|
return nodeId === undefined ? { text } : { text, nodeId };
|
|
330
368
|
}
|
|
331
369
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
370
|
+
/**
|
|
371
|
+
* Terminal / active / busy / running: one derivation from the liveness
|
|
372
|
+
* authority (contract `docs/contracts/display-liveness-v1.md` D1-a, D5).
|
|
373
|
+
*
|
|
374
|
+
* This file used to carry its own three tables, and the terminal one disagreed
|
|
375
|
+
* with every other surface: it listed `awaiting` and `unknown` as terminal
|
|
376
|
+
* (so a centurion waiting on its squad vanished from the widget) and omitted
|
|
377
|
+
* `error`/`truncated`/`done` (so those kept a live clock). `TERMINAL_STATES` is
|
|
378
|
+
* therefore imported, never re-declared.
|
|
379
|
+
*/
|
|
380
|
+
const TERMINAL_STATES: ReadonlySet<string> = LIVENESS_TERMINAL_STATES;
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* "This row describes something that is still alive": the liveness module's
|
|
384
|
+
* in-flight labels plus `awaiting`, which D1-a places on the non-terminal side
|
|
385
|
+
* ("alive, waiting on downstream"). Drives visibility (`filterLiveTree`,
|
|
386
|
+
* `isActiveEntry`) and the open-ended clocks.
|
|
387
|
+
*
|
|
388
|
+
* This stays a known-label set on purpose. D1-a's "use the complement, not a
|
|
389
|
+
* whitelist" rule governs `liveness()`'s ledger rule, where the question is
|
|
390
|
+
* *may we declare this lifecycle over?* — the safe answer for an unrecognised
|
|
391
|
+
* label is "no". Here the question is the opposite one, *may this row count as
|
|
392
|
+
* alive?*, and a permissive answer would promote the widget's own synthetic
|
|
393
|
+
* `waiting` label (and any legacy/foreign label) to activity: rows that must
|
|
394
|
+
* age out would be resurrected and the `N running` count would inflate.
|
|
395
|
+
*/
|
|
396
|
+
const ACTIVE_STATES: ReadonlySet<string> = new Set([...NON_TERMINAL, "awaiting"]);
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* "This row is doing work": active and not merely holding a slot. An `idle`
|
|
400
|
+
* seat is alive and visible (D5) but has no running clock and must not keep the
|
|
401
|
+
* busy label lit forever; same for a `queued` seat that has not started and an
|
|
402
|
+
* `awaiting` centurion whose squad is the thing actually running.
|
|
403
|
+
*
|
|
404
|
+
* Kept separate from `ACTIVE_STATES` because conflating "should I show this?"
|
|
405
|
+
* with "is this busy?" is precisely the mistake that made `idle`/`awaiting`
|
|
406
|
+
* disappear instead of being shown grey.
|
|
407
|
+
*/
|
|
408
|
+
const BUSY_STATES: ReadonlySet<string> = new Set(
|
|
409
|
+
[...ACTIVE_STATES].filter((state) => displayStatusFromState(state) === "running"),
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Working states for the header's `N running` count, derived from the busy set
|
|
414
|
+
* so it can never list a state the panel considers dead. `blocked` is an
|
|
415
|
+
* approval gate: busy, but not generating, so it does not count as running.
|
|
416
|
+
*/
|
|
417
|
+
const RUNNING_STATES: ReadonlySet<string> = new Set(
|
|
418
|
+
[...BUSY_STATES].filter((state) => state !== "blocked"),
|
|
419
|
+
);
|
|
344
420
|
|
|
345
421
|
function cleanDisplayText(value: string): string {
|
|
346
422
|
return stripTerminalSequences(value)
|
|
@@ -494,6 +570,12 @@ function renderTreeWidgetLines(
|
|
|
494
570
|
// A terminal node whose subtree is still running is waiting on
|
|
495
571
|
// downstream work (e.g. a centurion between turns while its squad
|
|
496
572
|
// executes) — render it as "waiting" instead of a dead settled state.
|
|
573
|
+
//
|
|
574
|
+
// D1-a: a row that is *already* `awaiting` is not rewritten. `awaiting`
|
|
575
|
+
// means "alive, waiting on downstream", so it is active (ACTIVE_STATES)
|
|
576
|
+
// and this branch never sees it — which is the point: it must neither
|
|
577
|
+
// vanish from the panel nor be relabelled as a finished state. It renders
|
|
578
|
+
// grey, as itself, because `themeLines` dims every non-header row.
|
|
497
579
|
const effectiveEntry = !isActiveEntry(entry) && activeSubtree
|
|
498
580
|
? { ...entry, state: "waiting" as const, herdrStatus: "waiting" as const }
|
|
499
581
|
: entry;
|
|
@@ -543,7 +625,7 @@ function formatWidgetRows(entries: StatusEntry[], now: number, totals?: StatusTo
|
|
|
543
625
|
return formatEntryLines(entries[0]!, now, { head: "", detail: "" })
|
|
544
626
|
.map((text) => ({ text }));
|
|
545
627
|
}
|
|
546
|
-
const rows: WidgetRow[] = [{ text: "● Async agents
|
|
628
|
+
const rows: WidgetRow[] = [{ text: "● Async agents" }];
|
|
547
629
|
for (const [index, entry] of entries.entries()) {
|
|
548
630
|
const last = index === entries.length - 1;
|
|
549
631
|
rows.push(
|
package/src/tui/tree-view.ts
CHANGED
|
@@ -10,14 +10,40 @@ import {
|
|
|
10
10
|
type TUI,
|
|
11
11
|
} from "@earendil-works/pi-tui";
|
|
12
12
|
import type { LegionEvent } from "../extension/event-bus.ts";
|
|
13
|
-
import type { LegionNode
|
|
13
|
+
import type { LegionNode } from "../legion/nodes.ts";
|
|
14
|
+
import { NODE_STATUSES } from "../legion/nodes.ts";
|
|
15
|
+
import { isTerminalState } from "../extension/liveness.ts";
|
|
16
|
+
import type { SpawnMode } from "../shared/types.ts";
|
|
14
17
|
import { osc8NodeLink } from "./status.ts";
|
|
15
18
|
|
|
16
19
|
const TREE_HEADER = "Legion tree · j/k move · enter toggle · / search · pgup/pgdn scroll · esc/q close";
|
|
17
20
|
const DEFAULT_TERMINAL_ROWS = 24;
|
|
18
21
|
const MAX_ACTIVITY_TOOLS = 2;
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Active/terminal partition, derived from the liveness authority instead of
|
|
24
|
+
* hand-written here (contract `docs/contracts/display-liveness-v1.md` D1-a,
|
|
25
|
+
* D6 exit 4).
|
|
26
|
+
*
|
|
27
|
+
* The gain is anti-drift rather than a bug fix: `node.status` is `NodeStatus`,
|
|
28
|
+
* the six-value ledger set, and for those six the old local pair happened to
|
|
29
|
+
* agree with `isTerminalState`. But it was a fourth independent copy of the
|
|
30
|
+
* vocabulary, so the next ledger status would be terminal in the panel and
|
|
31
|
+
* non-terminal here — exactly the divergence D6 forbids.
|
|
32
|
+
*
|
|
33
|
+
* `idle`/`queued` are display states that cannot appear here (D9 keeps them out
|
|
34
|
+
* of `NODE_STATUSES`). They are unioned into the active side anyway so the two
|
|
35
|
+
* sets stay a partition of anything a snapshot row might carry: calling a
|
|
36
|
+
* pending row terminal would fold its subtree and freeze its clock for work
|
|
37
|
+
* that has not started.
|
|
38
|
+
*/
|
|
39
|
+
const ACTIVE_STATUSES: ReadonlySet<string> = new Set<string>([
|
|
40
|
+
...NODE_STATUSES.filter((status) => !isTerminalState(status)),
|
|
41
|
+
"idle",
|
|
42
|
+
"queued",
|
|
43
|
+
]);
|
|
44
|
+
const TERMINAL_STATUSES: ReadonlySet<string> = new Set<string>(
|
|
45
|
+
NODE_STATUSES.filter((status) => isTerminalState(status)),
|
|
46
|
+
);
|
|
21
47
|
|
|
22
48
|
export interface TreeNodeSnapshot extends LegionNode {
|
|
23
49
|
/** Snapshot from legion.db usage table (≤30s granularity). */
|
|
@@ -30,6 +56,13 @@ export interface TreeNodeSnapshot extends LegionNode {
|
|
|
30
56
|
lastTools?: readonly string[];
|
|
31
57
|
/** Unix ms start of the current turn, when known (bus events or snapshots). */
|
|
32
58
|
turnStartedAt?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Arbitrated backend for this child (#209), merged from the tracked job at
|
|
61
|
+
* snapshot time — `legion.db` has no spawn_mode column. Absent for legacy
|
|
62
|
+
* records and for nodes never launched in this session; readers must render
|
|
63
|
+
* that as unknown, never as `rpc`.
|
|
64
|
+
*/
|
|
65
|
+
spawnMode?: SpawnMode;
|
|
33
66
|
}
|
|
34
67
|
|
|
35
68
|
export interface TreeViewTheme {
|
package/src/tui/viz/layout.ts
CHANGED
|
@@ -17,6 +17,13 @@ export interface LegionLayout {
|
|
|
17
17
|
/** Subagent wall/gen/other table (#151). */
|
|
18
18
|
timing: Rect;
|
|
19
19
|
log: Rect;
|
|
20
|
+
/**
|
|
21
|
+
* Agent status table (role/state/supervisor/ctx/rss/turns). Zero-area in
|
|
22
|
+
* portrait: 46×24 already packs four panes into 19 of 21 body rows, so the
|
|
23
|
+
* fifth cannot fit (D1). A zero-height rect keeps `frame()` from painting and
|
|
24
|
+
* `availablePanes()` (view.ts) keeps Tab off it.
|
|
25
|
+
*/
|
|
26
|
+
status: Rect;
|
|
20
27
|
footer: Rect;
|
|
21
28
|
}
|
|
22
29
|
|
|
@@ -35,6 +42,35 @@ export const TIMING_BAND_MIN = 5;
|
|
|
35
42
|
export const TIMING_BAND_MAX = 10;
|
|
36
43
|
/** Below this bottom height, stacked prefers a timing strip over side-by-side. */
|
|
37
44
|
export const STACKED_TIMING_SIDE_MIN = 8;
|
|
45
|
+
/** Status band: enough rows for a header plus a few agents, never a tall empty box. */
|
|
46
|
+
export const STATUS_BAND_MIN = 5;
|
|
47
|
+
export const STATUS_BAND_MAX = 12;
|
|
48
|
+
/**
|
|
49
|
+
* Rows the log keeps when a status band is carved out above it. Below this the
|
|
50
|
+
* band is dropped entirely rather than shrinking the log to a header-only stub —
|
|
51
|
+
* the same trade `timing` already makes against `log` in portrait.
|
|
52
|
+
*/
|
|
53
|
+
export const STATUS_LOG_RESERVE = 4;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Height of a status band carved out of `avail` rows while leaving `reserve`
|
|
57
|
+
* rows for whatever sits below it. Returns 0 when the band cannot reach
|
|
58
|
+
* STATUS_BAND_MIN — callers then hand the whole area to the neighbouring pane,
|
|
59
|
+
* and the zero-height rect is what makes Tab skip it.
|
|
60
|
+
*/
|
|
61
|
+
function statusBandHeight(avail: number, reserve: number, share: number): number {
|
|
62
|
+
const cap = Math.min(STATUS_BAND_MAX, avail - reserve);
|
|
63
|
+
if (cap < STATUS_BAND_MIN) return 0;
|
|
64
|
+
return Math.min(cap, Math.max(STATUS_BAND_MIN, Math.round(avail * share)));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A pane that could not be placed (D1). Zero-area, so `frame()` paints nothing
|
|
69
|
+
* and `availablePanes()` (view.ts) keeps Tab off it.
|
|
70
|
+
*/
|
|
71
|
+
function noRect(): Rect {
|
|
72
|
+
return { x: 0, y: 0, w: 0, h: 0 };
|
|
73
|
+
}
|
|
38
74
|
|
|
39
75
|
/** Terminal cells are ~2:1 (tall), so rows*2 >= cols means the window looks taller than wide. */
|
|
40
76
|
export function isTall(cols: number, rows: number): boolean {
|
|
@@ -73,6 +109,9 @@ export function computeLayout(cols: number, rows: number, hints: LayoutHints = {
|
|
|
73
109
|
Math.max(TIMING_BAND_MIN, Math.round(bodyH * 0.42)),
|
|
74
110
|
Math.max(TIMING_BAND_MIN, bodyH - 6),
|
|
75
111
|
);
|
|
112
|
+
// Right column: timing, then log, then the status band closes it (D1).
|
|
113
|
+
const statusH = statusBandHeight(bodyH - timingH, STATUS_LOG_RESERVE, 0.25);
|
|
114
|
+
const logH = Math.max(0, bodyH - timingH - statusH);
|
|
76
115
|
return {
|
|
77
116
|
kind,
|
|
78
117
|
header,
|
|
@@ -80,7 +119,8 @@ export function computeLayout(cols: number, rows: number, hints: LayoutHints = {
|
|
|
80
119
|
galaxy: { x: 0, y: bodyY, w: leftW, h: galaxyH },
|
|
81
120
|
chart: { x: 0, y: bodyY + galaxyH, w: leftW, h: chartH },
|
|
82
121
|
timing: { x: leftW, y: bodyY, w: rightW, h: timingH },
|
|
83
|
-
log: { x: leftW, y: bodyY + timingH, w: rightW, h:
|
|
122
|
+
log: { x: leftW, y: bodyY + timingH, w: rightW, h: logH },
|
|
123
|
+
status: statusH > 0 ? { x: leftW, y: bodyY + timingH + logH, w: rightW, h: statusH } : noRect(),
|
|
84
124
|
};
|
|
85
125
|
}
|
|
86
126
|
if (kind === "stacked") {
|
|
@@ -91,6 +131,10 @@ export function computeLayout(cols: number, rows: number, hints: LayoutHints = {
|
|
|
91
131
|
// Prefer timing|log side-by-side; strip when the bottom band is too short (#151).
|
|
92
132
|
if (bottomH >= STACKED_TIMING_SIDE_MIN) {
|
|
93
133
|
const timingW = Math.floor(W * 0.45);
|
|
134
|
+
const rightW = W - timingW;
|
|
135
|
+
// Right column of the bottom band: log on top, status closing it (D1).
|
|
136
|
+
const statusH = statusBandHeight(bottomH, STATUS_LOG_RESERVE, 0.35);
|
|
137
|
+
const logH = Math.max(0, bottomH - statusH);
|
|
94
138
|
return {
|
|
95
139
|
kind,
|
|
96
140
|
header,
|
|
@@ -98,10 +142,14 @@ export function computeLayout(cols: number, rows: number, hints: LayoutHints = {
|
|
|
98
142
|
galaxy: { x: 0, y: bodyY, w: galaxyW, h: topH },
|
|
99
143
|
chart: { x: galaxyW, y: bodyY, w: W - galaxyW, h: topH },
|
|
100
144
|
timing: { x: 0, y: bottomY, w: timingW, h: bottomH },
|
|
101
|
-
log: { x: timingW, y: bottomY, w:
|
|
145
|
+
log: { x: timingW, y: bottomY, w: rightW, h: logH },
|
|
146
|
+
status: statusH > 0 ? { x: timingW, y: bottomY + logH, w: rightW, h: statusH } : noRect(),
|
|
102
147
|
};
|
|
103
148
|
}
|
|
104
149
|
const timingH = Math.min(TIMING_BAND_MAX, Math.max(TIMING_BAND_MIN, Math.round(bottomH * 0.4)), Math.max(3, bottomH - 3));
|
|
150
|
+
const afterTiming = Math.max(0, bottomH - timingH);
|
|
151
|
+
const statusH = statusBandHeight(afterTiming, STATUS_LOG_RESERVE, 0.35);
|
|
152
|
+
const logH = Math.max(0, afterTiming - statusH);
|
|
105
153
|
return {
|
|
106
154
|
kind,
|
|
107
155
|
header,
|
|
@@ -109,7 +157,8 @@ export function computeLayout(cols: number, rows: number, hints: LayoutHints = {
|
|
|
109
157
|
galaxy: { x: 0, y: bodyY, w: galaxyW, h: topH },
|
|
110
158
|
chart: { x: galaxyW, y: bodyY, w: W - galaxyW, h: topH },
|
|
111
159
|
timing: { x: 0, y: bottomY, w: W, h: timingH },
|
|
112
|
-
log: { x: 0, y: bottomY + timingH, w: W, h:
|
|
160
|
+
log: { x: 0, y: bottomY + timingH, w: W, h: logH },
|
|
161
|
+
status: statusH > 0 ? { x: 0, y: bottomY + timingH + logH, w: W, h: statusH } : noRect(),
|
|
113
162
|
};
|
|
114
163
|
}
|
|
115
164
|
// Portrait: galaxy (aspect-limited), chart (fixed band), timing (compact), log gets the rest.
|
|
@@ -151,5 +200,7 @@ export function computeLayout(cols: number, rows: number, hints: LayoutHints = {
|
|
|
151
200
|
chart: { x: 0, y: bodyY + galaxyH, w: W, h: chartH },
|
|
152
201
|
timing: { x: 0, y: bodyY + galaxyH + chartH, w: W, h: Math.max(0, timingH) },
|
|
153
202
|
log: { x: 0, y: bodyY + galaxyH + chartH + Math.max(0, timingH), w: W, h: Math.max(0, logH) },
|
|
203
|
+
// D1: portrait has no room for a fifth pane — zero-area, so frame()/Tab skip it.
|
|
204
|
+
status: noRect(),
|
|
154
205
|
};
|
|
155
206
|
}
|
package/src/tui/viz/model.ts
CHANGED
|
@@ -2,13 +2,25 @@
|
|
|
2
2
|
* Shared data model for the Legion visualization (railway strip + /legion view).
|
|
3
3
|
*
|
|
4
4
|
* Subagents spawn dynamically and nest, so nothing here assumes a fixed role
|
|
5
|
-
* list. Node statuses collapse to
|
|
6
|
-
*
|
|
5
|
+
* list. Node statuses collapse to the display states of contract
|
|
6
|
+
* `docs/contracts/display-liveness-v1.md` D5: `blocked` is a transient
|
|
7
|
+
* in-memory approval flag and renders as running, while `idle`/`queued` are
|
|
8
|
+
* first-class display states (a child can hold a concurrency slot without
|
|
9
|
+
* working) and an unrecognised label is `unknown` rather than the old
|
|
10
|
+
* `retired` fallback.
|
|
7
11
|
*/
|
|
8
12
|
|
|
9
13
|
import type { StatusEntry } from "../status.ts";
|
|
14
|
+
import { SPAWN_MODES, type SpawnMode } from "../../shared/types.ts";
|
|
15
|
+
import { displayStatusFromState, isTerminalState, type DisplayStatus } from "../../extension/liveness.ts";
|
|
10
16
|
|
|
11
|
-
|
|
17
|
+
/**
|
|
18
|
+
* D5's display vocabulary, imported from the single authority rather than
|
|
19
|
+
* re-declared. The name `VizStatus` stays because every viz consumer imports
|
|
20
|
+
* it; the values are `liveness.ts`'s `DisplayStatus` so the panel cannot drift
|
|
21
|
+
* from the list/tree.
|
|
22
|
+
*/
|
|
23
|
+
export type VizStatus = DisplayStatus;
|
|
12
24
|
|
|
13
25
|
export interface VizNode {
|
|
14
26
|
id: string;
|
|
@@ -37,19 +49,44 @@ export interface VizNode {
|
|
|
37
49
|
turns?: number;
|
|
38
50
|
/** Unix ms of last visible activity for this seat (not run wall-clock start). */
|
|
39
51
|
lastActivityAt?: number;
|
|
52
|
+
/**
|
|
53
|
+
* Arbitrated backend the child was spawned on (#209). Optional: a row restored
|
|
54
|
+
* from disk has none, and consumers must render `unknown` — never guess `rpc`.
|
|
55
|
+
*/
|
|
56
|
+
spawnMode?: SpawnMode;
|
|
57
|
+
/** Own OS process of an `rpc` child; `inprocess` children have none by definition. */
|
|
58
|
+
pid?: number;
|
|
59
|
+
/** Prompt size of the last assistant message; absolute tokens, not a ratio. */
|
|
60
|
+
contextTokens?: number;
|
|
61
|
+
/**
|
|
62
|
+
* Resident memory of this child in MB. Only ever set for a child with its own
|
|
63
|
+
* process (D2): an `inprocess` child has no separate RSS, so it renders `—`
|
|
64
|
+
* rather than a share of the host figure.
|
|
65
|
+
*/
|
|
66
|
+
rss?: number;
|
|
40
67
|
}
|
|
41
68
|
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
69
|
+
/**
|
|
70
|
+
* `StatusEntry.spawnMode` is a plain string (it can come off disk or from an old
|
|
71
|
+
* caller). Only a real mode reaches `VizNode`; anything else stays absent so the
|
|
72
|
+
* panel prints `unknown` instead of echoing an unvalidated backend name.
|
|
73
|
+
*/
|
|
74
|
+
function asSpawnMode(value: string | undefined): SpawnMode | undefined {
|
|
75
|
+
return value !== undefined && (SPAWN_MODES as readonly string[]).includes(value) ? (value as SpawnMode) : undefined;
|
|
76
|
+
}
|
|
46
77
|
|
|
78
|
+
/**
|
|
79
|
+
* D5: raw lifecycle state → display state, delegated to `liveness.ts`.
|
|
80
|
+
*
|
|
81
|
+
* The three local sets that used to live here (`RUNNING`/`SETTLED`/`FAILED`)
|
|
82
|
+
* plus the `return "retired"` catch-all are gone: a fourth copy of the state
|
|
83
|
+
* vocabulary is exactly how the strip, the tree and the rail drifted apart. In
|
|
84
|
+
* particular `idle`/`queued` no longer collapse into `retired`, and anything
|
|
85
|
+
* unrecognised (including `undefined`) is `unknown` — an undecided child must
|
|
86
|
+
* not be painted as finished.
|
|
87
|
+
*/
|
|
47
88
|
export function displayStatus(state: string | undefined): VizStatus {
|
|
48
|
-
|
|
49
|
-
if (SETTLED.has(value)) return "settled";
|
|
50
|
-
if (FAILED.has(value)) return "failed";
|
|
51
|
-
if (RUNNING.has(value)) return "running";
|
|
52
|
-
return "retired";
|
|
89
|
+
return displayStatusFromState(state);
|
|
53
90
|
}
|
|
54
91
|
|
|
55
92
|
/** Build viz nodes from status entries (root row included when present). */
|
|
@@ -60,7 +97,19 @@ export function nodesFromEntries(entries: readonly StatusEntry[]): VizNode[] {
|
|
|
60
97
|
const id = entry.nodeId ?? entry.name;
|
|
61
98
|
if (seen.has(id)) continue;
|
|
62
99
|
seen.add(id);
|
|
63
|
-
const
|
|
100
|
+
const raw = entry.herdrStatus ?? entry.state;
|
|
101
|
+
const status = id === "root" ? "running" : displayStatus(raw);
|
|
102
|
+
const spawnMode = asSpawnMode(entry.spawnMode);
|
|
103
|
+
// `endedAt` means "this lane's lifetime is over", and the rail/log/chart
|
|
104
|
+
// read it as such: the railway drops the lane, `buildLog` emits an end
|
|
105
|
+
// event, the timing pane freezes the wall clock. Only a *terminal* status
|
|
106
|
+
// may claim that. `idle`/`queued` are alive (D5: slot held, not started)
|
|
107
|
+
// and `unknown` is undecided — stamping an end on any of them would erase a
|
|
108
|
+
// live child from the strip and fake an end event for it.
|
|
109
|
+
//
|
|
110
|
+
// The root row is excluded because its display status is pinned to
|
|
111
|
+
// `running` above: an end stamp would contradict the status it carries.
|
|
112
|
+
const ended = id !== "root" && isTerminalState(raw);
|
|
64
113
|
out.push({
|
|
65
114
|
id,
|
|
66
115
|
name: entry.name,
|
|
@@ -69,7 +118,7 @@ export function nodesFromEntries(entries: readonly StatusEntry[]): VizNode[] {
|
|
|
69
118
|
depth: entry.depth ?? (id === "root" ? 0 : 1),
|
|
70
119
|
status,
|
|
71
120
|
startedAt: entry.startedAt,
|
|
72
|
-
...(
|
|
121
|
+
...(ended ? { endedAt: entry.finishedAt ?? entry.startedAt } : {}),
|
|
73
122
|
tokens: Math.max(0, entry.tokensTotal ?? 0),
|
|
74
123
|
tokensOut: Math.max(0, entry.tokensOut ?? 0),
|
|
75
124
|
generationMs: Math.max(0, entry.generationMs ?? 0),
|
|
@@ -77,6 +126,10 @@ export function nodesFromEntries(entries: readonly StatusEntry[]): VizNode[] {
|
|
|
77
126
|
...(entry.currentTurnEndedAt !== undefined ? { currentTurnEndedAt: entry.currentTurnEndedAt } : {}),
|
|
78
127
|
...(entry.turns !== undefined && entry.turns > 0 ? { turns: entry.turns } : {}),
|
|
79
128
|
...(entry.lastActivityAt !== undefined ? { lastActivityAt: entry.lastActivityAt } : {}),
|
|
129
|
+
...(spawnMode ? { spawnMode } : {}),
|
|
130
|
+
...(entry.pid !== undefined ? { pid: entry.pid } : {}),
|
|
131
|
+
...(entry.contextTokens !== undefined ? { contextTokens: entry.contextTokens } : {}),
|
|
132
|
+
...(entry.rss !== undefined ? { rss: entry.rss } : {}),
|
|
80
133
|
});
|
|
81
134
|
}
|
|
82
135
|
// Parents that are missing from the snapshot re-attach to the nearest known ancestor.
|
package/src/tui/viz/palette.ts
CHANGED
|
@@ -177,9 +177,20 @@ export function resolveRolePalette(theme: VizTheme, roles: Iterable<string>): Ma
|
|
|
177
177
|
return out;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Colour token for a node's status glyph.
|
|
182
|
+
*
|
|
183
|
+
* D5: `idle` is *alive but not working* — it holds a concurrency slot, so it
|
|
184
|
+
* must stay on screen — and an undecided (`unknown`) node is not active
|
|
185
|
+
* either. Both render `dim`, which is exactly "present, not doing anything".
|
|
186
|
+
* `queued` keeps its role colour: it has been admitted and is about to run, so
|
|
187
|
+
* greying it out would read as "nothing to see here" for work that is pending.
|
|
188
|
+
*/
|
|
180
189
|
export function statusToken(role: string, status: VizStatus): string {
|
|
181
190
|
if (status === "failed") return "error";
|
|
182
191
|
if (status === "retired") return "dim";
|
|
192
|
+
if (status === "idle") return "dim";
|
|
193
|
+
if (status === "unknown") return "dim";
|
|
183
194
|
return roleColor(role);
|
|
184
195
|
}
|
|
185
196
|
|