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
package/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
type ActiveTeam,
|
|
36
36
|
} from "./src/agents/teams.ts";
|
|
37
37
|
import { resolveModel } from "./src/agents/model-resolution.ts";
|
|
38
|
+
import { providerForModel, type ProviderLookup } from "./src/runs/provider-pin.ts";
|
|
38
39
|
import { resolveStepModel } from "./src/agents/step-model.ts";
|
|
39
40
|
import { checkModelScope } from "./src/agents/model-scope.ts";
|
|
40
41
|
import {
|
|
@@ -52,7 +53,7 @@ import { attachUsageTotals, upsertUsage } from "./src/legion/usage.ts";
|
|
|
52
53
|
import { getChildByName, getChildren, getNode, getSubtree, insertNode, type LegionNode, updateNode } from "./src/legion/nodes.ts";
|
|
53
54
|
import { DEFAULT_LEGION_SETTINGS, type LegionSettings } from "./src/shared/types.ts";
|
|
54
55
|
import { parseNestedPathEnv } from "./src/shared/nested-path.ts";
|
|
55
|
-
import { allocateSiblingNames, makeName } from "./src/shared/name.ts";
|
|
56
|
+
import { allocateSiblingNames, isValidAgentName, makeName } from "./src/shared/name.ts";
|
|
56
57
|
import { deriveOutcome, isLastTurnComplete, parseSessionFile } from "./src/shared/session.ts";
|
|
57
58
|
import { restoreHostNodeOptions } from "./src/supervisor/child-memory.ts";
|
|
58
59
|
import { setVerifyQueueListener } from "./src/runs/verify-queue.ts";
|
|
@@ -81,7 +82,7 @@ import {
|
|
|
81
82
|
import { renderSubagentNotice } from "./src/extension/notice-renderer.ts";
|
|
82
83
|
import { PARENT_PLAYBOOK, TOOL_DESCRIPTION } from "./src/extension/playbook.ts";
|
|
83
84
|
import { getAgentDir } from "./src/agents/paths.ts";
|
|
84
|
-
import { expectedModelsFromSettings, runLegionDoctor } from "./src/extension/legion-doctor.ts";
|
|
85
|
+
import { expectedModelsFromSettings, parseDoctorSections, runLegionDoctor } from "./src/extension/legion-doctor.ts";
|
|
85
86
|
import { readFileSync } from "node:fs";
|
|
86
87
|
import {
|
|
87
88
|
effectiveMaxDepth,
|
|
@@ -97,6 +98,7 @@ import {
|
|
|
97
98
|
DEFAULT_ON_BLOCKED,
|
|
98
99
|
ErrorCodes,
|
|
99
100
|
type ModelOrigin,
|
|
101
|
+
type RetireOptions,
|
|
100
102
|
SubagentError,
|
|
101
103
|
} from "./src/shared/types.ts";
|
|
102
104
|
|
|
@@ -146,7 +148,7 @@ const SubagentParams = Type.Object({
|
|
|
146
148
|
Type.Literal("stop"),
|
|
147
149
|
Type.Literal("diagnose"),
|
|
148
150
|
],
|
|
149
|
-
{ description: "Defaults to launch. diagnose = Legion doctor
|
|
151
|
+
{ description: "Defaults to launch. diagnose = Legion doctor self-check (extensions/models/spawn/memory/tools/storage/runtime); optional section filters like /legion doctor." },
|
|
150
152
|
),
|
|
151
153
|
),
|
|
152
154
|
agent: Type.Optional(
|
|
@@ -189,7 +191,10 @@ const SubagentParams = Type.Object({
|
|
|
189
191
|
),
|
|
190
192
|
),
|
|
191
193
|
name: Type.Optional(
|
|
192
|
-
Type.String({
|
|
194
|
+
Type.String({
|
|
195
|
+
description:
|
|
196
|
+
"Child handle name. On launch (single agent+task): registers this exact name (lowercase [a-z][a-z0-9_-]{0,31}) instead of auto scout-N; collisions refuse. On control actions (status/wait/stop/…): addresses that child.",
|
|
197
|
+
}),
|
|
193
198
|
),
|
|
194
199
|
all: Type.Optional(
|
|
195
200
|
Type.Boolean({
|
|
@@ -207,6 +212,12 @@ const SubagentParams = Type.Object({
|
|
|
207
212
|
team: Type.Optional(
|
|
208
213
|
Type.String({ description: "Team to use for the launched child and its nested roster" }),
|
|
209
214
|
),
|
|
215
|
+
section: Type.Optional(
|
|
216
|
+
Type.String({
|
|
217
|
+
description:
|
|
218
|
+
'diagnose only: optional section filter (summary|extensions|models|spawn|memory|tools|storage|runtime|next), same as `/legion doctor [section]`. Space/comma-separated for multiple.',
|
|
219
|
+
}),
|
|
220
|
+
),
|
|
210
221
|
});
|
|
211
222
|
|
|
212
223
|
/** The validated tool parameters, as the model supplies them. */
|
|
@@ -469,7 +480,7 @@ async function resumeLegionChild(input: { pi: ExtensionAPI; supervisor: LegionSu
|
|
|
469
480
|
await store.updateChild(parentRun.runId, saved.name, (child) => Object.assign(child, handle.child));
|
|
470
481
|
}
|
|
471
482
|
|
|
472
|
-
export default function
|
|
483
|
+
export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps = {}) {
|
|
473
484
|
cascadePromise = undefined;
|
|
474
485
|
const isChild = process.env.PI_SUBAGENT_CHILD === "1";
|
|
475
486
|
const allowNested = process.env[ALLOW_NESTED_ENV] === "1";
|
|
@@ -562,14 +573,22 @@ export default function herdrSubagents(pi: ExtensionAPI, deps: ExtensionSupervis
|
|
|
562
573
|
timer.unref?.();
|
|
563
574
|
}
|
|
564
575
|
});
|
|
565
|
-
const pendingUtilityRetires: Array<() => Promise<void>> = [];
|
|
576
|
+
const pendingUtilityRetires: Array<(opts?: RetireOptions) => Promise<void>> = [];
|
|
566
577
|
let sessionRuntime: SessionRuntime | undefined;
|
|
567
|
-
const shutdownRetireAll = async (): Promise<void> => {
|
|
578
|
+
const shutdownRetireAll = async (_event?: unknown, ctx?: { mode?: string }): Promise<void> => {
|
|
579
|
+
// pi --print / JSON exits when the parent turn ends. Async children
|
|
580
|
+
// have only just been spawned; retiring them here aborts the run
|
|
581
|
+
// before the first model call. Interactive quit still retires immediately.
|
|
582
|
+
const mode = ctx?.mode;
|
|
583
|
+
if (mode === "print" || mode === "json") {
|
|
584
|
+
await sessionRuntime?.drainBeforeShutdown();
|
|
585
|
+
}
|
|
568
586
|
const children = sessionRuntime?.activeJobs() ?? [];
|
|
569
587
|
sessionRuntime?.dispose();
|
|
588
|
+
const shutdownRetire: RetireOptions = { cause: "session-shutdown" };
|
|
570
589
|
await Promise.all([
|
|
571
|
-
...children.map((job) => job.retire?.().catch(() => {})),
|
|
572
|
-
...pendingUtilityRetires.map((retire) => retire().catch(() => {})),
|
|
590
|
+
...children.map((job) => job.retire?.(shutdownRetire).catch(() => {})),
|
|
591
|
+
...pendingUtilityRetires.map((retire) => retire(shutdownRetire).catch(() => {})),
|
|
573
592
|
]);
|
|
574
593
|
stopLegionRuntime();
|
|
575
594
|
};
|
|
@@ -786,6 +805,27 @@ export default function herdrSubagents(pi: ExtensionAPI, deps: ExtensionSupervis
|
|
|
786
805
|
runtime,
|
|
787
806
|
supervisor: rpcEventSource,
|
|
788
807
|
stop: stopChildren,
|
|
808
|
+
childStatus: (name, cwd) => {
|
|
809
|
+
try {
|
|
810
|
+
const found = findChild(runStoreFor(cwd).listRuns(), name);
|
|
811
|
+
if (!found) return undefined;
|
|
812
|
+
const child = found.child;
|
|
813
|
+
return {
|
|
814
|
+
name: child.name,
|
|
815
|
+
state: child.state,
|
|
816
|
+
agent: child.agent,
|
|
817
|
+
kind: child.kind,
|
|
818
|
+
sessionFile: child.sessionFile,
|
|
819
|
+
...(child.execution ? { execution: child.execution } : {}),
|
|
820
|
+
...(child.acceptance
|
|
821
|
+
? { acceptance: { status: child.acceptance.status, level: child.acceptance.level } }
|
|
822
|
+
: {}),
|
|
823
|
+
alive: supervisor.isAlive(child.name),
|
|
824
|
+
};
|
|
825
|
+
} catch {
|
|
826
|
+
return undefined;
|
|
827
|
+
}
|
|
828
|
+
},
|
|
789
829
|
commands: {
|
|
790
830
|
team: { loadCatalog: commandCatalogLoader },
|
|
791
831
|
agents: { loadCatalog: commandCatalogLoader },
|
|
@@ -868,6 +908,17 @@ export default function herdrSubagents(pi: ExtensionAPI, deps: ExtensionSupervis
|
|
|
868
908
|
|
|
869
909
|
if (action === "list") return ok(renderAgentList(agents, team, teamWarnings));
|
|
870
910
|
if (action === "diagnose") {
|
|
911
|
+
let sections;
|
|
912
|
+
try {
|
|
913
|
+
sections = parseDoctorSections(
|
|
914
|
+
typeof params.section === "string" ? params.section : undefined,
|
|
915
|
+
);
|
|
916
|
+
} catch (error) {
|
|
917
|
+
return fail(
|
|
918
|
+
error instanceof Error ? error.message : String(error),
|
|
919
|
+
ErrorCodes.INVALID_PARAMS,
|
|
920
|
+
);
|
|
921
|
+
}
|
|
871
922
|
let settings: unknown;
|
|
872
923
|
try {
|
|
873
924
|
settings = JSON.parse(readFileSync(path.join(getAgentDir(), "settings.json"), "utf8"));
|
|
@@ -881,6 +932,7 @@ export default function herdrSubagents(pi: ExtensionAPI, deps: ExtensionSupervis
|
|
|
881
932
|
expectedModels: expectedModelsFromSettings(settings),
|
|
882
933
|
sampleAgent: agents[0],
|
|
883
934
|
agents,
|
|
935
|
+
sections,
|
|
884
936
|
runtime: {
|
|
885
937
|
liveJobs: runtime.activeJobs().map((job) => ({
|
|
886
938
|
name: job.name,
|
|
@@ -1460,6 +1512,8 @@ async function launchFamily(input: {
|
|
|
1460
1512
|
|
|
1461
1513
|
const plan = buildPlan(params);
|
|
1462
1514
|
if (!plan.ok) return fail(plan.message, ErrorCodes.INVALID_PARAMS);
|
|
1515
|
+
const nameGate = resolveExplicitLaunchName(params.name, plan.steps.length);
|
|
1516
|
+
if (!nameGate.ok) return fail(nameGate.message, ErrorCodes.INVALID_PARAMS);
|
|
1463
1517
|
|
|
1464
1518
|
onUpdate?.({
|
|
1465
1519
|
content: [
|
|
@@ -1536,9 +1590,11 @@ async function launchFamily(input: {
|
|
|
1536
1590
|
dispatchModel: ctx.model
|
|
1537
1591
|
? `${ctx.model.provider}/${ctx.model.id}`
|
|
1538
1592
|
: undefined,
|
|
1593
|
+
modelRegistry: ctx.modelRegistry,
|
|
1539
1594
|
results: [],
|
|
1540
1595
|
handles: [],
|
|
1541
1596
|
timeoutByName: new Map(),
|
|
1597
|
+
...(nameGate.name ? { launchName: nameGate.name } : {}),
|
|
1542
1598
|
};
|
|
1543
1599
|
|
|
1544
1600
|
const interruptedResult = async (): Promise<AgentToolResult<unknown> | undefined> => {
|
|
@@ -1619,9 +1675,13 @@ interface LaunchSession {
|
|
|
1619
1675
|
runtime: SessionRuntime;
|
|
1620
1676
|
onUpdate?: AgentToolUpdateCallback;
|
|
1621
1677
|
dispatchModel: string | undefined;
|
|
1678
|
+
/** Parent registry, used to pin `--provider` for ambiguous model ids (#123). */
|
|
1679
|
+
modelRegistry: ProviderLookup | undefined;
|
|
1622
1680
|
results: string[];
|
|
1623
1681
|
handles: string[];
|
|
1624
1682
|
timeoutByName: Map<string, number>;
|
|
1683
|
+
/** Explicit child name for this single-step launch; omit to auto-allocate. */
|
|
1684
|
+
launchName?: string;
|
|
1625
1685
|
}
|
|
1626
1686
|
|
|
1627
1687
|
/**
|
|
@@ -1700,13 +1760,20 @@ async function launchStep(
|
|
|
1700
1760
|
return;
|
|
1701
1761
|
}
|
|
1702
1762
|
|
|
1703
|
-
//
|
|
1704
|
-
//
|
|
1763
|
+
// Honor `name=` when the caller asked for one; otherwise let `launch()`
|
|
1764
|
+
// allocate from the supervisor-local registry (scout-N). The index must
|
|
1765
|
+
// not race separate subagent tool calls for auto names.
|
|
1705
1766
|
const handle = await session.orchestrator.launch({
|
|
1706
1767
|
agent: launchedAgent,
|
|
1707
1768
|
task: step.task,
|
|
1769
|
+
...(session.launchName ? { name: session.launchName } : {}),
|
|
1708
1770
|
...(session.teamOverride ? { team: session.teamOverride } : {}),
|
|
1709
1771
|
...(resolved.model ? { model: resolved.model } : {}),
|
|
1772
|
+
// Pin the provider (#123): a bare id like `workbuddy/...` may also
|
|
1773
|
+
// exist under a static auth-less provider in models.json; under
|
|
1774
|
+
// `--no-extensions` the child would resolve it there and die with
|
|
1775
|
+
// "No API key found". The parent registry knows the live provider.
|
|
1776
|
+
...(resolved.model ? { modelProvider: providerForModel(session.modelRegistry, resolved.model) } : {}),
|
|
1710
1777
|
// The origin decides whether an unacceptable model is refused or
|
|
1711
1778
|
// dropped, and only this layer can tell them apart (a parent model
|
|
1712
1779
|
// arrives as the same `dispatch` source as a per-run override).
|
|
@@ -1924,7 +1991,7 @@ async function launchUtilityFamily(input: {
|
|
|
1924
1991
|
legion?: LegionRuntimeIdentity;
|
|
1925
1992
|
eventBus: LegionEventBus;
|
|
1926
1993
|
onLegion: (identity: LegionRuntimeIdentity) => void;
|
|
1927
|
-
onUtilityChild: (retire: () => Promise<void>) => void;
|
|
1994
|
+
onUtilityChild: (retire: (opts?: RetireOptions) => Promise<void>) => void;
|
|
1928
1995
|
}): Promise<AgentToolResult<unknown>> {
|
|
1929
1996
|
const plan = buildUtilityPlan(input.params);
|
|
1930
1997
|
if (!plan.ok) return fail(plan.message, ErrorCodes.INVALID_PARAMS);
|
|
@@ -2092,6 +2159,8 @@ async function launchUtilityFamily(input: {
|
|
|
2092
2159
|
task: item.step.task,
|
|
2093
2160
|
name: names.get(item.step),
|
|
2094
2161
|
...(item.resolved.model ? { model: item.resolved.model } : {}),
|
|
2162
|
+
// Provider pin (#123) — see launchStep for the rationale.
|
|
2163
|
+
...(item.resolved.model ? { modelProvider: providerForModel(input.ctx.modelRegistry, item.resolved.model) } : {}),
|
|
2095
2164
|
modelOrigin: item.modelOrigin,
|
|
2096
2165
|
...(item.step.worktree !== undefined ? { worktree: item.step.worktree } : {}),
|
|
2097
2166
|
utilityDispatch: true,
|
|
@@ -2099,10 +2168,10 @@ async function launchUtilityFamily(input: {
|
|
|
2099
2168
|
});
|
|
2100
2169
|
await persistChild(store, runId, orchestrator, handle.name);
|
|
2101
2170
|
let retirement: Promise<void> | undefined;
|
|
2102
|
-
const retire = (): Promise<void> => {
|
|
2171
|
+
const retire = (opts?: RetireOptions): Promise<void> => {
|
|
2103
2172
|
if (!retirement) {
|
|
2104
2173
|
const current = (async () => {
|
|
2105
|
-
await orchestrator.retire(handle.name);
|
|
2174
|
+
await orchestrator.retire(handle.name, opts);
|
|
2106
2175
|
await persistChild(store, runId, orchestrator, handle.name);
|
|
2107
2176
|
})();
|
|
2108
2177
|
retirement = current;
|
|
@@ -2112,9 +2181,9 @@ async function launchUtilityFamily(input: {
|
|
|
2112
2181
|
}
|
|
2113
2182
|
return retirement;
|
|
2114
2183
|
};
|
|
2115
|
-
input.onUtilityChild(async () => {
|
|
2184
|
+
input.onUtilityChild(async (opts) => {
|
|
2116
2185
|
try {
|
|
2117
|
-
await retire();
|
|
2186
|
+
await retire(opts);
|
|
2118
2187
|
} catch {
|
|
2119
2188
|
// Shutdown cleanup is best-effort; the supervisor owns final process state.
|
|
2120
2189
|
}
|
|
@@ -2141,7 +2210,7 @@ async function launchUtilityFamily(input: {
|
|
|
2141
2210
|
agent: string;
|
|
2142
2211
|
sessionFile: string;
|
|
2143
2212
|
orchestrator: Orchestrator;
|
|
2144
|
-
retire: () => Promise<void>;
|
|
2213
|
+
retire: (opts?: RetireOptions) => Promise<void>;
|
|
2145
2214
|
}> = [];
|
|
2146
2215
|
for (const launch of launches) {
|
|
2147
2216
|
if ("error" in launch) results.push(`✗ ${launch.item.agent.name}: ${launch.error}`);
|
|
@@ -2211,6 +2280,41 @@ async function launchUtilityFamily(input: {
|
|
|
2211
2280
|
};
|
|
2212
2281
|
}
|
|
2213
2282
|
|
|
2283
|
+
/**
|
|
2284
|
+
* Resolve an explicit `name=` on launch.
|
|
2285
|
+
*
|
|
2286
|
+
* Prefer honoring the caller's handle over auto `role-N`, so `/legion status <name>`
|
|
2287
|
+
* and control actions match the tool parameter. Multi-step `tasks[]` cannot share
|
|
2288
|
+
* one name; invalid grammar is refused rather than silently sanitized into a
|
|
2289
|
+
* different handle the caller would not find.
|
|
2290
|
+
*/
|
|
2291
|
+
export function resolveExplicitLaunchName(
|
|
2292
|
+
requested: string | undefined,
|
|
2293
|
+
stepCount: number,
|
|
2294
|
+
): { ok: true; name?: string } | { ok: false; message: string } {
|
|
2295
|
+
if (requested === undefined) return { ok: true };
|
|
2296
|
+
const trimmed = requested.trim();
|
|
2297
|
+
if (!trimmed) {
|
|
2298
|
+
return { ok: false, message: "`name` must not be blank when provided." };
|
|
2299
|
+
}
|
|
2300
|
+
if (stepCount > 1) {
|
|
2301
|
+
return {
|
|
2302
|
+
ok: false,
|
|
2303
|
+
message:
|
|
2304
|
+
"`name` applies only to a single (agent+task) launch, not tasks[] with multiple steps.",
|
|
2305
|
+
};
|
|
2306
|
+
}
|
|
2307
|
+
const normalized = trimmed.toLowerCase();
|
|
2308
|
+
if (!isValidAgentName(normalized)) {
|
|
2309
|
+
return {
|
|
2310
|
+
ok: false,
|
|
2311
|
+
message:
|
|
2312
|
+
`invalid child name "${trimmed}": must match [a-z][a-z0-9_-]{0,31} (lowercase letter first, max 32 chars).`,
|
|
2313
|
+
};
|
|
2314
|
+
}
|
|
2315
|
+
return { ok: true, name: normalized };
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2214
2318
|
export function buildPlan(params: {
|
|
2215
2319
|
agent?: string;
|
|
2216
2320
|
task?: string;
|
|
@@ -2319,7 +2423,7 @@ function followJob(
|
|
|
2319
2423
|
orchestrator: Orchestrator;
|
|
2320
2424
|
store: RunStore;
|
|
2321
2425
|
watch: boolean;
|
|
2322
|
-
retire?: () => Promise<void>;
|
|
2426
|
+
retire?: (opts?: RetireOptions) => Promise<void>;
|
|
2323
2427
|
treeIdentity?: { nodeId: string; parentId: string | null; depth: number };
|
|
2324
2428
|
},
|
|
2325
2429
|
): void {
|
|
@@ -2350,12 +2454,12 @@ function followJob(
|
|
|
2350
2454
|
isAlive: () => input.orchestrator.isChildAlive(input.name),
|
|
2351
2455
|
persist: async () =>
|
|
2352
2456
|
persistChild(input.store, input.runId, input.orchestrator, input.name),
|
|
2353
|
-
retire: async () => {
|
|
2457
|
+
retire: async (opts) => {
|
|
2354
2458
|
if (input.retire) {
|
|
2355
|
-
await input.retire();
|
|
2459
|
+
await input.retire(opts);
|
|
2356
2460
|
return;
|
|
2357
2461
|
}
|
|
2358
|
-
await input.orchestrator.retire(input.name);
|
|
2462
|
+
await input.orchestrator.retire(input.name, opts);
|
|
2359
2463
|
await persistChild(
|
|
2360
2464
|
input.store,
|
|
2361
2465
|
input.runId,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-legion",
|
|
3
|
-
"version": "2.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
|
+
"description": "Pi Legion v2: recursive centurion trees on headless Pi RPC; shared SQLite WAL legion.db. Four-stage planning/implementing/reviewing/verifying state machine, parent/child/sibling legion_mail, three-layer /legion UI, upward MR trees, squad resume.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -15,9 +15,12 @@
|
|
|
15
15
|
"keywords": [
|
|
16
16
|
"pi-package",
|
|
17
17
|
"pi",
|
|
18
|
-
"
|
|
18
|
+
"pi-extension",
|
|
19
|
+
"legion",
|
|
20
|
+
"rpc",
|
|
19
21
|
"subagents",
|
|
20
|
-
"agents"
|
|
22
|
+
"agents",
|
|
23
|
+
"centurion"
|
|
21
24
|
],
|
|
22
25
|
"publishConfig": {
|
|
23
26
|
"access": "public"
|
|
@@ -41,6 +44,7 @@
|
|
|
41
44
|
"skills/**/*",
|
|
42
45
|
"docs/**/*.md",
|
|
43
46
|
"README.md",
|
|
47
|
+
"CHANGELOG.md",
|
|
44
48
|
"CONTRIBUTING.md",
|
|
45
49
|
"LICENSE"
|
|
46
50
|
],
|
|
@@ -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,
|
|
@@ -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);
|