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.
- package/CHANGELOG.md +117 -0
- package/CONTRIBUTING.md +51 -18
- package/README.md +93 -7
- package/agents/planner.md +1 -1
- package/agents/reviewer.md +2 -3
- package/agents/worker.md +2 -3
- package/docs/contracts/esc-cascade-v2.md +2 -1
- package/docs/design.md +33 -16
- package/docs/vexillatio-contract.md +1 -1
- package/index.ts +125 -21
- 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 +63 -9
- package/src/extension/legion-doctor.ts +127 -40
- package/src/extension/legion-status.ts +172 -0
- package/src/extension/playbook.ts +3 -3
- package/src/extension/runtime.ts +93 -14
- package/src/extension/scout-guard.ts +12 -3
- package/src/runs/args.ts +69 -13
- package/src/runs/orchestrator.ts +22 -2
- package/src/runs/provider-pin.ts +59 -0
- package/src/runs/role-tool-gates.ts +103 -0
- package/src/shared/session.ts +37 -2
- package/src/shared/types.ts +8 -0
- package/src/tui/viz/panels.ts +3 -1
|
@@ -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
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("-
|
|
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
|
}
|
|
@@ -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
|
|
package/src/extension/runtime.ts
CHANGED
|
@@ -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
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
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
|
-
|
|
281
|
+
`${acceptanceLabel}: ${input.acceptance.status}${input.acceptance.level ? ` (${input.acceptance.level})` : ""}`,
|
|
236
282
|
);
|
|
237
283
|
}
|
|
238
|
-
|
|
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;
|