pi-legion 2.0.2 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,39 @@ summary so you can see **2.x vs 0.16** without leaving the tree.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [2.0.4] — 2026-10-15
15
+
16
+ ### Added
17
+
18
+ - `/legion timing [--json] [<name>]`: subagent wall / generation / other timing table
19
+ (reuses session summary parsers; live wall marked `+`; footer span vs sum_wall)
20
+ (#148 / #150).
21
+ - `/legion` independent **timing** panel (4th pane): wide right-top / stacked bottom /
22
+ portrait between chart↔log; fields align CLI timing; Tab / ↑↓ / Enter / `o` →
23
+ context float (#151 / #152).
24
+ - TUI context viewer is a floating modal (dim mask, title **主**/**子**); Esc / mask
25
+ closes without retire or stop (#147 / #149).
26
+
27
+ ### Fixed
28
+
29
+ - `subagent({ action: "status" })` without `name` returns a live-children overview
30
+ (consistent with wait/stop `all` and slash `/legion status`) (#145 / #146).
31
+ - Chart `tok/s · by role` x-domain freezes while idle: `to` advances only on
32
+ substantive `tokensOut` / `generationMs` samples (not wall-clock / flat-collapse)
33
+ (#153 / #154).
34
+
35
+ ## [2.0.3] — 2026-10-15
36
+
37
+ ### Fixed
38
+
39
+ - `maxChildrenPerNode` only counts **live/running** children; `retire` (including
40
+ Already-retired no-op) frees the budget slot so a full tree can launch again
41
+ after retire. Session keep for resume does **not** occupy the concurrent slot
42
+ (#142 / #143).
43
+ - Header tok/s uses `MIN_RATE_GENERATION_MS=1000` (same floor as tree rows): when
44
+ the in-window generation delta is under 1s, fall back to wall-clock so tiny
45
+ `dGen` cannot explode rates into the millions (#140 / #141).
46
+
14
47
  ## [2.0.2] — 2026-10-16
15
48
 
16
49
  ### Fixed
@@ -131,6 +164,10 @@ branch — pin `pi-legion@0.16.x` if you still need that model. See
131
164
  Earlier 0.11–0.15 history (including the `pi-herdr-swarm` /
132
165
  `@zzjcool/pi-herdr-subagents` renames) lives in git tags and older Releases.
133
166
 
134
- [Unreleased]: https://github.com/zzjcool/pi-legion/compare/v2.0.0...HEAD
167
+ [Unreleased]: https://github.com/zzjcool/pi-legion/compare/v2.0.4...HEAD
168
+ [2.0.4]: https://github.com/zzjcool/pi-legion/compare/v2.0.3...v2.0.4
169
+ [2.0.3]: https://github.com/zzjcool/pi-legion/compare/v2.0.2...v2.0.3
170
+ [2.0.2]: https://github.com/zzjcool/pi-legion/compare/v2.0.1...v2.0.2
171
+ [2.0.1]: https://github.com/zzjcool/pi-legion/compare/v2.0.0...v2.0.1
135
172
  [2.0.0]: https://github.com/zzjcool/pi-legion/releases/tag/v2.0.0
136
173
  [0.16.x]: https://github.com/zzjcool/pi-legion/releases
@@ -359,7 +359,7 @@ interface LegionEventBus {
359
359
  | 项 | 默认 | 判定 |
360
360
  |---|---|---|
361
361
  | `maxDepth`(全树深度) | 4 | 硬顶 = `MAX_NESTED_PATH_ENTRIES`(血缘路径在 4 处饱和;settings/env 超 4 截断——告警在 M1-integration 的 launch 路径接入,M0 阶段截断静默可接受)。每进程生效上限 C:根 `C = min(env 运营者覆盖, settings.legion.maxDepth, 4)`;深度 d、上限 C 的父 launch 角色 R:先过闸门 `d+1 > C` → BUDGET_EXCEEDED;再经 `PI_SUBAGENT_MAX_DEPTH` 下发 `C子 = min(C, d + R, 4)`(R 缺省=不收紧;非法值按叶子 R=1 处理;语义 = 子树代数含自身,位置无关,只收紧不放宽)。不变量:C子 ≤ C父,且每进程 depth ≤ maxDepth |
362
- | `maxChildrenPerNode`(单节点扇出) | 8 | 父 supervisor 查自己子节点数 |
362
+ | `maxChildrenPerNode`(单节点扇出) | 8 | 父 supervisor 查自己 **live** 子节点数(`starting`/`running`/`blocked`;`settled`/`failed`/`retired` 不占槽,session 可保留 resume) |
363
363
  | `maxActiveNodes`(全树并发 running+starting) | 30 | **全树真闸门**(替代只当 hint 的 maxConcurrentAgents):launch 前一条 SQL,超限 BUDGET_EXCEEDED + `budget_refused` 事件 |
364
364
 
365
365
  ### 11.2 心跳与孤儿:postman 循环每次刷新直接子节点 `updated_at`。根 supervisor 每 60s 扫描:非终态 + `updated_at` 超 90s = `orphan_detected` 事件 + mail 通知 CEO。恢复 = resume 链(CEO 裁决,auto-resume 默认关)。孤儿判定可判定化:**非终态 ∧ 无活跃进程 ∧ 心跳超时**。
package/index.ts CHANGED
@@ -188,12 +188,12 @@ const SubagentParams = Type.Object({
188
188
  name: Type.Optional(
189
189
  Type.String({
190
190
  description:
191
- "Child handle name. On launch (single agent+task): registers this exact name (lowercase [a-z][a-z0-9_-]{0,31}) instead of auto scout-N; collisions refuse. On control actions (status/wait/stop/…): addresses that child.",
191
+ "Child handle name. On launch (single agent+task): registers this exact name (lowercase [a-z][a-z0-9_-]{0,31}) instead of auto scout-N; collisions refuse. On control actions (wait/stop/collect/…): addresses that child. For status: omit (or pass all:true) for a live-children overview; pass name for one child.",
192
192
  }),
193
193
  ),
194
194
  all: Type.Optional(
195
195
  Type.Boolean({
196
- description: "wait: wait for active children; stop: stop every non-retired child",
196
+ description: "wait: wait for active children; stop: stop every non-retired child; status: overview of all live (non-retired) children under cwd",
197
197
  }),
198
198
  ),
199
199
  timeoutMs: Type.Optional(
@@ -1167,6 +1167,39 @@ async function controlAction(input: {
1167
1167
  });
1168
1168
  }
1169
1169
 
1170
+ // status: no name (or all:true) → live-children overview; named → one child.
1171
+ if (action === "status") {
1172
+ if (params.name && params.all) {
1173
+ return fail(
1174
+ "`name` and `all` are mutually exclusive for status.",
1175
+ ErrorCodes.INVALID_PARAMS,
1176
+ );
1177
+ }
1178
+ if (!params.name) {
1179
+ const live = listLiveChildrenForStatus(store.listRuns());
1180
+ return ok(
1181
+ renderStatusOverview(
1182
+ live.map((child) => ({
1183
+ child,
1184
+ alive: input.supervisor.isAlive(child.name),
1185
+ })),
1186
+ ),
1187
+ );
1188
+ }
1189
+ const found = findChild(store.listRuns(), params.name);
1190
+ if (!found) return unknownChild(params.name, store, cwd);
1191
+ return ok(renderLiveChildStatus({
1192
+ name: found.child.name,
1193
+ state: found.child.state,
1194
+ agent: found.child.agent,
1195
+ kind: found.child.kind,
1196
+ sessionFile: found.child.sessionFile,
1197
+ execution: found.child.execution,
1198
+ acceptance: found.child.acceptance,
1199
+ alive: input.supervisor.isAlive(params.name),
1200
+ }));
1201
+ }
1202
+
1170
1203
  if (!params.name) {
1171
1204
  return fail(
1172
1205
  `\`name\` is required for the "${action}" action.`,
@@ -1200,18 +1233,6 @@ async function controlAction(input: {
1200
1233
  orchestrator,
1201
1234
  };
1202
1235
 
1203
- if (action === "status") {
1204
- return ok(renderLiveChildStatus({
1205
- name: found.child.name,
1206
- state: found.child.state,
1207
- agent: found.child.agent,
1208
- kind: found.child.kind,
1209
- sessionFile: found.child.sessionFile,
1210
- execution: found.child.execution,
1211
- acceptance: found.child.acceptance,
1212
- alive: input.supervisor.isAlive(params.name),
1213
- }));
1214
- }
1215
1236
  if (action === "steer") return steerChild(ctx);
1216
1237
  if (action === "collect") return collectChild(ctx, input.signal);
1217
1238
  if (action === "retire") return retireChild(ctx);
@@ -1224,7 +1245,7 @@ async function controlAction(input: {
1224
1245
  interface ChildContext {
1225
1246
  action: string;
1226
1247
  params: SubagentParams;
1227
- /** Always set: `controlAction` rejects a request without a name. */
1248
+ /** Always set: named control actions reject a request without a name. */
1228
1249
  name: string;
1229
1250
  supervisor: LegionSupervisor;
1230
1251
  uiProxy: UIProxy;
@@ -2508,6 +2529,53 @@ async function abandonLaunchedChild(session: LaunchSession, name: string): Promi
2508
2529
  // wait (§1.5): block for results instead of relying on async notices
2509
2530
  // ---------------------------------------------------------------------------
2510
2531
 
2532
+ /**
2533
+ * Live (non-retired / non-exited) children under this cwd for status overview.
2534
+ *
2535
+ * Names can be reused after retire; prefer the newest live record per name
2536
+ * (same preference as {@link pickChildByName}).
2537
+ */
2538
+ export function listLiveChildrenForStatus(
2539
+ runs: ReturnType<RunStore["listRuns"]>,
2540
+ ): NonNullable<ReturnType<RunStore["findChild"]>>[] {
2541
+ const ranked = [...runs].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
2542
+ const seen = new Set<string>();
2543
+ const out: NonNullable<ReturnType<RunStore["findChild"]>>[] = [];
2544
+ for (const run of ranked) {
2545
+ for (const child of run.children) {
2546
+ if (child.state === "retired" || child.state === "exited") continue;
2547
+ if (seen.has(child.name)) continue;
2548
+ seen.add(child.name);
2549
+ out.push(child);
2550
+ }
2551
+ }
2552
+ return out;
2553
+ }
2554
+
2555
+ /** Multi-child status text: one {@link renderLiveChildStatus} block per live child. */
2556
+ export function renderStatusOverview(
2557
+ children: Array<{
2558
+ child: NonNullable<ReturnType<RunStore["findChild"]>>;
2559
+ alive: boolean;
2560
+ }>,
2561
+ ): string {
2562
+ if (children.length === 0) return "No live children.";
2563
+ const noun = children.length === 1 ? "child" : "children";
2564
+ const blocks = children.map(({ child, alive }) =>
2565
+ renderLiveChildStatus({
2566
+ name: child.name,
2567
+ state: child.state,
2568
+ agent: child.agent,
2569
+ kind: child.kind,
2570
+ sessionFile: child.sessionFile,
2571
+ execution: child.execution,
2572
+ acceptance: child.acceptance,
2573
+ alive,
2574
+ }),
2575
+ );
2576
+ return [`Status · ${children.length} live ${noun}`, "", blocks.join("\n\n")].join("\n");
2577
+ }
2578
+
2511
2579
  /**
2512
2580
  * Resolve which children a wait targets.
2513
2581
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-legion",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
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",
@@ -26,6 +26,7 @@ import {
26
26
  type ToggleCommandDeps,
27
27
  } from "./slash.ts";
28
28
  import { createSummaryCommand } from "./summary.ts";
29
+ import { createTimingCommand } from "./timing.ts";
29
30
  import { getAgentDir } from "../agents/paths.ts";
30
31
  import type {
31
32
  ExtensionAPI,
@@ -304,6 +305,8 @@ function liveNode(
304
305
  status: node.status,
305
306
  createdAt: node.createdAt,
306
307
  updatedAt: node.updatedAt,
308
+ parentId: node.parentId,
309
+ depth: node.depth,
307
310
  ...(node.turn === undefined ? {} : { turn: node.turn }),
308
311
  ...(node.turns === undefined ? {} : { turns: node.turns }),
309
312
  sessionFile: node.sessionFile,
@@ -432,7 +435,8 @@ function openLiveOverlay(
432
435
  });
433
436
  void ui.custom(factory, {
434
437
  overlay: true,
435
- overlayOptions: { width: "100%" },
438
+ // Full-screen host overlay so the viewer paints its own dim mask + floating frame.
439
+ overlayOptions: { width: "100%", maxHeight: "100%", anchor: "top-left", row: 0, col: 0 },
436
440
  onHandle: (handle) => controller.getCurrent()?.attachOverlayHandle(handle),
437
441
  }).catch((error: unknown) => reportOverlayOpenError(controller, ui, error));
438
442
  }
@@ -558,6 +562,7 @@ export function registerLegionCommand(
558
562
  }
559
563
  : {}),
560
564
  summary: createSummaryCommand(pi),
565
+ timing: createTimingCommand(pi),
561
566
  };
562
567
  const helpText = [
563
568
  "Legion commands:",
@@ -567,7 +572,7 @@ export function registerLegionCommand(
567
572
  description: "Open the Legion view or manage teams, agents, subagent runs, and settings",
568
573
  getArgumentCompletions(prefix) {
569
574
  const trimmed = prefix.trimStart();
570
- if (/^(team|agents|on|off|summary)\s/i.test(trimmed)) {
575
+ if (/^(team|agents|on|off|summary|timing)\s/i.test(trimmed)) {
571
576
  const key = trimmed.split(/\s+/, 1)[0]?.toLowerCase() ?? "";
572
577
  if (key === "on" || key === "off") return null;
573
578
  const command = subcommands[key];
@@ -595,7 +600,7 @@ export function registerLegionCommand(
595
600
  const nodes = deps.runtime.treeNodes();
596
601
  const verbs: { value: string; label: string }[] = [];
597
602
  if (!prefix.includes(" ")) {
598
- for (const command of ["team", "agents", "on", "off", "summary", "help"]) {
603
+ for (const command of ["team", "agents", "on", "off", "summary", "timing", "help"]) {
599
604
  if (command.startsWith(prefix)) {
600
605
  verbs.push({ value: command, label: command });
601
606
  }
@@ -68,6 +68,7 @@ export const LEGION_SLASH_HELP_LINES = [
68
68
  "/legion on | /legion off — enable/disable subagents for this session (status is shown after each run)",
69
69
  "/scout-guard | /no-scout-guard — session override for the main-session scout guard",
70
70
  "/legion summary [--all] [<name>]",
71
+ "/legion timing [--json] [<name>] — wall / generation / other timing for children",
71
72
  "/legion doctor [section]",
72
73
  "/legion stop [name|all]",
73
74
  "/legion --tree | /legion <node>",
@@ -11,7 +11,7 @@ export const PARENT_PLAYBOOK = [
11
11
  " Parallel: subagent({ tasks: [{ agent, task }, { agent, task }] })",
12
12
  "2. Prefer tasks[] for independent parallel work. Child processes are headless RPC children; 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` 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 })`; use `stop` to gracefully retire one child or all tracked children. If a child exits oddly (Model not found, missing tools, memory refused, …), call `subagent({ action: \"diagnose\" })` or ask the user to run `/legion doctor` / `/legion doctor models` — children use `--no-extensions`, so provider packages must be passed explicitly. Do not tell a child to message or prompt the parent directly.",
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. If a child exits oddly (Model not found, missing tools, memory refused, …), call `subagent({ action: \"diagnose\" })` or ask the user to run `/legion doctor` / `/legion doctor models` — children use `--no-extensions`, so provider packages must be passed explicitly. Do not tell a child to message or prompt the parent directly.",
15
15
  "5. A collect timeout with a live RPC child is a progress signal, not a verdict. Check status or session JSONL before deciding to steer or resume.",
16
16
  "6. Isolation is YOUR call. Pass `worktree: true` when another parent may write this repo or the child must ship via MR; pass `worktree: false` to share the checkout. Omit it for the role default.",
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.",
@@ -23,7 +23,7 @@ export const TOOL_DESCRIPTION = [
23
23
  PARENT_PLAYBOOK,
24
24
  "Roles: the system prompt lists every loaded role. Prefer the matching role over doing that work yourself.",
25
25
  "Launch is async by default. Outcomes come from the child session JSONL. In non-interactive `pi --print` / JSON mode, shutdown waits for those children before retiring them; pass `async: false` or call `wait`/`collect` in the same turn to fold the result into the print output.",
26
- "Actions: launch (default), continue, steer, resume, status, collect, wait, list, retire, stop, diagnose. `diagnose` prints the Legion doctor report (host vs child extensions/providers/models/memory). `wait` is event-driven: it returns when a child settles, times out, or the parent turn is interrupted; Esc does not retire children — use `stop` (or exit the session) to retire them.",
26
+ "Actions: launch (default), continue, steer, resume, status, collect, wait, list, retire, stop, diagnose. `status` with no name (or `all: true`) returns a live-children overview; with `name` returns that one child (unknown → NOT_FOUND). `diagnose` prints the Legion doctor report (host vs child extensions/providers/models/memory). `wait` is event-driven: it returns when a child settles, times out, or the parent turn is interrupted; Esc does not retire children — use `stop` (or exit the session) to retire them.",
27
27
  ].join(" ");
28
28
 
29
29
  /** Split shell commands into sequential chunks while preserving old helper API. */
@@ -0,0 +1,391 @@
1
+ /**
2
+ * Subagent timing slash command (`/legion timing`).
3
+ *
4
+ * Reuses summary.ts duration resolution + session parse (via aggregateSubagentRuns
5
+ * / parseSessionFile). Zero schema change — wall from spawnedAt/retiredAt|now,
6
+ * generation from ParsedSession.generationMs, other = max(0, wall − gen).
7
+ */
8
+
9
+ import * as path from "node:path";
10
+ import type {
11
+ ExtensionAPI,
12
+ ExtensionCommandContext,
13
+ } from "@earendil-works/pi-coding-agent";
14
+ import { parseSessionFile } from "../shared/session.ts";
15
+ import type { ChildRecord, RunRecord, TaskState } from "../shared/types.ts";
16
+ import {
17
+ RunStore,
18
+ pickChildByName,
19
+ } from "../runs/store.ts";
20
+ import { sendSlashText, type LegionSubcommand } from "./slash.ts";
21
+ import {
22
+ aggregateSubagentRuns,
23
+ formatDuration,
24
+ type SummaryChild,
25
+ type SummarySession,
26
+ type SubagentSummary,
27
+ } from "./summary.ts";
28
+ import { breakdown } from "../shared/timing-breakdown.ts";
29
+
30
+ export { breakdown } from "../shared/timing-breakdown.ts";
31
+
32
+ const TIMING_USAGE = "Usage: /legion timing [--json] [<name>]";
33
+
34
+ export interface TimingChild {
35
+ name: string;
36
+ role: string;
37
+ state: TaskState;
38
+ /** Still in a running lifecycle state (working/blocked/awaiting). */
39
+ live: boolean;
40
+ wall_ms: number;
41
+ /** Session-derived model generation wall; null when no turn data. */
42
+ generation_ms: number | null;
43
+ /** max(0, wall − gen); null when generation_ms unavailable. Coarse bucket. */
44
+ other_ms: number | null;
45
+ turns: number | null;
46
+ startedAtMs: number | null;
47
+ endedAtMs: number | null;
48
+ /** True when wall_ms tracks the live clock (retiredAt absent + running). */
49
+ liveWall: boolean;
50
+ }
51
+
52
+ export interface TimingReport {
53
+ children: TimingChild[];
54
+ /** Earliest spawn → latest end across children with known endpoints. */
55
+ span_ms: number | null;
56
+ sum_wall_ms: number;
57
+ cwd?: string;
58
+ }
59
+
60
+ export interface TimingFormatOptions {
61
+ cwd?: string;
62
+ json?: boolean;
63
+ }
64
+
65
+ function sessionHasTurns(
66
+ session: SummarySession | undefined,
67
+ ): session is SummarySession {
68
+ return Boolean(session && session.turns.length > 0);
69
+ }
70
+
71
+ /** Map one SummaryChild (already duration-resolved) into a timing row. */
72
+ export function timingChildFromSummary(child: SummaryChild): TimingChild {
73
+ const generation_ms = sessionHasTurns(child.session)
74
+ ? child.session.generationMs
75
+ : null;
76
+ const parts = breakdown(child.durationMs, generation_ms);
77
+ return {
78
+ name: child.child.name,
79
+ role: child.role,
80
+ state: child.child.state,
81
+ live: child.running,
82
+ wall_ms: parts.wall_ms,
83
+ generation_ms: parts.generation_ms,
84
+ other_ms: parts.other_ms,
85
+ turns: child.turns,
86
+ startedAtMs: child.startedAtMs,
87
+ endedAtMs: child.endedAtMs,
88
+ liveWall: child.liveDuration,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Build a timing report from the same aggregation path as `/legion summary`.
94
+ * Children are sorted by startedAt (unknowns last), then name.
95
+ */
96
+ export function buildTimingReport(
97
+ summary: SubagentSummary,
98
+ options: { cwd?: string } = {},
99
+ ): TimingReport {
100
+ const children: TimingChild[] = [];
101
+ for (const group of summary.groups) {
102
+ for (const child of group.children) {
103
+ children.push(timingChildFromSummary(child));
104
+ }
105
+ }
106
+ children.sort((a, b) => {
107
+ if (a.startedAtMs === null && b.startedAtMs === null) {
108
+ return a.name.localeCompare(b.name);
109
+ }
110
+ if (a.startedAtMs === null) return 1;
111
+ if (b.startedAtMs === null) return -1;
112
+ return a.startedAtMs - b.startedAtMs || a.name.localeCompare(b.name);
113
+ });
114
+
115
+ const started = children
116
+ .map((c) => c.startedAtMs)
117
+ .filter((v): v is number => v !== null);
118
+ const ended = children
119
+ .map((c) => c.endedAtMs)
120
+ .filter((v): v is number => v !== null);
121
+ const span_ms =
122
+ started.length > 0 && ended.length > 0
123
+ ? Math.max(0, Math.max(...ended) - Math.min(...started))
124
+ : null;
125
+ const sum_wall_ms = children.reduce((sum, c) => sum + c.wall_ms, 0);
126
+
127
+ return {
128
+ children,
129
+ span_ms,
130
+ sum_wall_ms,
131
+ ...(options.cwd
132
+ ? { cwd: options.cwd }
133
+ : summary.cwd
134
+ ? { cwd: summary.cwd }
135
+ : {}),
136
+ };
137
+ }
138
+
139
+ function formatMsCell(
140
+ ms: number | null,
141
+ opts: { live?: boolean } = {},
142
+ ): string {
143
+ if (ms === null) return "—";
144
+ const rendered = formatDuration(ms);
145
+ return opts.live ? `${rendered}+` : rendered;
146
+ }
147
+
148
+ function formatOptionalMs(ms: number | null): string {
149
+ return ms === null ? "—" : formatDuration(ms);
150
+ }
151
+
152
+ function formatTurns(turns: number | null): string {
153
+ return turns === null ? "—" : String(turns);
154
+ }
155
+
156
+ /** Human table for all children + optional concurrency footer. */
157
+ export function formatTimingTable(
158
+ report: TimingReport,
159
+ options: TimingFormatOptions = {},
160
+ ): string {
161
+ if (report.children.length === 0) {
162
+ const cwd = options.cwd ?? report.cwd ?? process.cwd();
163
+ return `No subagent runs under ${cwd}.\nLaunch children with the subagent tool to see timing here.`;
164
+ }
165
+
166
+ const lines = [
167
+ `Subagent timing — ${report.children.length} children`,
168
+ "",
169
+ "| name | state | wall | gen | other | turns |",
170
+ "|---|---|---|---|---|---|",
171
+ ];
172
+ for (const child of report.children) {
173
+ const wall =
174
+ child.startedAtMs === null
175
+ ? "—"
176
+ : formatMsCell(child.wall_ms, { live: child.liveWall });
177
+ lines.push(
178
+ `| ${child.name} | ${child.state} | ${wall} | ${formatOptionalMs(child.generation_ms)} | ${formatOptionalMs(child.other_ms)} | ${formatTurns(child.turns)} |`,
179
+ );
180
+ }
181
+
182
+ if (report.span_ms !== null && report.children.length > 0) {
183
+ const ratio =
184
+ report.span_ms > 0
185
+ ? (report.sum_wall_ms / report.span_ms).toFixed(2)
186
+ : "—";
187
+ lines.push(
188
+ "",
189
+ `span ${formatDuration(report.span_ms)} · sum wall ${formatDuration(report.sum_wall_ms)} · concurrency ~${ratio}×`,
190
+ );
191
+ }
192
+ return lines.join("\n");
193
+ }
194
+
195
+ /** Single-child detail (same口径 as summary detail, timing-focused). */
196
+ export function formatTimingDetail(child: TimingChild, run: RunRecord): string {
197
+ const title = `${child.name} — ${child.role}`;
198
+ const lines = [
199
+ title,
200
+ ` state: ${child.state}${child.live ? " (live)" : ""}`,
201
+ ` run: ${run.runId}`,
202
+ ];
203
+ const wall =
204
+ child.startedAtMs === null
205
+ ? "—"
206
+ : formatMsCell(child.wall_ms, { live: child.liveWall });
207
+ lines.push(` wall: ${wall}`);
208
+ lines.push(` generation: ${formatOptionalMs(child.generation_ms)}`);
209
+ lines.push(
210
+ ` other: ${formatOptionalMs(child.other_ms)} (wall − gen; not precise wait/tool)`,
211
+ );
212
+ lines.push(` turns: ${formatTurns(child.turns)}`);
213
+ return lines.join("\n");
214
+ }
215
+
216
+ function childToJson(child: TimingChild): Record<string, unknown> {
217
+ return {
218
+ name: child.name,
219
+ role: child.role,
220
+ state: child.state,
221
+ live: child.live,
222
+ wall_ms: child.wall_ms,
223
+ generation_ms: child.generation_ms,
224
+ other_ms: child.other_ms,
225
+ turns: child.turns,
226
+ };
227
+ }
228
+
229
+ export function formatTimingJson(
230
+ report: TimingReport,
231
+ detail?: { child: TimingChild; run: RunRecord },
232
+ ): string {
233
+ if (detail) {
234
+ return JSON.stringify(
235
+ {
236
+ ...childToJson(detail.child),
237
+ runId: detail.run.runId,
238
+ },
239
+ null,
240
+ 2,
241
+ );
242
+ }
243
+ return JSON.stringify(
244
+ {
245
+ children: report.children.map(childToJson),
246
+ span_ms: report.span_ms,
247
+ sum_wall_ms: report.sum_wall_ms,
248
+ ...(report.span_ms !== null && report.span_ms > 0
249
+ ? {
250
+ concurrency_ratio: Number(
251
+ (report.sum_wall_ms / report.span_ms).toFixed(4),
252
+ ),
253
+ }
254
+ : {}),
255
+ },
256
+ null,
257
+ 2,
258
+ );
259
+ }
260
+
261
+ function parseTimingArgs(
262
+ args: string,
263
+ ):
264
+ | { ok: true; json: boolean; name?: string }
265
+ | { ok: false; message: string } {
266
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
267
+ let json = false;
268
+ let name: string | undefined;
269
+ for (const token of tokens) {
270
+ if (token === "--json") {
271
+ if (json) return { ok: false, message: TIMING_USAGE };
272
+ json = true;
273
+ continue;
274
+ }
275
+ if (token.startsWith("-")) return { ok: false, message: TIMING_USAGE };
276
+ if (name !== undefined) return { ok: false, message: TIMING_USAGE };
277
+ name = token;
278
+ }
279
+ return { ok: true, json, ...(name ? { name } : {}) };
280
+ }
281
+
282
+ function sessionMapFor(runs: readonly RunRecord[]): Map<string, SummarySession> {
283
+ const sessions = new Map<string, SummarySession>();
284
+ for (const run of runs) {
285
+ for (const child of run.children) {
286
+ if (child.sessionFile) {
287
+ sessions.set(child.sessionFile, parseSessionFile(child.sessionFile));
288
+ }
289
+ }
290
+ }
291
+ return sessions;
292
+ }
293
+
294
+ function timingCompletions(
295
+ prefix: string,
296
+ cwd: string,
297
+ ): Array<{ value: string; label: string }> | null {
298
+ const trimmed = prefix.trimStart();
299
+ if (trimmed.startsWith("--") && !trimmed.includes(" ")) {
300
+ return "--json".startsWith(trimmed)
301
+ ? [{ value: "--json", label: "--json" }]
302
+ : [];
303
+ }
304
+ const json = /^--json\s+/.test(trimmed);
305
+ const namePrefix = json ? trimmed.replace(/^--json\s+/, "") : prefix.trim();
306
+ if (namePrefix.includes(" ")) return null;
307
+ const store = new RunStore({ rootDir: path.join(cwd, ".pi-subagents") });
308
+ const names = new Set<string>();
309
+ for (const run of store.listRuns()) {
310
+ for (const child of run.children) names.add(child.name);
311
+ }
312
+ return [...names]
313
+ .filter((name) => name.startsWith(namePrefix))
314
+ .sort((a, b) => a.localeCompare(b))
315
+ .map((name) => ({
316
+ value: json ? `--json ${name}` : name,
317
+ label: name,
318
+ }));
319
+ }
320
+
321
+ function notifyUnknownChild(
322
+ ctx: ExtensionCommandContext,
323
+ name: string,
324
+ ): void {
325
+ ctx.ui.notify(
326
+ `unknown child: ${name} (no run under ${ctx.cwd}/.pi-subagents). Child records are scoped to the cwd they were launched from — pass the same cwd used for launch.`,
327
+ "error",
328
+ );
329
+ }
330
+
331
+ function findTimingChild(
332
+ report: TimingReport,
333
+ summary: SubagentSummary,
334
+ name: string,
335
+ ): { timing: TimingChild; run: RunRecord; child: ChildRecord } | null {
336
+ const picked = pickChildByName(summary.runs, name);
337
+ if (!picked) return null;
338
+ const timing = report.children.find((c) => c.name === picked.child.name);
339
+ if (!timing) return null;
340
+ return { timing, run: picked.run, child: picked.child };
341
+ }
342
+
343
+ /** Build the `/legion timing [--json] [<name>]` handler. */
344
+ export function createTimingCommand(pi: ExtensionAPI): LegionSubcommand {
345
+ let completionCwd = process.cwd();
346
+ return {
347
+ description: "Show wall / generation / other timing for subagent children",
348
+ getArgumentCompletions: (prefix) => timingCompletions(prefix, completionCwd),
349
+ handler: async (args, ctx) => {
350
+ completionCwd = ctx.cwd;
351
+ const parsed = parseTimingArgs(args);
352
+ if (parsed.ok === false) {
353
+ ctx.ui.notify(parsed.message, "error");
354
+ return;
355
+ }
356
+ const store = new RunStore({
357
+ rootDir: path.join(ctx.cwd, ".pi-subagents"),
358
+ });
359
+ const runs = store.listRuns();
360
+ const sessions = sessionMapFor(runs);
361
+ const now = Date.now();
362
+ const summary = aggregateSubagentRuns(runs, { now, sessions });
363
+ const report = buildTimingReport(summary, { cwd: ctx.cwd });
364
+
365
+ if (parsed.name) {
366
+ const found = findTimingChild(report, summary, parsed.name);
367
+ if (!found) {
368
+ notifyUnknownChild(ctx, parsed.name);
369
+ return;
370
+ }
371
+ sendSlashText(
372
+ pi,
373
+ parsed.json
374
+ ? formatTimingJson(report, {
375
+ child: found.timing,
376
+ run: found.run,
377
+ })
378
+ : formatTimingDetail(found.timing, found.run),
379
+ );
380
+ return;
381
+ }
382
+
383
+ sendSlashText(
384
+ pi,
385
+ parsed.json
386
+ ? formatTimingJson(report)
387
+ : formatTimingTable(report, { cwd: ctx.cwd }),
388
+ );
389
+ },
390
+ };
391
+ }