pi-legion 2.0.0 → 2.0.2

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.
@@ -32,14 +32,20 @@ directly.
32
32
 
33
33
  ## Legion visualization
34
34
 
35
- In an interactive Pi session, `/legion` opens the paged Legion tree. Navigate
36
- with `j`/`k` or the arrow keys, use Enter/Space to expand or collapse, `/` to
37
- search, and PageUp/PageDown to scroll; `q` or Esc closes the tree. Press `o` on
38
- the selected node, or use a component-routed mouse click in the full-screen
39
- tree, to open its live overlay. `/legion <name>` opens a node directly. If
40
- short names are ambiguous, use the full node ID shown in command completion.
41
- Esc closes an overlay and unsubscribes its node's live feed. OSC 8 links are
42
- visual anchors only; they do not open overlays or route clicks.
35
+ In an interactive Pi session, bare `/legion` opens the **galaxy / tok-s / log**
36
+ full view (when a viz snapshot is available and you did not pass `--tree`).
37
+ Tab switches panes; arrows move or scroll; Enter selects an agent; `o` opens
38
+ its live overlay; `q` or Esc closes. `/legion --tree` opens the classic paged
39
+ tree (`j`/`k`, Enter/Space fold, `/` search, PageUp/PageDown). `/legion <name>`
40
+ opens a node overlay directly; if short names are ambiguous, use the full node
41
+ ID from completion. `/legion status` prints the whole tree in chat (same lines
42
+ as `legion_tree`); `/legion status <name>` prints one node like
43
+ `subagent({ action: "status", name })`. Esc closes an overlay and unsubscribes
44
+ its live feed. OSC 8 links are visual anchors only.
45
+
46
+ To stop live children yourself: `/legion stop` (or `/legion stop <name>`), idle
47
+ Esc with an empty editor (confirm "Stop N running subagents?"), or `x` then `y`
48
+ on the selected live agent in the `/legion` view or overlay.
43
49
 
44
50
  ## Tree tools and phases
45
51
 
@@ -92,7 +98,15 @@ subagent({ action: "resume", name: "worker-0" })
92
98
  subagent({ action: "retire", name: "worker-0" })
