pi-legion 2.0.0 → 2.0.1

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.
@@ -2,8 +2,9 @@
2
2
  * `/legion doctor` and `subagent action=diagnose`: broad self-check for
3
3
  * pi-legion (inspired by `pi list` / `pi auth check` / `pi --help`).
4
4
  *
5
- * Sections are filterable via `/legion doctor <section>` so the main agent can
6
- * pull only what it needs, then act on the structured Next steps.
5
+ * Sections are filterable via `/legion doctor [section]` or
6
+ * `subagent({ action: "diagnose", section })` so the main agent can pull only
7
+ * what it needs, then act on the structured Next steps.
7
8
  */
8
9
 
9
10
  import { execFileSync } from "node:child_process";
@@ -13,11 +14,16 @@ import * as path from "node:path";
13
14
  import {
14
15
  CHILD_EXTENSIONS_ENV,
15
16
  CHILD_INHERIT_EXTENSIONS_ENV,
16
- childExtraExtensions,
17
- childInheritsExtensions,
18
17
  buildPiArgs,
18
+ childExtraExtensions,
19
19
  childGuardExtensionPath,
20
+ childInheritsExtensions,
21
+ collectChildExtensions,
20
22
  } from "../runs/args.ts";
23
+ import {
24
+ collectRoleToolExtensionIssues,
25
+ roleToolGateNextSteps,
26
+ } from "../runs/role-tool-gates.ts";
21
27
  import { getAgentDir } from "../agents/paths.ts";
