pi-legion 3.0.1 → 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/src/tui/status.ts CHANGED
@@ -107,13 +107,22 @@ export function formatElapsed(ms: number): string {
107
107
  }
108
108
 
109
109
  /**
110
- * Relative age for railway "last activity" (`3s ago` / `2m ago` / `1h ago`).
111
- * Missing timestamp → `—` (placeholder; see #159).
110
+ * Age floor for the railway "last activity" field. Below it the field is
111
+ * dropped entirely instead of reading `1s ago` on every live lane.
112
+ */
113
+ export const ACTIVITY_AGO_MIN_SECS = 60;
114
+
115
+ /**
116
+ * Relative age for railway "last activity" (`2m ago` / `1h ago`).
117
+ *
118
+ * Missing timestamp → `—` (placeholder; see #159). Anything fresher than a
119
+ * minute → `""`, meaning "say nothing": a lane that just printed activity is
120
+ * already visibly live, so `1s ago` was pure noise on every running agent.
112
121
  */
113
122
  export function formatActivityAgo(lastActivityAt: number | undefined, now: number): string {
114
123
  if (lastActivityAt === undefined || !Number.isFinite(lastActivityAt)) return "—";
115
124
  const secs = Math.max(0, Math.floor((now - lastActivityAt) / 1000));
116
- if (secs < 60) return `${secs}s ago`;
125
+ if (secs < ACTIVITY_AGO_MIN_SECS) return "";
117
126
  const mins = Math.floor(secs / 60);
118
127
  if (mins < 60) return `${mins}m ago`;
119
128
  const hours = Math.floor(mins / 60);
@@ -11,6 +11,7 @@ import {
11
11
  } from "@earendil-works/pi-tui";
12
12
  import type { LegionEvent } from "../extension/event-bus.ts";
13
13
  import type { LegionNode, NodeStatus } from "../legion/nodes.ts";
14
+ import type { SpawnMode } from "../shared/types.ts";
14
15
  import { osc8NodeLink } from "./status.ts";
15
16
 
16
17
  const TREE_HEADER = "Legion tree · j/k move · enter toggle · / search · pgup/pgdn scroll · esc/q close";
@@ -30,6 +31,13 @@ export interface TreeNodeSnapshot extends LegionNode {
30
31
  lastTools?: readonly string[];
31
32
  /** Unix ms start of the current turn, when known (bus events or snapshots). */
32
33
  turnStartedAt?: number;
34
+ /**
35
+ * Arbitrated backend for this child (#209), merged from the tracked job at
36
+ * snapshot time — `legion.db` has no spawn_mode column. Absent for legacy
37
+ * records and for nodes never launched in this session; readers must render
38
+ * that as unknown, never as `rpc`.
39
+ */
40
+ spawnMode?: SpawnMode;
33
41
  }
34
42
 
35
43
  export interface TreeViewTheme {
@@ -156,8 +156,8 @@ export function formatRate(rate: number): string {
156
156
 
157
157
 
158
158
  /**
159
- * Wide-lane detail after the name: role, turn/lifetime clocks, and
160
- * last-activity age.
159
+ * Wide-lane detail after the name: role, turn/lifetime clocks, and — once it
160
+ * is at least a minute old — the last-activity age.
161
161
  *
162
162
  * The leading clock is "time since the last task started": it keeps ticking
163
163
  * through the quiet gap after a turn settles (the old behavior froze at that
@@ -170,34 +170,37 @@ export function formatRate(rate: number): string {
170
170
  * has a real start time and a real lifetime; show both.
171
171
  */
172
172
  export function formatRailLaneDetail(node: VizNode, now: number): string {
173
- const parts: string[] = [node.role];
174
- parts.push(formatAgentElapsed({
175
- state: node.status,
176
- startedAt: node.startedAt,
177
- ...(node.endedAt !== undefined ? { finishedAt: node.endedAt } : {}),
178
- ...(node.currentTurnStartedAt !== undefined ? { currentTurnStartedAt: node.currentTurnStartedAt } : {}),
179
- }, now));
180
- parts.push(formatActivityAgo(node.lastActivityAt, now));
181
- return parts.join(" · ");
173
+ return laneDetailParts(node, now).join(" · ");
182
174
  }
183
175
 
184
- /** Lane detail candidates, richest first (role → clocks → ago). */
185
- export function laneDetailCandidates(node: VizNode, now: number): string[] {
176
+ /**
177
+ * Lane detail fields, richest first: role, clocks, then last-activity age —
178
+ * the age is omitted entirely while it is under a minute, because a lane that
179
+ * just saw activity is already visibly live and `1s ago` was pure noise.
180
+ */
181
+ function laneDetailParts(node: VizNode, now: number): string[] {
186
182
  const ago = formatActivityAgo(node.lastActivityAt, now);
187
- const clocks = formatAgentElapsed({
188
- state: node.status,
189
- startedAt: node.startedAt,
190
- ...(node.endedAt !== undefined ? { finishedAt: node.endedAt } : {}),
191
- ...(node.currentTurnStartedAt !== undefined ? { currentTurnStartedAt: node.currentTurnStartedAt } : {}),
192
- }, now);
193
183
  return [
194
- `${node.role} · ${clocks} · ${ago}`,
195
- `${clocks} · ${ago}`,
196
- clocks,
197
- ago,
184
+ node.role,
185
+ formatAgentElapsed({
186
+ state: node.status,
187
+ startedAt: node.startedAt,
188
+ ...(node.endedAt !== undefined ? { finishedAt: node.endedAt } : {}),
189
+ ...(node.currentTurnStartedAt !== undefined ? { currentTurnStartedAt: node.currentTurnStartedAt } : {}),
190
+ }, now),
191
+ ...(ago ? [ago] : []),
198
192
  ];
199
193
  }
200
194
 
195
+ /** Lane detail candidates, richest first (role → clocks → ago). */
196
+ export function laneDetailCandidates(node: VizNode, now: number): string[] {
197
+ const parts = laneDetailParts(node, now);
198
+ // Drop fields from the left: the role goes first, the clocks are the last
199
+ // thing to give up. A fresh lane has no age field, so it ends at the clocks
200
+ // instead of paying for a `1s ago` suffix it never shows.
201
+ return parts.map((_, start) => parts.slice(start).join(" · "));
202
+ }
203
+
201
204
  /**
202
205
  * Width of the lane-name column: as wide as the longest name, clamped to
203
206
  * `RAIL_NAME_MAX_COLS` and never below `RAIL_NAME_MIN_COLS`.
@@ -255,7 +258,7 @@ export function railColumnPlan(
255
258
  if (portrait) {
256
259
  const label = Math.max(...shown.map((n) => {
257
260
  const ago = formatActivityAgo(n.lastActivityAt, now);
258
- return Array.from(ago === "—" ? n.name : `${n.name} · ${ago}`).length;
261
+ return Array.from(ago === "" || ago === "—" ? n.name : `${n.name} · ${ago}`).length;
259
262
  }));
260
263
  return { nameW, grade: 0, needed: label + 2 + reserve };
261
264
  }
@@ -423,7 +426,7 @@ export function renderRailway(input: RailInput): RailRender | undefined {
423
426
  const avail = width - (nameX + 2) - overflow.length;
424
427
  if (portrait) {
425
428
  const ago = formatActivityAgo(node.lastActivityAt, now);
426
- const label = ago === "—" ? node.name : `${node.name} · ${ago}`;
429
+ const label = ago === "" || ago === "—" ? node.name : `${node.name} · ${ago}`;
427
430
  canvas.text(nameX + 2, y, ellipsize(label, avail), col, { bold: true });
428
431
  } else {
429
432
  // Paint with the same width the plan measured (clamped to what is