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.
- package/CHANGELOG.md +136 -0
- package/CONTRIBUTING.md +51 -18
- package/README.md +101 -18
- package/agents/planner.md +1 -1
- package/agents/reviewer.md +2 -3
- package/agents/worker.md +2 -3
- package/docs/contracts/esc-abort-v1.md +2 -0
- package/docs/contracts/esc-cascade-v2.md +5 -1
- package/docs/contracts/esc-no-retire-v3.md +57 -0
- package/docs/design.md +37 -18
- package/docs/vexillatio-contract.md +1 -1
- package/index.ts +145 -134
- package/package.json +8 -4
- package/skills/pi-legion/SKILL.md +22 -8
- package/src/agents/agents.ts +60 -5
- package/src/api.ts +2 -0
- package/src/extension/legion-commands.ts +64 -10
- package/src/extension/legion-doctor.ts +128 -41
- package/src/extension/legion-status.ts +172 -0
- package/src/extension/legion-stop.ts +5 -37
- package/src/extension/playbook.ts +4 -4
- package/src/extension/runtime.ts +97 -22
- package/src/extension/scout-guard.ts +12 -3
- package/src/runs/args.ts +69 -13
- package/src/runs/orchestrator.ts +28 -3
- package/src/runs/provider-pin.ts +59 -0
- package/src/runs/provision.ts +7 -4
- package/src/runs/role-tool-gates.ts +103 -0
- package/src/runs/worktree.ts +32 -0
- package/src/shared/session.ts +37 -2
- package/src/shared/types.ts +8 -0
- package/src/tui/status.ts +12 -14
- package/src/tui/viz/canvas.ts +5 -0
- package/src/tui/viz/panels.ts +16 -8
- package/src/tui/viz/railway.ts +34 -11
- package/src/tui/viz/rate.ts +41 -9
- package/src/tui/viz/view.ts +18 -11
|
@@ -32,14 +32,20 @@ directly.
|
|
|
32
32
|
|
|
33
33
|
## Legion visualization
|
|
34
34
|
|
|
35
|
-
In an interactive Pi session, `/legion` opens the
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
tree
|
|
40
|
-
short names are ambiguous, use the full node
|
|
41
|
-
|
|
42
|
-
|
|
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`.
|
package/src/agents/agents.ts
CHANGED
|
@@ -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 = "
|
|
71
|
+
export const EXTRA_AGENT_DIRS_ENV = "PI_LEGION_EXTRA_AGENT_DIRS";
|
|
68
72
|
|
|
69
|
-
/**
|
|
70
|
-
|
|
71
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
6
|
-
*
|
|
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,
|
|
388
|
+
function extensionsForRole(agent: AgentConfig, env: NodeJS.ProcessEnv, _extras: string[]): string[] {
|
|
356
389
|
if (childInheritsExtensions(agent, env)) return ["(all host-discovered extensions)"];
|
|
357
|
-
|
|
358
|
-
|
|
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
|
|
529
|
-
const exts = extensionsForRole(agent, env, extras);
|
|
530
|
-
const
|
|
531
|
-
|
|
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:
|
|
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(`-
|
|
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("-
|
|
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
|
-
|
|
734
|
-
|
|
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
|
-
|
|
741
|
-
|
|
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
|
-
|
|
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
|
}
|