22
28
  import {
23
29
  CHILD_MAX_OLD_SPACE_ENV,
@@ -50,6 +56,24 @@ import { IDLE_STOP_HINT, IDLE_STOP_HINT_SHORT } from "./legion-stop.ts";
50
56
  import type { AgentConfig } from "../shared/types.ts";
51
57
  import { recentSpawnFailures, type SpawnFailureRecord } from "./spawn-hints.ts";
52
58
 
59
+ /**
60
+ * Canonical slash surface. `/legion help` and the doctor Tools section both
61
+ * print these lines so the two lists cannot drift.
62
+ */
63
+ export const LEGION_SLASH_HELP_LINES = [
64
+ "/legion — open the Legion view (TUI)",
65
+ "/legion status [name] — whole-tree summary, or one node (same text as subagent status)",
66
+ "/legion team [list|use <name> [--global]|create <name> a,b,c [--global]]",
67
+ "/legion agents [user|project|both]",
68
+ "/legion on | /legion off — enable/disable subagents for this session (status is shown after each run)",
69
+ "/scout-guard | /no-scout-guard — session override for the main-session scout guard",
70
+ "/legion summary [--all] [<name>]",
71
+ "/legion doctor [section]",
72
+ "/legion stop [name|all]",
73
+ "/legion --tree | /legion <node>",
74
+ "/legion help",
75
+ ] as const;
76
+
53
77
  export const DOCTOR_SECTIONS = [
54
78
  "summary",
55
79
  "extensions",
@@ -64,6 +88,15 @@ export const DOCTOR_SECTIONS = [
64
88
 
65
89
  export type DoctorSection = (typeof DOCTOR_SECTIONS)[number];
66
90
 
91
+ /**
92
+ * Doctor warns when `.hygiene-last.json` is missing or older than this.
93
+ * Shorter than the 14-day run-retention default so a week without a root
94
+ * restart surfaces before retention pruning goes quiet (#85).
95
+ */
96
+ export const HYGIENE_SWEEP_STALE_DAYS = 7;
97
+
98
+ const DAY_MS = 24 * 60 * 60 * 1000;
99
+
67
100
  export interface DoctorPackage {
68
101
  source: string;
69
102
  name: string;
@@ -352,17 +385,10 @@ export function parseDoctorSections(raw: string | undefined): DoctorSection[] |
352
385
  return out.length ? out : undefined;
353
386
  }
354
387
 
355
- function extensionsForRole(agent: AgentConfig, env: NodeJS.ProcessEnv, extras: string[]): string[] {
388
+ function extensionsForRole(agent: AgentConfig, env: NodeJS.ProcessEnv, _extras: string[]): string[] {
356
389
  if (childInheritsExtensions(agent, env)) return ["(all host-discovered extensions)"];
357
- const seen = new Set<string>();
358
- const out: string[] = [];
359
- for (const ext of [...(agent.extensions ?? []), ...(agent.subagentOnlyExtensions ?? []), ...extras, childGuardExtensionPath()]) {
360
- const resolved = path.resolve(ext);
361
- if (seen.has(resolved)) continue;
362
- seen.add(resolved);
363
- out.push(ext);
364
- }
365
- return out;
390
+ // Same effective list as `buildPiArgs` / `collectChildExtensions` (#89 builtin:codemode).
391
+ return collectChildExtensions(agent, env);
366
392
  }
367
393
 
368
394
  function scanOrphanTemps(storeRoot: string): { total: number; orphanDead: number; live: number; unknown: number; samples: string[] } {
@@ -401,6 +427,61 @@ function formatBytes(n: number): string {
401
427
  return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GiB`;
402
428
  }
403
429
 
430
+ interface HygieneLastMarker {
431
+ at?: number;
432
+ removedTempDirs?: number;
433
+ prunedRuns?: number;
434
+ deletedEvents?: number;
435
+ freedBytes?: number;
436
+ errors?: number;
437
+ }
438
+
439
+ function readHygieneLast(storeRoot: string): HygieneLastMarker | undefined {
440
+ const last = readJson(path.join(storeRoot, HYGIENE_LAST_FILE));
441
+ if (!last || typeof last !== "object") return undefined;
442
+ return last as HygieneLastMarker;
443
+ }
444
+
445
+ function hygieneSweepAt(last: HygieneLastMarker | undefined): number | undefined {
446
+ return typeof last?.at === "number" && Number.isFinite(last.at) ? last.at : undefined;
447
+ }
448
+
449
+ /**
450
+ * Mirrors `envNumber` in hygiene.ts: blank or non-finite / negative values
451
+ * fall back to the built-in default, so doctor labels those as default.
452
+ */
453
+ function hygieneConfigSource(env: NodeJS.ProcessEnv, key: string): "default" | "env" {
454
+ const raw = env[key]?.trim();
455
+ if (!raw) return "default";
456
+ const n = Number(raw);
457
+ return Number.isFinite(n) && n >= 0 ? "env" : "default";
458
+ }
459
+
460
+ function formatHygieneToggleSource(env: NodeJS.ProcessEnv): string {
461
+ const raw = env[HYGIENE_ENV.disable]?.trim();
462
+ if (!raw) return `default; ${HYGIENE_ENV.disable}`;
463
+ return `env ${HYGIENE_ENV.disable}=${raw}`;
464
+ }
465
+
466
+ /** Effective `resolveHygieneConfig()` values, each tagged default or env. */
467
+ function formatHygieneConfigLines(env: NodeJS.ProcessEnv): string[] {
468
+ const hy = resolveHygieneConfig(env);
469
+ const rows: Array<[string, string, string]> = [
470
+ ["retentionDays", `${hy.retentionDays}d`, HYGIENE_ENV.retentionDays],
471
+ ["staleNodeHours", `${hy.staleNodeHours}h`, HYGIENE_ENV.staleNodeHours],
472
+ ["legacyTempHours", `${hy.legacyTempHours}h`, HYGIENE_ENV.legacyTempHours],
473
+ ["eventRetentionDays", `${hy.eventRetentionDays}d`, HYGIENE_ENV.eventRetentionDays],
474
+ ["eventMaxRows", String(hy.eventMaxRows), HYGIENE_ENV.eventMaxRows],
475
+ ];
476
+ return rows.map(([label, value, envKey]) => {
477
+ const source = hygieneConfigSource(env, envKey);
478
+ return ` ${label}=${value} (${source === "env" ? `env ${envKey}` : "default"})`;
479
+ });
480
+ }
481
+
482
+ const HYGIENE_SWEEP_HINT =
483
+ `Restart the root pi session so startup hygiene runs, or check ${HYGIENE_ENV.disable}=0.`;
484
+
404
485
  export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
405
486
  const env = options.env ?? process.env;
406
487
  const agentDir = options.agentDir ?? getAgentDir();
@@ -524,20 +605,12 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
524
605
  }
525
606
 
526
607
  const agents = options.agents ?? (options.sampleAgent ? [options.sampleAgent] : []);
608
+ // #97: extensible tool→extension gates (lens #70, codemode #89, …).
527
609
  for (const agent of agents) {
528
- const needsLens = (agent.tools ?? []).some((t) => /lens|symbol_search|project_report|module_report|read_symbol|read_enclosing/i.test(t));
529
- const exts = extensionsForRole(agent, env, extras);
530
- const hasLens =
531
- childInheritsExtensions(agent, env) ||
532
- exts.some((e) => /pi-lens/i.test(e));
533
- if (needsLens && !hasLens && !inheritGlobal) {
534
- issues.push({
535
- severity: "warning",
536
- code: "ROLE_MISSING_LENS",
537
- section: "extensions",
538
- message: `Role "${agent.name}" lists lens tools but would not load pi-lens under --no-extensions.`,
539
- fix: `Add pi-lens to ${agent.name}'s extensions: frontmatter, or to ${CHILD_EXTENSIONS_ENV}.`,
540
- });
610
+ const inherits = childInheritsExtensions(agent, env) || inheritGlobal;
611
+ const exts = inherits ? [] : extensionsForRole(agent, env, extras);
612
+ for (const issue of collectRoleToolExtensionIssues(agent, exts, { inheritsExtensions: inherits })) {
613
+ issues.push(issue);
541
614
  }
542
615
  }
543
616
 
@@ -575,6 +648,7 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
575
648
 
576
649
  const storeRoot = options.storeRoot ?? (options.cwd ? path.join(options.cwd, ".pi-subagents") : undefined);
577
650
  const orphans = storeRoot ? scanOrphanTemps(storeRoot) : undefined;
651
+ const hygieneLast = storeRoot ? readHygieneLast(storeRoot) : undefined;
578
652
  if (orphans && orphans.orphanDead > 0) {
579
653
  issues.push({
580
654
  severity: "info",
@@ -583,6 +657,19 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
583
657
  message: `${orphans.orphanDead} rpc-child temp dir(s) have a dead owner (startup hygiene will remove them).`,
584
658
  });
585
659
  }
660
+ const sweepAt = hygieneSweepAt(hygieneLast);
661
+ const sweepStale = storeRoot !== undefined && (sweepAt === undefined || Date.now() - sweepAt > HYGIENE_SWEEP_STALE_DAYS * DAY_MS);
662
+ if (sweepStale) {
663
+ issues.push({
664
+ severity: "info",
665
+ code: "HYGIENE_SWEEP_STALE",
666
+ section: "storage",
667
+ message: sweepAt === undefined
668
+ ? `No ${HYGIENE_LAST_FILE} marker — startup hygiene has not recorded a sweep.`
669
+ : `Last hygiene sweep ${new Date(sweepAt).toISOString()} is older than ${HYGIENE_SWEEP_STALE_DAYS} days.`,
670
+ fix: HYGIENE_SWEEP_HINT,
671
+ });
672
+ }
586
673
 
587
674
  const verify = inspectVerifySlots(resolveVerifyQueueConfig(env));
588
675
  const staleVerify = verify.holders.filter((h) => h.alive === false);
@@ -718,29 +805,28 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
718
805
 
719
806
  if (want(selected, "tools")) {
720
807
  push("## Tools / commands");
721
- push("- slash: `/legion` (view), `/legion doctor [section]`, `/legion stop [name|all]`, `/legion --tree`, `/legion <name>`");
808
+ push("- slash:");
809
+ for (const line of LEGION_SLASH_HELP_LINES) push(` ${line}`);
722
810
  push(`- stop wired: ${runtime.stopAvailable === undefined ? "(unknown)" : runtime.stopAvailable ? "yes" : "no"}`);
723
811
  push(`- idle Esc stop wired: ${runtime.idleEscWired === undefined ? "(unknown)" : runtime.idleEscWired ? "yes" : "no"}`);
724
812
  push(`- idle strip hints: "${IDLE_STOP_HINT}" / short "${IDLE_STOP_HINT_SHORT}"`);
725
813
  push(`- legion tools registered: ${(runtime.legionTools ?? ["(unknown — typically subagent, vexillatio, legion-* mail/tools)"]).join(", ")}`);
726
- push("- diagnose: `subagent({ action: \"diagnose\" })` or `subagent({ action: \"diagnose\" })` with section via `/legion doctor <section>`");
814
+ push("- slash: `/legion doctor [section]`");
815
+ push("- tool: `subagent({ action: \"diagnose\", section? })` — optional section filter (same as `/legion doctor [section]`)");
727
816
  push("");
728
817
  }
729
818
 
730
819
  if (want(selected, "storage")) {
731
820
  push("## Storage / hygiene");
732
- push(`- hygiene enabled: ${hygieneEnabled(env)}`);
733
- const hy = resolveHygieneConfig(env);
734
- push(`- retention: runs ${hy.retentionDays}d, stale nodes ${hy.staleNodeHours}h, legacy temps ${hy.legacyTempHours}h, events ${hy.eventRetentionDays}d / max ${hy.eventMaxRows}`);
735
- push(`- env: ${HYGIENE_ENV.disable}, ${HYGIENE_ENV.retentionDays}, ${HYGIENE_ENV.staleNodeHours}, …`);
821
+ push(`- hygiene enabled: ${hygieneEnabled(env)} (${formatHygieneToggleSource(env)})`);
822
+ push("- resolveHygieneConfig():");
823
+ for (const line of formatHygieneConfigLines(env)) push(line);
736
824
  if (!storeRoot) {
737
825
  push("- storeRoot: (not provided — pass cwd/storeRoot for db/orphan stats)");
738
826
  } else {
739
827
  push(`- storeRoot: ${homeize(storeRoot, home)}`);
740
- const lastPath = path.join(storeRoot, HYGIENE_LAST_FILE);
741
- const last = readJson(lastPath) as { at?: number; removedTempDirs?: number; prunedRuns?: number; deletedEvents?: number; freedBytes?: number; errors?: number } | undefined;
742
- if (last?.at) {
743
- push(`- last sweep: ${new Date(last.at).toISOString()} (removedTemps=${last.removedTempDirs ?? 0}, prunedRuns=${last.prunedRuns ?? 0}, deletedEvents=${last.deletedEvents ?? 0}, freed=${formatBytes(last.freedBytes ?? 0)}, errors=${last.errors ?? 0})`);
828
+ if (sweepAt !== undefined) {
829
+ push(`- last sweep: ${new Date(sweepAt).toISOString()} (removedTemps=${hygieneLast?.removedTempDirs ?? 0}, prunedRuns=${hygieneLast?.prunedRuns ?? 0}, deletedEvents=${hygieneLast?.deletedEvents ?? 0}, freed=${formatBytes(hygieneLast?.freedBytes ?? 0)}, errors=${hygieneLast?.errors ?? 0})`);
744
830
  } else {
745
831
  push("- last sweep: (no marker yet — written on next root-session startup hygiene)");
746
832
  }
@@ -817,15 +903,16 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
817
903
  steps.push(`export ${CHILD_EXTENSIONS_ENV}=${providerExtHome.join(",")} # then restart pi`);
818
904
  steps.push(`# or role frontmatter extensions: […] / inheritExtensions: true (more RAM)`);
819
905
  }
820
- if (issues.some((i) => i.code === "ROLE_MISSING_LENS")) {
821
- steps.push("Add pi-lens extension path to roles that list lens_* tools, or to PI_LEGION_CHILD_EXTENSIONS.");
822
- }
906
+ steps.push(...roleToolGateNextSteps(issues));
823
907
  if (issues.some((i) => i.code === "MEMORY_GATE_WOULD_REFUSE")) {
824
908
  steps.push("Stop idle children (`/legion stop`) or lower/disable PI_LEGION_MIN_FREE_MEM_MB before launching more.");
825
909
  }
826
910
  if (issues.some((i) => i.code === "ORPHAN_TEMPS")) {
827
911
  steps.push("Orphan temps clear on next root session start; or restart pi to trigger hygiene.");
828
912
  }
913
+ if (selected.has("storage") && issues.some((i) => i.code === "HYGIENE_SWEEP_STALE")) {
914
+ steps.push(HYGIENE_SWEEP_HINT);
915
+ }
829
916
  if (issues.some((i) => i.code === "STALE_VERIFY_SLOTS")) {
830
917
  steps.push("Stale verify locks are reclaimed automatically; delete slot-*.lock under the lock dir if a slot stays stuck.");
831
918
  }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * `/legion status [name]` — chat text for humans.
3
+ *
4
+ * No name: the whole tree, same lines as `formatTree` (the legion_tree summary).
5
+ * A name: `renderLiveChildStatus`, the same text as `subagent({ action: "status" })`
6
+ * when a run-store child record is available.
7
+ */
8
+
9
+ import { NODE_STATUSES, type LegionNode } from "../legion/nodes.ts";
10
+ import { formatTree } from "./legion-tools.ts";
11
+ import {
12
+ renderLiveChildStatus,
13
+ type LiveChildStatusInput,
14
+ } from "./runtime.ts";
15
+
16
+ /** Child snapshot the status tool already prints. `now` is supplied by the caller. */
17
+ export type LegionChildStatusFacts = Omit<LiveChildStatusInput, "now">;
18
+
19
+ export interface LegionStatusJob {
20
+ name: string;
21
+ nodeId?: string;
22
+ state: string;
23
+ }
24
+
25
+ export interface LegionStatusInput {
26
+ /** Empty shows the tree. Otherwise a node id, short id, or child name. */
27
+ query?: string;
28
+ nodes: readonly LegionNode[];
29
+ jobs?: readonly LegionStatusJob[];
30
+ /**
31
+ * Persisted child behind the subagent status action.
32
+ * Undefined when this process has no run-store record for the name.
33
+ */
34
+ lookupChild?: (name: string) => LegionChildStatusFacts | undefined;
35
+ now?: number;
36
+ }
37
+
38
+ export interface LegionStatusReport {
39
+ ok: boolean;
40
+ text: string;
41
+ }
42
+
43
+ const LIVE_NODE_STATUSES = new Set(["starting", "running", "blocked"]);
44
+
45
+ function nodesMatching(nodes: readonly LegionNode[], query: string): LegionNode[] {
46
+ const byId = nodes.filter((node) => node.id === query);
47
+ if (byId.length > 0) return byId;
48
+ return nodes.filter((node) =>
49
+ node.name === query || node.id.slice(node.id.lastIndexOf(".") + 1) === query,
50
+ );
51
+ }
52
+
53
+ function statusCounts(nodes: readonly LegionNode[]): string {
54
+ const counts = new Map<string, number>();
55
+ for (const node of nodes) counts.set(node.status, (counts.get(node.status) ?? 0) + 1);
56
+ const known = new Set<string>(NODE_STATUSES);
57
+ const ordered = [
58
+ ...NODE_STATUSES.filter((status) => counts.has(status)),
59
+ ...[...counts.keys()].filter((status) => !known.has(status)),
60
+ ];
61
+ return ordered.map((status) => `${status}=${counts.get(status)}`).join(", ");
62
+ }
63
+
64
+ function withNow(facts: LegionChildStatusFacts, now: number | undefined): LiveChildStatusInput {
65
+ return now === undefined ? facts : { ...facts, now };
66
+ }
67
+
68
+ function liveJobForNode(node: LegionNode, jobs: readonly LegionStatusJob[]): LegionStatusJob | undefined {
69
+ return jobs.find((job) =>
70
+ job.state !== "retired" && (job.nodeId === node.id || (job.nodeId === undefined && job.name === node.name)),
71
+ );
72
+ }
73
+
74
+ function renderFacts(facts: LegionChildStatusFacts, now: number | undefined): string {
75
+ return renderLiveChildStatus(withNow(facts, now));
76
+ }
77
+
78
+ function renderNode(
79
+ node: LegionNode,
80
+ jobs: readonly LegionStatusJob[],
81
+ lookupChild: LegionStatusInput["lookupChild"],
82
+ now: number | undefined,
83
+ ): string {
84
+ const facts = lookupChild?.(node.name);
85
+ if (facts) return renderFacts(facts, now);
86
+ const job = liveJobForNode(node, jobs);
87
+ const alive = job
88
+ ? job.state === "working" || job.state === "blocked"
89
+ : LIVE_NODE_STATUSES.has(node.status);
90
+ return renderLiveChildStatus(withNow({
91
+ name: node.name,
92
+ state: node.status,
93
+ agent: node.role,
94
+ kind: node.kind,
95
+ sessionFile: node.sessionFile ?? "",
96
+ alive,
97
+ }, now));
98
+ }
99
+
100
+ function renderJob(
101
+ job: LegionStatusJob,
102
+ lookupChild: LegionStatusInput["lookupChild"],
103
+ now: number | undefined,
104
+ ): string {
105
+ const facts = lookupChild?.(job.name);
106
+ if (facts) return renderFacts(facts, now);
107
+ return renderLiveChildStatus(withNow({
108
+ name: job.name,
109
+ state: job.state,
110
+ sessionFile: "",
111
+ alive: job.state === "working" || job.state === "blocked",
112
+ }, now));
113
+ }
114
+
115
+ function jobsOutsideTree(nodes: readonly LegionNode[], jobs: readonly LegionStatusJob[]): LegionStatusJob[] {
116
+ const ids = new Set(nodes.map((node) => node.id));
117
+ const names = new Set(nodes.map((node) => node.name));
118
+ return jobs.filter((job) =>
119
+ job.state !== "retired" &&
120
+ (job.nodeId === undefined || !ids.has(job.nodeId)) &&
121
+ !names.has(job.name),
122
+ );
123
+ }
124
+
125
+ function renderTree(nodes: readonly LegionNode[], jobs: readonly LegionStatusJob[]): string {
126
+ const noun = nodes.length === 1 ? "node" : "nodes";
127
+ const counts = nodes.length > 0 ? ` · ${statusCounts(nodes)}` : "";
128
+ const lines = [`Legion status · ${nodes.length} ${noun}${counts}`];
129
+ lines.push("");
130
+ lines.push(nodes.length > 0 ? formatTree(nodes) : "No legion nodes.");
131
+ const extras = jobsOutsideTree(nodes, jobs);
132
+ if (extras.length > 0) {
133
+ lines.push("", "Live jobs not in the tree:");
134
+ for (const job of extras) lines.push(`- ${job.name} state=${job.state}`);
135
+ }
136
+ lines.push("", "One node: /legion status <name>");
137
+ return lines.join("\n");
138
+ }
139
+
140
+ /** Human-readable `/legion status` text. `ok: false` is an unknown or ambiguous name. */
141
+ export function renderLegionStatus(input: LegionStatusInput): LegionStatusReport {
142
+ const query = input.query?.trim() ?? "";
143
+ const jobs = input.jobs ?? [];
144
+ if (!query) return { ok: true, text: renderTree(input.nodes, jobs) };
145
+
146
+ const matches = nodesMatching(input.nodes, query);
147
+ if (matches.length > 1) {
148
+ return {
149
+ ok: false,
150
+ text: `"${query}" is ambiguous; use a full node id with /legion status <node-id>.`,
151
+ };
152
+ }
153
+ if (matches.length === 1) {
154
+ return { ok: true, text: renderNode(matches[0]!, jobs, input.lookupChild, input.now) };
155
+ }
156
+
157
+ const namedJobs = jobs.filter((job) => job.name === query || job.nodeId === query);
158
+ if (namedJobs.length > 1) {
159
+ return {
160
+ ok: false,
161
+ text: `"${query}" is ambiguous; use a full node id with /legion status <node-id>.`,
162
+ };
163
+ }
164
+ if (namedJobs.length === 1) {
165
+ return { ok: true, text: renderJob(namedJobs[0]!, input.lookupChild, input.now) };
166
+ }
167
+
168
+ const facts = input.lookupChild?.(query);
169
+ if (facts) return { ok: true, text: renderFacts(facts, input.now) };
170
+
171
+ return { ok: false, text: `Unknown Legion node "${query}".` };
172
+ }
@@ -7,10 +7,10 @@ export const PLAYBOOK_NAME = "pi-legion-playbook";
7
7
  export const PARENT_PLAYBOOK = [
8
8
  "Pi Legion playbook (frozen — do not invent a launch recipe):",
9
9
  "1. Call the `subagent` tool immediately with the matching role from the roster in this prompt. Do not substitute bash/curl/web fetch for a search or research role.",
10
- ' One child: subagent({ agent: "<role>", task: "..." })',
10
+ ' One child: subagent({ agent: "<role>", task: "...", name?: "scout-a" }) // optional name= registers that handle (not auto scout-N)',
11
11
  " Parallel: subagent({ tasks: [{ agent, task }, { agent, task }] })",
12
12
  "2. Prefer tasks[] for independent parallel work. Child processes are headless RPC children; no pane or tab is created.",
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. Pressing Esc to abort a parent turn gracefully retires its children; a wait interrupted by Esc returns `interrupted (parent turn aborted)` after cascade shutdown.",
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. Pressing Esc to abort a parent turn gracefully retires its children; a wait interrupted by Esc returns `interrupted (parent turn aborted)` after cascade shutdown. 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
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.",
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.",
@@ -22,7 +22,7 @@ export const PARENT_PLAYBOOK = [
22
22
  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
- "Launch is async by default. Outcomes come from the child session JSONL.",
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
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 aborted; an Esc abort also gracefully retires children owned by that turn. `stop` explicitly and gracefully retires selected children.",
27
27
  ].join(" ");
28
28
 
@@ -30,7 +30,7 @@ import {
30
30
  } from "../tui/status.ts";
31
31
  import { progressFromSessionFile } from "../shared/progress.ts";
32
32
  import { isTransientConnectionFailure } from "../shared/session.ts";
33
- import { DEFAULTS, type AgentKind } from "../shared/types.ts";
33
+ import { DEFAULTS, type AgentKind, type RetireOptions } from "../shared/types.ts";
34
34
  import type {
35
35
  EventSubscriptionOptions,
36
36
  SupervisorEvent,
@@ -81,7 +81,7 @@ export interface TrackedJobInput {
81
81
  isAlive?: () => boolean;
82
82
  persist?: (snapshot: CollectSnapshot) => Promise<void>;
83
83
  /** Retire the RPC child after a terminal collect. Blocked children stay alive. */
84
- retire?: () => Promise<void>;
84
+ retire?: (opts?: RetireOptions) => Promise<void>;
85
85
  /**
86
86
  * Called when collect reports a blocked child. `resume` rewatches after
87
87
  * the parent approved or denied; `hold` keeps the widget up.
@@ -190,10 +190,35 @@ export function lastJsonlEventAgeMs(sessionFile: string, now: number): number |
190
190
  }
191
191
  }
192
192
 
193
+ /** Quiet age at or above this is treated as a long silent stretch (P2a). */
194
+ export const LONG_QUIET_MS = 30_000;
195
+
196
+ /**
197
+ * Lead line for a living child: process is up, session JSONL quiet for N seconds.
198
+ * `ageMs` undefined means the session file is missing or unreadable.
199
+ * Long quiet (>= {@link LONG_QUIET_MS}) adds `(living)` so 「活着但安静」reads first.
200
+ */
201
+ export function formatAliveQuiet(ageMs: number | undefined): string {
202
+ if (ageMs === undefined) return "alive · quiet unknown";
203
+ const secs = Math.floor(ageMs / 1000);
204
+ if (ageMs >= LONG_QUIET_MS) return `alive · quiet ${secs}s (living)`;
205
+ return `alive · quiet ${secs}s`;
206
+ }
207
+
208
+ /** @deprecated Prefer {@link formatAliveQuiet}; kept for older call sites. */
193
209
  export function formatLastEventAge(ageMs: number): string {
194
210
  return `last event ${Math.floor(ageMs / 1000)}s ago`;
195
211
  }
196
212
 
213
+ /**
214
+ * Collect-deadline / transient-connection / "still alive" reasons are progress
215
+ * signals, not verdicts. Showing them next to quiet age steals the eye.
216
+ */
217
+ export function isAliveProgressSignalReason(reason: string | undefined): boolean {
218
+ if (!reason) return false;
219
+ return /still alive|rpc child alive|transient connection/i.test(reason);
220
+ }
221
+
197
222
  function persistedSnapshotIsStale(input: LiveChildStatusInput): boolean {
198
223
  if (!input.alive) return false;
199
224
  if (input.state === "retired" || input.state === "exited") return true;
@@ -202,12 +227,27 @@ function persistedSnapshotIsStale(input: LiveChildStatusInput): boolean {
202
227
  return isTransientConnectionFailure(execution);
203
228
  }
204
229
 
230
+ function formatExecutionStatusLine(
231
+ alive: boolean,
232
+ execution: { status: string; reason?: string },
233
+ ): string {
234
+ const label = alive ? "execution snapshot" : "execution";
235
+ // Alive + progress-signal reason: status only — quiet age owns the liveness story.
236
+ if (alive && isAliveProgressSignalReason(execution.reason)) {
237
+ return `${label}: ${execution.status}`;
238
+ }
239
+ return `${label}: ${execution.status}${execution.reason ? ` (${execution.reason})` : ""}`;
240
+ }
241
+
205
242
  /**
206
- * Status text for one child.
243
+ * Status text for one child (`/legion status <name>` and `subagent status`).
207
244
  *
208
- * A living child always reports how long the session JSONL has been quiet.
209
- * A retired/failed record left behind by a connection blip is not the current
210
- * state while that process is still alive.
245
+ * Living children lead with `alive · quiet Ns` so a long silent reasoning
246
+ * stretch is obvious before any execution/acceptance snapshot. Progress-signal
247
+ * reasons (collect deadline, transient connection) are omitted from the
248
+ * snapshot line while alive so they do not steal attention from quiet age.
249
+ * A retired or failed record left by a connection blip is not treated as
250
+ * current state while the RPC process is still alive.
211
251
  */
212
252
  export function renderLiveChildStatus(input: LiveChildStatusInput): string {
213
253
  const stale = persistedSnapshotIsStale(input);
@@ -217,13 +257,18 @@ export function renderLiveChildStatus(input: LiveChildStatusInput): string {
217
257
  : input.execution;
218
258
  const lines = [
219
259
  `${input.name} — state=${state} agent=${input.agent ?? "?"} kind=${input.kind ?? "?"}`,
260
+ ];
261
+ if (input.alive) {
262
+ const age = lastJsonlEventAgeMs(input.sessionFile, input.now ?? Date.now());
263
+ lines.push(formatAliveQuiet(age));
264
+ }
265
+ // Quiet age first; path metadata after snapshots so old store lines stay below.
266
+ const meta = [
220
267
  "supervisor=rpc",
221
268
  `session=${input.sessionFile}`,
222
269
  ];
223
270
  if (execution) {
224
- lines.push(
225
- `execution: ${execution.status}${execution.reason ? ` (${execution.reason})` : ""}`,
226
- );
271
+ lines.push(formatExecutionStatusLine(input.alive, execution));
227
272
  if (execution.usage) {
228
273
  lines.push(
229
274
  `usage: in=${execution.usage.input} out=${execution.usage.output} cost=${execution.usage.cost.toFixed(4)}`,
@@ -231,14 +276,12 @@ export function renderLiveChildStatus(input: LiveChildStatusInput): string {
231
276
  }
232
277
  }
233
278
  if (!stale && input.acceptance) {
279
+ const acceptanceLabel = input.alive ? "acceptance snapshot" : "acceptance";
234
280
  lines.push(
235
- `acceptance: ${input.acceptance.status}${input.acceptance.level ? ` (${input.acceptance.level})` : ""}`,
281
+ `${acceptanceLabel}: ${input.acceptance.status}${input.acceptance.level ? ` (${input.acceptance.level})` : ""}`,
236
282
  );
237
283
  }
238
- if (input.alive) {
239
- const age = lastJsonlEventAgeMs(input.sessionFile, input.now ?? Date.now());
240
- if (age !== undefined) lines.push(formatLastEventAge(age));
241
- }
284
+ lines.push(...meta);
242
285
  if (stale) {
243
286
  const persistedExecution = input.execution
244
287
  ? `execution=${input.execution.status}${input.execution.reason ? ` (${input.execution.reason})` : ""}`
@@ -310,6 +353,11 @@ export interface SessionRuntime {
310
353
  names: string[],
311
354
  opts?: { timeoutMs?: number; signal?: AbortSignal },
312
355
  ): Promise<WaitResult[]>;
356
+ /**
357
+ * Non-interactive shutdown: let in-flight collects finish before the
358
+ * caller retires whatever is still alive. Watches do not re-arm.
359
+ */
360
+ drainBeforeShutdown(): Promise<void>;
313
361
  dispose(): void;
314
362
  }
315
363
 
@@ -329,6 +377,8 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
329
377
  let dbEventSource: DbEventSource | undefined;
330
378
  let timer: ReturnType<typeof setInterval> | undefined;
331
379
  let disposed = false;
380
+ let draining = false;
381
+ let drainPromise: Promise<void> | undefined;
332
382
  let busyRaised = false;
333
383
 
334
384
  // Smart join: batch completion notices per runId. The deliver sink merges
@@ -800,6 +850,15 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
800
850
  try {
801
851
  snapshot = await ensureCollect(job);
802
852
  if (disposed || job.generation !== gen) return;
853
+ // Print/json shutdown is waiting on this collect. A timeout or
854
+ // a blocked child must stay tracked so the caller can retire
855
+ // it; re-arming would start another turn after the process
856
+ // has already decided to exit.
857
+ if (draining && !shouldRecycleAfterCollect(snapshot.execution.status)) {
858
+ hold = true;
859
+ job.watching = false;
860
+ return;
861
+ }
803
862
  if (snapshot.blocked) {
804
863
  job.state = "blocked";
805
864
  refreshUi();
@@ -1087,6 +1146,26 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
1087
1146
  );
1088
1147
  },
1089
1148
 
1149
+ drainBeforeShutdown() {
1150
+ if (disposed) return Promise.resolve();
1151
+ if (drainPromise) return drainPromise;
1152
+ draining = true;
1153
+ const pending = [...jobs.values()];
1154
+ drainPromise = (async () => {
1155
+ await Promise.all(pending.map(async (job) => {
1156
+ try {
1157
+ await ensureCollect(job);
1158
+ } catch {
1159
+ // The watch records the failure; shutdown still retires the child.
1160
+ }
1161
+ }));
1162
+ // The watch continuation shares this collect and must observe
1163
+ // `draining` before the caller snapshots the jobs it will retire.
1164
+ await new Promise<void>((resolve) => setImmediate(resolve));
1165
+ })();
1166
+ return drainPromise;
1167
+ },
1168
+
1090
1169
  dispose() {
1091
1170
  if (disposed) return;
1092
1171
  disposed = true;