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 +38 -1
- package/docs/legion-v2-contract.md +1 -1
- package/index.ts +83 -15
- package/package.json +1 -1
- package/src/extension/legion-commands.ts +8 -3
- package/src/extension/legion-doctor.ts +1 -0
- package/src/extension/playbook.ts +2 -2
- package/src/extension/timing.ts +391 -0
- package/src/legion/budget.ts +5 -3
- package/src/legion/nodes.ts +7 -0
- package/src/runs/orchestrator.ts +3 -2
- package/src/shared/timing-breakdown.ts +23 -0
- package/src/tui/live-overlay.ts +202 -21
- package/src/tui/viz/layout.ts +50 -6
- package/src/tui/viz/panels.ts +156 -3
- package/src/tui/viz/rate.ts +21 -0
- package/src/tui/viz/view.ts +69 -10
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.
|
|
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 (
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|