93
99
  ```
94
100
 
101
+ A person can read that same status without a tool call: `/legion status` for
102
+ the tree, `/legion status <name>` for one child.
103
+
95
104
  For a blocked extension UI request, the RPC supervisor bubbles the request up
96
105
  to the root Pi UI for human response by default. A child completion or a
97
106
  `legion_mail` message is not a substitute for checking its saved result and
98
107
  machine-recorded state.
108
+
109
+ `pi --print` and JSON mode wait for live async children during session
110
+ shutdown, up to each child's turn timeout. That keeps a just-spawned child
111
+ from being retired before its first assistant message. To put the child's
112
+ result in the same print turn, use `wait`, `collect`, or `async: false`.
@@ -63,12 +63,24 @@ export const BUILTIN_AGENTS_DIR = path.resolve(
63
63
  * or vendored roles without copying them into the writable user agent dir. These
64
64
  * load as `user`-level, at LOWER precedence than the real user directory, so a
65
65
  * user's own definition of the same name still wins.
66
+ *
67
+ * Canonical env: `PI_LEGION_EXTRA_AGENT_DIRS`. The Herdr-era name
68
+ * `PI_HERDR_SUBAGENTS_EXTRA_AGENT_DIRS` remains a compatibility alias for at
69
+ * least one minor version. When both are set, the new name wins (old ignored).
66
70
  */
67
- export const EXTRA_AGENT_DIRS_ENV = "PI_HERDR_SUBAGENTS_EXTRA_AGENT_DIRS";
71
+ export const EXTRA_AGENT_DIRS_ENV = "PI_LEGION_EXTRA_AGENT_DIRS";
68
72
 
69
- /** Split `PI_HERDR_SUBAGENTS_EXTRA_AGENT_DIRS` into absolute directory paths. */
70
- function extraAgentDirs(): string[] {
71
- const raw = process.env[EXTRA_AGENT_DIRS_ENV];
73
+ /** @deprecated Use {@link EXTRA_AGENT_DIRS_ENV} (`PI_LEGION_EXTRA_AGENT_DIRS`). */
74
+ export const LEGACY_EXTRA_AGENT_DIRS_ENV = "PI_HERDR_SUBAGENTS_EXTRA_AGENT_DIRS";
75
+
76
+ let legacyExtraAgentDirsWarned = false;
77
+
78
+ /** @internal Reset one-shot deprecation warn (unit tests). */
79
+ export function resetExtraAgentDirsDeprecationWarnForTests(): void {
80
+ legacyExtraAgentDirsWarned = false;
81
+ }
82
+
83
+ function splitExtraAgentDirEntries(raw: string | undefined): string[] {
72
84
  if (!raw) return [];
73
85
  return raw.split(path.delimiter).flatMap((entry) => {
74
86
  const trimmed = entry.trim();
@@ -76,6 +88,49 @@ function extraAgentDirs(): string[] {
76
88
  });
77
89
  }
78
90
 
91
+ function envHasContent(value: string | undefined): value is string {
92
+ return value !== undefined && value.trim() !== "";
93
+ }
94
+
95
+ /**
96
+ * Resolve PATH-style extra agent dirs from env.
97
+ *
98
+ * Priority: `PI_LEGION_EXTRA_AGENT_DIRS` overrides
99
+ * `PI_HERDR_SUBAGENTS_EXTRA_AGENT_DIRS` when both are set. Setting the legacy
100
+ * name alone still works and emits one stderr deprecation warning per process.
101
+ */
102
+ export function resolveExtraAgentDirs(
103
+ env: NodeJS.ProcessEnv = process.env,
104
+ warn: (message: string) => void = (message) => console.warn(message),
105
+ ): string[] {
106
+ const modern = env[EXTRA_AGENT_DIRS_ENV];
107
+ const legacy = env[LEGACY_EXTRA_AGENT_DIRS_ENV];
108
+ const hasModern = envHasContent(modern);
109
+ const hasLegacy = envHasContent(legacy);
110
+
111
+ if (hasLegacy && !legacyExtraAgentDirsWarned) {
112
+ legacyExtraAgentDirsWarned = true;
113
+ if (hasModern) {
114
+ warn(
115
+ `[pi-legion] Both ${EXTRA_AGENT_DIRS_ENV} and ${LEGACY_EXTRA_AGENT_DIRS_ENV} are set; using ${EXTRA_AGENT_DIRS_ENV} (new name wins). Remove the deprecated ${LEGACY_EXTRA_AGENT_DIRS_ENV}.`,
116
+ );
117
+ } else {
118
+ warn(
119
+ `[pi-legion] ${LEGACY_EXTRA_AGENT_DIRS_ENV} is deprecated; use ${EXTRA_AGENT_DIRS_ENV}. The old name remains as a compatibility alias for at least one minor version.`,
120
+ );
121
+ }
122
+ }
123
+
124
+ if (hasModern) return splitExtraAgentDirEntries(modern);
125
+ if (hasLegacy) return splitExtraAgentDirEntries(legacy);
126
+ return [];
127
+ }
128
+
129
+ /** Split env extra-agent-dir entries into absolute directory paths. */
130
+ function extraAgentDirs(): string[] {
131
+ return resolveExtraAgentDirs();
132
+ }
133
+
79
134
  const VALID_KINDS: ReadonlySet<string> = new Set(AGENT_KINDS);
80
135
 
81
136
  const VALID_ON_BLOCKED: ReadonlySet<string> = new Set([
@@ -600,7 +655,7 @@ export interface DiscoverOptions {
600
655
  *
601
656
  * Precedence, lowest to highest:
602
657
  * builtin (shipped with the package)
603
- * extra dirs (`PI_HERDR_SUBAGENTS_EXTRA_AGENT_DIRS`)
658
+ * extra dirs (`PI_LEGION_EXTRA_AGENT_DIRS`, legacy `PI_HERDR_SUBAGENTS_EXTRA_AGENT_DIRS`)
604
659
  * user (`~/.pi/agent/agents`)
605
660
  * project (`<nearest>/.pi/agents`)
606
661
  *
package/src/api.ts CHANGED
@@ -34,6 +34,7 @@ export {
34
34
  BUILTIN_AGENT_NAMES,
35
35
  BUILTIN_AGENTS_DIR,
36
36
  EXTRA_AGENT_DIRS_ENV,
37
+ LEGACY_EXTRA_AGENT_DIRS_ENV,
37
38
  discoverAgents,
38
39
  findAgent,
39
40
  findNearestProjectAgentsDir,
@@ -41,6 +42,7 @@ export {
41
42
  formatAgentRoster,
42
43
  loadAgentsFromDir,
43
44
  parseAgentDocument,
45
+ resolveExtraAgentDirs,
44
46
  } from "./agents/agents.ts";
45
47
  export type { DiscoverOptions } from "./agents/agents.ts";
46
48
  export {
@@ -4,11 +4,16 @@ import { matchesKey, type TuiMouseEvent } from "@earendil-works/pi-tui";
4
4
  import { resolveStopTarget, runLegionStop, stopCompletions, type StopDeps } from "./legion-stop.ts";
5
5
  import {
6
6
  DOCTOR_SECTIONS,
7
+ LEGION_SLASH_HELP_LINES,
7
8
  expectedModelsFromSettings,
8
9
  parseDoctorSections,
9
10
  runLegionDoctor,
10
11
  type DoctorRuntimeSnapshot,
11
12
  } from "./legion-doctor.ts";
13
+ import {
14
+ renderLegionStatus,
15
+ type LegionChildStatusFacts,
16
+ } from "./legion-status.ts";
12
17
  import { ALLOW_NESTED_ENV } from "./budget.ts";
13
18
  import {
14
19
  createAgentsCommand,
@@ -91,6 +96,11 @@ export interface LegionCommandDeps {
91
96
  supervisor: RpcEventSource;
92
97
  /** Graceful retire via the subagent `stop` action (see legion-stop.ts). */
93
98
  stop?: (params: { name?: string; all?: boolean }) => Promise<{ ok: boolean; text: string }>;
99
+ /**
100
+ * Persisted child snapshot used by `subagent({ action: "status" })`.
101
+ * Undefined when this process has no run-store record for `name`.
102
+ */
103
+ childStatus?: (name: string, cwd: string) => LegionChildStatusFacts | undefined;
94
104
  /** Existing user-facing subcommand handlers; absent only in isolated tests. */
95
105
  commands?: {
96
106
  team: TeamCommandDeps;
@@ -181,6 +191,32 @@ function nodeLabel(nodeId: string, name: string): string {
181
191
  return `${name} (${nodeId})`;
182
192
  }
183
193
 
194
+ /** Completions for `/legion status <prefix>`: tree nodes, then live jobs the tree does not already name. */
195
+ function statusArgumentCompletions(
196
+ prefix: string,
197
+ deps: LegionCommandDeps,
198
+ ): Array<{ value: string; label: string }> {
199
+ const nodes = deps.runtime.treeNodes();
200
+ const out: Array<{ value: string; label: string }> = [];
201
+ const seen = new Set<string>();
202
+ const add = (value: string, label: string): void => {
203
+ if (!value.startsWith(prefix) || seen.has(value)) return;
204
+ seen.add(value);
205
+ out.push({ value: `status ${value}`, label });
206
+ };
207
+ for (const node of nodes) {
208
+ const value = nodes.filter((candidate) => candidate.name === node.name).length > 1
209
+ ? node.id
210
+ : node.name;
211
+ add(value, nodeLabel(node.id, node.name));
212
+ }
213
+ for (const job of deps.runtime.activeJobs()) {
214
+ if (job.state === "retired") continue;
215
+ add(job.name, `${job.name} (live)`);
216
+ }
217
+ return out;
218
+ }
219
+
184
220
  function resolveNode(
185
221
  nodes: readonly TreeNodeSnapshot[],
186
222
  query: string,
@@ -483,7 +519,7 @@ function buildDoctorReport(
483
519
  : undefined,
484
520
  })),
485
521
  stopAvailable: Boolean(deps.stop),
486
- idleEscWired: true,
522
+ idleEscWired: false,
487
523
  legionTools: ["subagent", "vexillatio"],
488
524
  allowNestedEnv: process.env[ALLOW_NESTED_ENV] === "1",
489
525
  };
@@ -525,15 +561,7 @@ export function registerLegionCommand(
525
561
  };
526
562
  const helpText = [
527
563
  "Legion commands:",
528
- " /legion team [list|use <name> [--global]|create <name> a,b,c [--global]]",
529
- " /legion agents [user|project|both]",
530
- " /legion on | off — enable/disable subagents for this session (status is shown after each run)",
531
- " /scout-guard | /no-scout-guard — session override for the main-session scout guard",
532
- " /legion summary [--all] [<name>]",
533
- " /legion doctor [section]",
534
- " /legion stop [name]",
535
- " /legion --tree | /legion <node>",
536
- " /legion help",
564
+ ...LEGION_SLASH_HELP_LINES.map((line) => ` ${line}`),
537
565
  ].join("\n");
538
566
  pi.registerCommand(LEGION_COMMAND, {
539
567
  description: "Open the Legion view or manage teams, agents, subagent runs, and settings",
@@ -554,6 +582,9 @@ export function registerLegionCommand(
554
582
  return stopCompletions(rest, deps.runtime.activeJobs(), deps.runtime.treeNodes())
555
583
  .map((item) => ({ value: `stop ${item.value}`, label: item.label }));
556
584
  }
585
+ if (/^status\s/i.test(trimmed)) {
586
+ return statusArgumentCompletions(trimmed.replace(/^status\s+/i, ""), deps);
587
+ }
557
588
  if (/^doctor(\s|$)/i.test(prefix)) {
558
589
  const rest = prefix.replace(/^doctor\s*/i, "");
559
590
  return DOCTOR_SECTIONS
@@ -570,6 +601,12 @@ export function registerLegionCommand(
570
601
  }
571
602
  }
572
603
  }
604
+ if ("status".startsWith(prefix)) {
605
+ verbs.push({
606
+ value: "status",
607
+ label: "status [name] — tree summary, or one node (same text as subagent status)",
608
+ });
609
+ }
573
610
  if ("doctor".startsWith(prefix)) {
574
611
  verbs.push({ value: "doctor", label: "doctor [section] — full self-check (extensions/models/spawn/memory/tools/storage/runtime)" });
575
612
  }
@@ -601,6 +638,23 @@ export function registerLegionCommand(
601
638
  sendSlashText(pi, helpText);
602
639
  return;
603
640
  }
641
+ if (normalizedKeyword === "status") {
642
+ const report = renderLegionStatus({
643
+ query: rest.join(" "),
644
+ nodes: deps.runtime.treeNodes(),
645
+ jobs: deps.runtime.activeJobs(),
646
+ lookupChild: deps.childStatus
647
+ ? (name) => deps.childStatus!(name, ctx.cwd)
648
+ : undefined,
649
+ now: Date.now(),
650
+ });
651
+ if (!report.ok) {
652
+ notify(ctx, report.text, "error");
653
+ return;
654
+ }
655
+ sendSlashText(pi, report.text);
656
+ return;
657
+ }
604
658
  if (/^doctor(?:\s|$)/i.test(query)) {
605
659
  const sectionArg = query.replace(/^doctor\s*/i, "").trim();
606
660
  const report = buildDoctorReport(deps, ctx.cwd, sectionArg || undefined);
@@ -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
- push(`- idle strip hints: "${IDLE_STOP_HINT}" / short "${IDLE_STOP_HINT_SHORT}"`);
812
+ push(`- stop 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
  }