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
package/index.ts
CHANGED
|
@@ -35,13 +35,14 @@ 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 {
|
|
41
42
|
loadSubagentSettings,
|
|
42
43
|
resolveSubagentSettings,
|
|
43
44
|
} from "./src/agents/settings.ts";
|
|
44
|
-
import { createSessionRuntime, renderLiveChildStatus, type SessionRuntime,
|
|
45
|
+
import { createSessionRuntime, renderLiveChildStatus, type SessionRuntime, shouldRecycleAfterCollect, type WaitResult } from "./src/extension/runtime.ts";
|
|
45
46
|
import { createDbEventSource, createLegionEventBus, type DbEventSource, type LegionEventBus } from "./src/extension/event-bus.ts";
|
|
46
47
|
import { LegionPostman, registerLegionTools, type LegionMailCommand } from "./src/extension/legion-tools.ts";
|
|
47
48
|
import { LEGION_DB_ENV, LEGION_NODE_ID_ENV } from "./src/runs/args.ts";
|
|
@@ -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";
|
|
@@ -65,7 +66,6 @@ import {
|
|
|
65
66
|
type RpcEventSource,
|
|
66
67
|
confirmAndStopNode,
|
|
67
68
|
} from "./src/extension/legion-commands.ts";
|
|
68
|
-
import { createIdleEscStop, liveJobs } from "./src/extension/legion-stop.ts";
|
|
69
69
|
import { DEFAULT_FLUSH_MS, DEFAULT_JOIN_MODE } from "./src/extension/join.ts";
|
|
70
70
|
import { registerChildGuard } from "./src/extension/child-guard.ts";
|
|
71
71
|
import { registerScoutGuard } from "./src/extension/scout-guard.ts";
|
|
@@ -81,7 +81,7 @@ import {
|
|
|
81
81
|
import { renderSubagentNotice } from "./src/extension/notice-renderer.ts";
|
|
82
82
|
import { PARENT_PLAYBOOK, TOOL_DESCRIPTION } from "./src/extension/playbook.ts";
|
|
83
83
|
import { getAgentDir } from "./src/agents/paths.ts";
|
|
84
|
-
import { expectedModelsFromSettings, runLegionDoctor } from "./src/extension/legion-doctor.ts";
|
|
84
|
+
import { expectedModelsFromSettings, parseDoctorSections, runLegionDoctor } from "./src/extension/legion-doctor.ts";
|
|
85
85
|
import { readFileSync } from "node:fs";
|
|
86
86
|
import {
|
|
87
87
|
effectiveMaxDepth,
|
|
@@ -97,13 +97,10 @@ import {
|
|
|
97
97
|
DEFAULT_ON_BLOCKED,
|
|
98
98
|
ErrorCodes,
|
|
99
99
|
type ModelOrigin,
|
|
100
|
+
type RetireOptions,
|
|
100
101
|
SubagentError,
|
|
101
102
|
} from "./src/shared/types.ts";
|
|
102
103
|
|
|
103
|
-
// The parent turn owns its child lifetime. Parallel tool aborts in one turn
|
|
104
|
-
// share this graceful-retire cascade; turn_start begins a fresh cycle.
|
|
105
|
-
let cascadePromise: Promise<void> | undefined;
|
|
106
|
-
|
|
107
104
|
// ---------------------------------------------------------------------------
|
|
108
105
|
// Parameters
|
|
109
106
|
// ---------------------------------------------------------------------------
|
|
@@ -146,7 +143,7 @@ const SubagentParams = Type.Object({
|
|
|
146
143
|
Type.Literal("stop"),
|
|
147
144
|
Type.Literal("diagnose"),
|
|
148
145
|
],
|
|
149
|
-
{ description: "Defaults to launch. diagnose = Legion doctor
|
|
146
|
+
{ description: "Defaults to launch. diagnose = Legion doctor self-check (extensions/models/spawn/memory/tools/storage/runtime); optional section filters like /legion doctor." },
|
|
150
147
|
),
|
|
151
148
|
),
|
|
152
149
|
agent: Type.Optional(
|
|
@@ -189,7 +186,10 @@ const SubagentParams = Type.Object({
|
|
|
189
186
|
),
|
|
190
187
|
),
|
|
191
188
|
name: Type.Optional(
|
|
192
|
-
Type.String({
|
|
189
|
+
Type.String({
|
|
190
|
+
description:
|
|
191
|
+
"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.",
|
|
192
|
+
}),
|
|
193
193
|
),
|
|
194
194
|
all: Type.Optional(
|
|
195
195
|
Type.Boolean({
|
|
@@ -207,6 +207,12 @@ const SubagentParams = Type.Object({
|
|
|
207
207
|
team: Type.Optional(
|
|
208
208
|
Type.String({ description: "Team to use for the launched child and its nested roster" }),
|
|
209
209
|
),
|
|
210
|
+
section: Type.Optional(
|
|
211
|
+
Type.String({
|
|
212
|
+
description:
|
|
213
|
+
'diagnose only: optional section filter (summary|extensions|models|spawn|memory|tools|storage|runtime|next), same as `/legion doctor [section]`. Space/comma-separated for multiple.',
|
|
214
|
+
}),
|
|
215
|
+
),
|
|
210
216
|
});
|
|
211
217
|
|
|
212
218
|
/** The validated tool parameters, as the model supplies them. */
|
|
@@ -469,8 +475,7 @@ async function resumeLegionChild(input: { pi: ExtensionAPI; supervisor: LegionSu
|
|
|
469
475
|
await store.updateChild(parentRun.runId, saved.name, (child) => Object.assign(child, handle.child));
|
|
470
476
|
}
|
|
471
477
|
|
|
472
|
-
export default function
|
|
473
|
-
cascadePromise = undefined;
|
|
478
|
+
export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps = {}) {
|
|
474
479
|
const isChild = process.env.PI_SUBAGENT_CHILD === "1";
|
|
475
480
|
const allowNested = process.env[ALLOW_NESTED_ENV] === "1";
|
|
476
481
|
if (isChild) restoreHostNodeOptions();
|
|
@@ -562,60 +567,25 @@ export default function herdrSubagents(pi: ExtensionAPI, deps: ExtensionSupervis
|
|
|
562
567
|
timer.unref?.();
|
|
563
568
|
}
|
|
564
569
|
});
|
|
565
|
-
const pendingUtilityRetires: Array<() => Promise<void>> = [];
|
|
570
|
+
const pendingUtilityRetires: Array<(opts?: RetireOptions) => Promise<void>> = [];
|
|
566
571
|
let sessionRuntime: SessionRuntime | undefined;
|
|
567
|
-
const shutdownRetireAll = async (): Promise<void> => {
|
|
572
|
+
const shutdownRetireAll = async (_event?: unknown, ctx?: { mode?: string }): Promise<void> => {
|
|
573
|
+
// pi --print / JSON exits when the parent turn ends. Async children
|
|
574
|
+
// have only just been spawned; retiring them here aborts the run
|
|
575
|
+
// before the first model call. Interactive quit still retires immediately.
|
|
576
|
+
const mode = ctx?.mode;
|
|
577
|
+
if (mode === "print" || mode === "json") {
|
|
578
|
+
await sessionRuntime?.drainBeforeShutdown();
|
|
579
|
+
}
|
|
568
580
|
const children = sessionRuntime?.activeJobs() ?? [];
|
|
569
581
|
sessionRuntime?.dispose();
|
|
582
|
+
const shutdownRetire: RetireOptions = { cause: "session-shutdown" };
|
|
570
583
|
await Promise.all([
|
|
571
|
-
...children.map((job) => job.retire?.().catch(() => {})),
|
|
572
|
-
...pendingUtilityRetires.map((retire) => retire().catch(() => {})),
|
|
584
|
+
...children.map((job) => job.retire?.(shutdownRetire).catch(() => {})),
|
|
585
|
+
...pendingUtilityRetires.map((retire) => retire(shutdownRetire).catch(() => {})),
|
|
573
586
|
]);
|
|
574
587
|
stopLegionRuntime();
|
|
575
588
|
};
|
|
576
|
-
let cascadingJobs = new Set<TrackedJob>();
|
|
577
|
-
let cascadingUtilityRetires = new Set<() => Promise<void>>();
|
|
578
|
-
const cascadeRetireAll = (): Promise<void> => {
|
|
579
|
-
const jobs = (sessionRuntime?.activeJobs() ?? []).filter(
|
|
580
|
-
(job) => job.state !== "retired" && !cascadingJobs.has(job),
|
|
581
|
-
);
|
|
582
|
-
const utilityRetires = pendingUtilityRetires.filter(
|
|
583
|
-
(retire) => !cascadingUtilityRetires.has(retire),
|
|
584
|
-
);
|
|
585
|
-
if (!cascadePromise) cascadePromise = Promise.resolve();
|
|
586
|
-
if (jobs.length === 0 && utilityRetires.length === 0) return cascadePromise;
|
|
587
|
-
for (const job of jobs) {
|
|
588
|
-
cascadingJobs.add(job);
|
|
589
|
-
sessionRuntime?.cancelWatch(job.name);
|
|
590
|
-
}
|
|
591
|
-
for (const retire of utilityRetires) cascadingUtilityRetires.add(retire);
|
|
592
|
-
const retirements = [
|
|
593
|
-
...jobs.map((job) => async () => job.retire?.()),
|
|
594
|
-
...utilityRetires.map((retire) => async () => retire()),
|
|
595
|
-
];
|
|
596
|
-
cascadePromise = cascadePromise.then(async () => {
|
|
597
|
-
// allSettled waits for every graceful retire without turning a child
|
|
598
|
-
// failure into a user-visible result or skipping runtime release.
|
|
599
|
-
await Promise.allSettled(retirements.map((retire) => retire()));
|
|
600
|
-
for (const job of jobs) sessionRuntime?.release(job.name);
|
|
601
|
-
});
|
|
602
|
-
return cascadePromise;
|
|
603
|
-
};
|
|
604
|
-
// Register before the child early-return paths below: a command-chain child
|
|
605
|
-
// may use vexillatio across multiple turns without creating a session runtime.
|
|
606
|
-
pi.on("turn_start", () => {
|
|
607
|
-
cascadePromise = undefined;
|
|
608
|
-
cascadingJobs = new Set();
|
|
609
|
-
cascadingUtilityRetires = new Set();
|
|
610
|
-
});
|
|
611
|
-
// An async launch has already returned when ESC lands: no subagent-family
|
|
612
|
-
// tool call is in flight to observe the abort signal, so the run's settle
|
|
613
|
-
// event is the last chance to retire this turn's children. Never awaited —
|
|
614
|
-
// a graceful retire may take seconds and must not stall the settle path.
|
|
615
|
-
pi.on("agent_settled", (event) => {
|
|
616
|
-
if (!event.aborted) return;
|
|
617
|
-
void cascadeRetireAll();
|
|
618
|
-
});
|
|
619
589
|
if (process.env[UTILITY_CHILD_ENV] !== "1") {
|
|
620
590
|
pi.registerTool({
|
|
621
591
|
name: "vexillatio",
|
|
@@ -624,8 +594,7 @@ export default function herdrSubagents(pi: ExtensionAPI, deps: ExtensionSupervis
|
|
|
624
594
|
parameters: VexillatioParams,
|
|
625
595
|
async execute(_id, params, signal, _onUpdate, ctx: ExtensionContext): Promise<AgentToolResult<unknown>> {
|
|
626
596
|
if (signal?.aborted) {
|
|
627
|
-
|
|
628
|
-
return ok("interrupted (parent turn aborted)");
|
|
597
|
+
return ok("interrupted (parent turn ended; children still running)");
|
|
629
598
|
}
|
|
630
599
|
if (!subagentsEnabled(ctx.cwd)) {
|
|
631
600
|
return fail("subagents are disabled (subagents.enabled: false or /legion off). Re-enable with /legion on.", ErrorCodes.DISABLED);
|
|
@@ -654,8 +623,7 @@ export default function herdrSubagents(pi: ExtensionAPI, deps: ExtensionSupervis
|
|
|
654
623
|
onUtilityChild: (retire) => pendingUtilityRetires.push(retire),
|
|
655
624
|
});
|
|
656
625
|
if (signal?.aborted) {
|
|
657
|
-
|
|
658
|
-
return ok("interrupted (parent turn aborted)");
|
|
626
|
+
return ok("interrupted (parent turn ended; children still running)");
|
|
659
627
|
}
|
|
660
628
|
return result;
|
|
661
629
|
},
|
|
@@ -786,6 +754,27 @@ export default function herdrSubagents(pi: ExtensionAPI, deps: ExtensionSupervis
|
|
|
786
754
|
runtime,
|
|
787
755
|
supervisor: rpcEventSource,
|
|
788
756
|
stop: stopChildren,
|
|
757
|
+
childStatus: (name, cwd) => {
|
|
758
|
+
try {
|
|
759
|
+
const found = findChild(runStoreFor(cwd).listRuns(), name);
|
|
760
|
+
if (!found) return undefined;
|
|
761
|
+
const child = found.child;
|
|
762
|
+
return {
|
|
763
|
+
name: child.name,
|
|
764
|
+
state: child.state,
|
|
765
|
+
agent: child.agent,
|
|
766
|
+
kind: child.kind,
|
|
767
|
+
sessionFile: child.sessionFile,
|
|
768
|
+
...(child.execution ? { execution: child.execution } : {}),
|
|
769
|
+
...(child.acceptance
|
|
770
|
+
? { acceptance: { status: child.acceptance.status, level: child.acceptance.level } }
|
|
771
|
+
: {}),
|
|
772
|
+
alive: supervisor.isAlive(child.name),
|
|
773
|
+
};
|
|
774
|
+
} catch {
|
|
775
|
+
return undefined;
|
|
776
|
+
}
|
|
777
|
+
},
|
|
789
778
|
commands: {
|
|
790
779
|
team: { loadCatalog: commandCatalogLoader },
|
|
791
780
|
agents: { loadCatalog: commandCatalogLoader },
|
|
@@ -834,8 +823,7 @@ export default function herdrSubagents(pi: ExtensionAPI, deps: ExtensionSupervis
|
|
|
834
823
|
runtime.bind(ctx);
|
|
835
824
|
const action = params.action ?? "launch";
|
|
836
825
|
if (signal?.aborted && action !== "stop") {
|
|
837
|
-
|
|
838
|
-
return ok("interrupted (parent turn aborted)");
|
|
826
|
+
return ok("interrupted (parent turn ended; children still running)");
|
|
839
827
|
}
|
|
840
828
|
// Master switch: refuse NEW launches with a pointer to the toggle;
|
|
841
829
|
// control actions (status/collect/wait/…) must stay available so a
|
|
@@ -868,6 +856,17 @@ export default function herdrSubagents(pi: ExtensionAPI, deps: ExtensionSupervis
|
|
|
868
856
|
|
|
869
857
|
if (action === "list") return ok(renderAgentList(agents, team, teamWarnings));
|
|
870
858
|
if (action === "diagnose") {
|
|
859
|
+
let sections;
|
|
860
|
+
try {
|
|
861
|
+
sections = parseDoctorSections(
|
|
862
|
+
typeof params.section === "string" ? params.section : undefined,
|
|
863
|
+
);
|
|
864
|
+
} catch (error) {
|
|
865
|
+
return fail(
|
|
866
|
+
error instanceof Error ? error.message : String(error),
|
|
867
|
+
ErrorCodes.INVALID_PARAMS,
|
|
868
|
+
);
|
|
869
|
+
}
|
|
871
870
|
let settings: unknown;
|
|
872
871
|
try {
|
|
873
872
|
settings = JSON.parse(readFileSync(path.join(getAgentDir(), "settings.json"), "utf8"));
|
|
@@ -881,6 +880,7 @@ export default function herdrSubagents(pi: ExtensionAPI, deps: ExtensionSupervis
|
|
|
881
880
|
expectedModels: expectedModelsFromSettings(settings),
|
|
882
881
|
sampleAgent: agents[0],
|
|
883
882
|
agents,
|
|
883
|
+
sections,
|
|
884
884
|
runtime: {
|
|
885
885
|
liveJobs: runtime.activeJobs().map((job) => ({
|
|
886
886
|
name: job.name,
|
|
@@ -888,7 +888,7 @@ export default function herdrSubagents(pi: ExtensionAPI, deps: ExtensionSupervis
|
|
|
888
888
|
nodeId: job.nodeId,
|
|
889
889
|
})),
|
|
890
890
|
stopAvailable: true,
|
|
891
|
-
idleEscWired:
|
|
891
|
+
idleEscWired: false,
|
|
892
892
|
legionTools: ["subagent", "vexillatio"],
|
|
893
893
|
allowNestedEnv: process.env[ALLOW_NESTED_ENV] === "1",
|
|
894
894
|
},
|
|
@@ -908,8 +908,7 @@ export default function herdrSubagents(pi: ExtensionAPI, deps: ExtensionSupervis
|
|
|
908
908
|
return fail(`legion runtime initialization failed: ${error instanceof Error ? error.message : String(error)}`, ErrorCodes.INVALID_PARAMS);
|
|
909
909
|
}
|
|
910
910
|
if (signal?.aborted && action !== "stop") {
|
|
911
|
-
|
|
912
|
-
return ok("interrupted (parent turn aborted)");
|
|
911
|
+
return ok("interrupted (parent turn ended; children still running)");
|
|
913
912
|
}
|
|
914
913
|
|
|
915
914
|
// Control actions address an EXISTING child; launch-family actions
|
|
@@ -933,8 +932,7 @@ export default function herdrSubagents(pi: ExtensionAPI, deps: ExtensionSupervis
|
|
|
933
932
|
legion: activeLegion,
|
|
934
933
|
});
|
|
935
934
|
if (signal?.aborted && action !== "stop") {
|
|
936
|
-
|
|
937
|
-
return ok("interrupted (parent turn aborted)");
|
|
935
|
+
return ok("interrupted (parent turn ended; children still running)");
|
|
938
936
|
}
|
|
939
937
|
return result;
|
|
940
938
|
}
|
|
@@ -958,41 +956,13 @@ export default function herdrSubagents(pi: ExtensionAPI, deps: ExtensionSupervis
|
|
|
958
956
|
onUpdate,
|
|
959
957
|
});
|
|
960
958
|
if (signal?.aborted) {
|
|
961
|
-
|
|
962
|
-
return ok("interrupted (parent turn aborted)");
|
|
959
|
+
return ok("interrupted (parent turn ended; children still running)");
|
|
963
960
|
}
|
|
964
961
|
return result;
|
|
965
962
|
},
|
|
966
963
|
});
|
|
967
964
|
|
|
968
|
-
let lastCtx: ExtensionContext | undefined;
|
|
969
|
-
let escUi: ExtensionUIContext | undefined;
|
|
970
|
-
let escUnsubscribe: (() => void) | undefined;
|
|
971
|
-
const idleEscStop = createIdleEscStop({
|
|
972
|
-
isIdle: () => lastCtx?.isIdle?.() ?? false,
|
|
973
|
-
editorEmpty: () => !(lastUi?.getEditorText?.() ?? "").trim(),
|
|
974
|
-
uiBusy: () => {
|
|
975
|
-
const tui = runtime.statusTui() as { hasOverlay?: () => boolean; getFocusedComponent?: () => unknown } | undefined;
|
|
976
|
-
if (!tui) return true;
|
|
977
|
-
if (tui.hasOverlay?.()) return true;
|
|
978
|
-
const focused = tui.getFocusedComponent?.() as { getText?: unknown } | null | undefined;
|
|
979
|
-
// Only the prompt editor may have focus: dialogs/selectors keep pi's Esc.
|
|
980
|
-
return !focused || typeof focused.getText !== "function" || !("onEscape" in focused);
|
|
981
|
-
},
|
|
982
|
-
liveCount: () => liveJobs(runtime.activeJobs()).length,
|
|
983
|
-
confirm: (title, message) => lastUi ? lastUi.confirm(title, message) : Promise.resolve(false),
|
|
984
|
-
stopAll: async () => {
|
|
985
|
-
const result = await stopChildren({ all: true });
|
|
986
|
-
lastUi?.notify(result.text, result.ok ? "info" : "error");
|
|
987
|
-
},
|
|
988
|
-
});
|
|
989
965
|
const bindUi = (_event: unknown, ctx: ExtensionContext): void => {
|
|
990
|
-
lastCtx = ctx;
|
|
991
|
-
if (ctx.hasUI && ctx.ui !== escUi && typeof ctx.ui?.onTerminalInput === "function") {
|
|
992
|
-
escUnsubscribe?.();
|
|
993
|
-
escUi = ctx.ui;
|
|
994
|
-
escUnsubscribe = ctx.ui.onTerminalInput(idleEscStop);
|
|
995
|
-
}
|
|
996
966
|
lastUi = ctx.hasUI ? ctx.ui : undefined;
|
|
997
967
|
if (ctx.cwd) lastCwd = ctx.cwd;
|
|
998
968
|
runtime.bind(ctx);
|
|
@@ -1023,7 +993,6 @@ export default function herdrSubagents(pi: ExtensionAPI, deps: ExtensionSupervis
|
|
|
1023
993
|
});
|
|
1024
994
|
|
|
1025
995
|
pi.on("session_start", (event, ctx) => {
|
|
1026
|
-
runtime.setParentIdle(ctx.isIdle?.() ?? true);
|
|
1027
996
|
bindUi(event, ctx);
|
|
1028
997
|
});
|
|
1029
998
|
pi.on("session_info_changed", bindUi);
|
|
@@ -1036,11 +1005,9 @@ export default function herdrSubagents(pi: ExtensionAPI, deps: ExtensionSupervis
|
|
|
1036
1005
|
bindUi(event, ctx);
|
|
1037
1006
|
});
|
|
1038
1007
|
pi.on("agent_start", (event, ctx) => {
|
|
1039
|
-
runtime.setParentIdle(false);
|
|
1040
1008
|
bindUi(event, ctx);
|
|
1041
1009
|
});
|
|
1042
1010
|
pi.on("agent_end", (event, ctx) => {
|
|
1043
|
-
runtime.setParentIdle(true);
|
|
1044
1011
|
bindUi(event, ctx);
|
|
1045
1012
|
});
|
|
1046
1013
|
pi.on("input", bindUi);
|
|
@@ -1315,13 +1282,13 @@ async function collectChild(
|
|
|
1315
1282
|
signal?: AbortSignal,
|
|
1316
1283
|
): Promise<AgentToolResult<unknown>> {
|
|
1317
1284
|
try {
|
|
1318
|
-
if (signal?.aborted) return ok("interrupted (parent turn
|
|
1285
|
+
if (signal?.aborted) return ok("interrupted (parent turn ended; children still running)");
|
|
1319
1286
|
const pending = signal
|
|
1320
1287
|
? ctx.runtime.consumeCollectInterruptible(ctx.name, signal)
|
|
1321
1288
|
: ctx.runtime.consumeCollect(ctx.name);
|
|
1322
1289
|
if (pending) {
|
|
1323
1290
|
const collected = await pending;
|
|
1324
|
-
if (!collected || signal?.aborted) return ok("interrupted (parent turn
|
|
1291
|
+
if (!collected || signal?.aborted) return ok("interrupted (parent turn ended; children still running)");
|
|
1325
1292
|
await persistChild(ctx.store, ctx.found.runId, ctx.orchestrator, ctx.name, { preserveRetired: true });
|
|
1326
1293
|
return ok(renderCollect(ctx.name, collected));
|
|
1327
1294
|
}
|
|
@@ -1340,7 +1307,7 @@ async function collectChild(
|
|
|
1340
1307
|
...(signal ? { signal } : {}),
|
|
1341
1308
|
});
|
|
1342
1309
|
if (signal?.aborted || collected.execution.reason === "aborted by parent") {
|
|
1343
|
-
return ok("interrupted (parent turn
|
|
1310
|
+
return ok("interrupted (parent turn ended; children still running)");
|
|
1344
1311
|
}
|
|
1345
1312
|
const retireAlreadyFinishedJob = shouldRecycleAfterCollect(collected.execution.status) && !collected.blocked;
|
|
1346
1313
|
await persistChild(ctx.store, ctx.found.runId, ctx.orchestrator, ctx.name, { preserveRetired: !retireAlreadyFinishedJob });
|
|
@@ -1460,6 +1427,8 @@ async function launchFamily(input: {
|
|
|
1460
1427
|
|
|
1461
1428
|
const plan = buildPlan(params);
|
|
1462
1429
|
if (!plan.ok) return fail(plan.message, ErrorCodes.INVALID_PARAMS);
|
|
1430
|
+
const nameGate = resolveExplicitLaunchName(params.name, plan.steps.length);
|
|
1431
|
+
if (!nameGate.ok) return fail(nameGate.message, ErrorCodes.INVALID_PARAMS);
|
|
1463
1432
|
|
|
1464
1433
|
onUpdate?.({
|
|
1465
1434
|
content: [
|
|
@@ -1536,9 +1505,11 @@ async function launchFamily(input: {
|
|
|
1536
1505
|
dispatchModel: ctx.model
|
|
1537
1506
|
? `${ctx.model.provider}/${ctx.model.id}`
|
|
1538
1507
|
: undefined,
|
|
1508
|
+
modelRegistry: ctx.modelRegistry,
|
|
1539
1509
|
results: [],
|
|
1540
1510
|
handles: [],
|
|
1541
1511
|
timeoutByName: new Map(),
|
|
1512
|
+
...(nameGate.name ? { launchName: nameGate.name } : {}),
|
|
1542
1513
|
};
|
|
1543
1514
|
|
|
1544
1515
|
const interruptedResult = async (): Promise<AgentToolResult<unknown> | undefined> => {
|
|
@@ -1546,7 +1517,7 @@ async function launchFamily(input: {
|
|
|
1546
1517
|
for (const name of session.handles) await abandonLaunchedChild(session, name);
|
|
1547
1518
|
session.handles.length = 0;
|
|
1548
1519
|
return {
|
|
1549
|
-
content: [{ type: "text", text: session.results.join("\n") || "interrupted (parent turn
|
|
1520
|
+
content: [{ type: "text", text: session.results.join("\n") || "interrupted (parent turn ended; children still running)" }],
|
|
1550
1521
|
details: { runId: run.runId, handles: [], async: params.async ?? true },
|
|
1551
1522
|
};
|
|
1552
1523
|
};
|
|
@@ -1619,9 +1590,13 @@ interface LaunchSession {
|
|
|
1619
1590
|
runtime: SessionRuntime;
|
|
1620
1591
|
onUpdate?: AgentToolUpdateCallback;
|
|
1621
1592
|
dispatchModel: string | undefined;
|
|
1593
|
+
/** Parent registry, used to pin `--provider` for ambiguous model ids (#123). */
|
|
1594
|
+
modelRegistry: ProviderLookup | undefined;
|
|
1622
1595
|
results: string[];
|
|
1623
1596
|
handles: string[];
|
|
1624
1597
|
timeoutByName: Map<string, number>;
|
|
1598
|
+
/** Explicit child name for this single-step launch; omit to auto-allocate. */
|
|
1599
|
+
launchName?: string;
|
|
1625
1600
|
}
|
|
1626
1601
|
|
|
1627
1602
|
/**
|
|
@@ -1700,13 +1675,20 @@ async function launchStep(
|
|
|
1700
1675
|
return;
|
|
1701
1676
|
}
|
|
1702
1677
|
|
|
1703
|
-
//
|
|
1704
|
-
//
|
|
1678
|
+
// Honor `name=` when the caller asked for one; otherwise let `launch()`
|
|
1679
|
+
// allocate from the supervisor-local registry (scout-N). The index must
|
|
1680
|
+
// not race separate subagent tool calls for auto names.
|
|
1705
1681
|
const handle = await session.orchestrator.launch({
|
|
1706
1682
|
agent: launchedAgent,
|
|
1707
1683
|
task: step.task,
|
|
1684
|
+
...(session.launchName ? { name: session.launchName } : {}),
|
|
1708
1685
|
...(session.teamOverride ? { team: session.teamOverride } : {}),
|
|
1709
1686
|
...(resolved.model ? { model: resolved.model } : {}),
|
|
1687
|
+
// Pin the provider (#123): a bare id like `workbuddy/...` may also
|
|
1688
|
+
// exist under a static auth-less provider in models.json; under
|
|
1689
|
+
// `--no-extensions` the child would resolve it there and die with
|
|
1690
|
+
// "No API key found". The parent registry knows the live provider.
|
|
1691
|
+
...(resolved.model ? { modelProvider: providerForModel(session.modelRegistry, resolved.model) } : {}),
|
|
1710
1692
|
// The origin decides whether an unacceptable model is refused or
|
|
1711
1693
|
// dropped, and only this layer can tell them apart (a parent model
|
|
1712
1694
|
// arrives as the same `dispatch` source as a per-run override).
|
|
@@ -1846,7 +1828,7 @@ function resolveStep(
|
|
|
1846
1828
|
async function collectInline(session: LaunchSession, signal?: AbortSignal): Promise<void> {
|
|
1847
1829
|
for (const name of session.handles) {
|
|
1848
1830
|
if (signal?.aborted) {
|
|
1849
|
-
session.results.push("interrupted (parent turn
|
|
1831
|
+
session.results.push("interrupted (parent turn ended; children still running)");
|
|
1850
1832
|
return;
|
|
1851
1833
|
}
|
|
1852
1834
|
try {
|
|
@@ -1855,7 +1837,7 @@ async function collectInline(session: LaunchSession, signal?: AbortSignal): Prom
|
|
|
1855
1837
|
...(signal ? { signal } : {}),
|
|
1856
1838
|
});
|
|
1857
1839
|
if (signal?.aborted || collected.execution.reason === "aborted by parent") {
|
|
1858
|
-
session.results.push("interrupted (parent turn
|
|
1840
|
+
session.results.push("interrupted (parent turn ended; children still running)");
|
|
1859
1841
|
return;
|
|
1860
1842
|
}
|
|
1861
1843
|
session.results.push(renderCollect(name, collected));
|
|
@@ -1924,7 +1906,7 @@ async function launchUtilityFamily(input: {
|
|
|
1924
1906
|
legion?: LegionRuntimeIdentity;
|
|
1925
1907
|
eventBus: LegionEventBus;
|
|
1926
1908
|
onLegion: (identity: LegionRuntimeIdentity) => void;
|
|
1927
|
-
onUtilityChild: (retire: () => Promise<void>) => void;
|
|
1909
|
+
onUtilityChild: (retire: (opts?: RetireOptions) => Promise<void>) => void;
|
|
1928
1910
|
}): Promise<AgentToolResult<unknown>> {
|
|
1929
1911
|
const plan = buildUtilityPlan(input.params);
|
|
1930
1912
|
if (!plan.ok) return fail(plan.message, ErrorCodes.INVALID_PARAMS);
|
|
@@ -2092,6 +2074,8 @@ async function launchUtilityFamily(input: {
|
|
|
2092
2074
|
task: item.step.task,
|
|
2093
2075
|
name: names.get(item.step),
|
|
2094
2076
|
...(item.resolved.model ? { model: item.resolved.model } : {}),
|
|
2077
|
+
// Provider pin (#123) — see launchStep for the rationale.
|
|
2078
|
+
...(item.resolved.model ? { modelProvider: providerForModel(input.ctx.modelRegistry, item.resolved.model) } : {}),
|
|
2095
2079
|
modelOrigin: item.modelOrigin,
|
|
2096
2080
|
...(item.step.worktree !== undefined ? { worktree: item.step.worktree } : {}),
|
|
2097
2081
|
utilityDispatch: true,
|
|
@@ -2099,10 +2083,10 @@ async function launchUtilityFamily(input: {
|
|
|
2099
2083
|
});
|
|
2100
2084
|
await persistChild(store, runId, orchestrator, handle.name);
|
|
2101
2085
|
let retirement: Promise<void> | undefined;
|
|
2102
|
-
const retire = (): Promise<void> => {
|
|
2086
|
+
const retire = (opts?: RetireOptions): Promise<void> => {
|
|
2103
2087
|
if (!retirement) {
|
|
2104
2088
|
const current = (async () => {
|
|
2105
|
-
await orchestrator.retire(handle.name);
|
|
2089
|
+
await orchestrator.retire(handle.name, opts);
|
|
2106
2090
|
await persistChild(store, runId, orchestrator, handle.name);
|
|
2107
2091
|
})();
|
|
2108
2092
|
retirement = current;
|
|
@@ -2112,9 +2096,9 @@ async function launchUtilityFamily(input: {
|
|
|
2112
2096
|
}
|
|
2113
2097
|
return retirement;
|
|
2114
2098
|
};
|
|
2115
|
-
input.onUtilityChild(async () => {
|
|
2099
|
+
input.onUtilityChild(async (opts) => {
|
|
2116
2100
|
try {
|
|
2117
|
-
await retire();
|
|
2101
|
+
await retire(opts);
|
|
2118
2102
|
} catch {
|
|
2119
2103
|
// Shutdown cleanup is best-effort; the supervisor owns final process state.
|
|
2120
2104
|
}
|
|
@@ -2141,7 +2125,7 @@ async function launchUtilityFamily(input: {
|
|
|
2141
2125
|
agent: string;
|
|
2142
2126
|
sessionFile: string;
|
|
2143
2127
|
orchestrator: Orchestrator;
|
|
2144
|
-
retire: () => Promise<void>;
|
|
2128
|
+
retire: (opts?: RetireOptions) => Promise<void>;
|
|
2145
2129
|
}> = [];
|
|
2146
2130
|
for (const launch of launches) {
|
|
2147
2131
|
if ("error" in launch) results.push(`✗ ${launch.item.agent.name}: ${launch.error}`);
|
|
@@ -2196,7 +2180,7 @@ async function launchUtilityFamily(input: {
|
|
|
2196
2180
|
return `✗ ${handle.name}: collect failed: ${error instanceof Error ? error.message : String(error)}\nsessionFile: ${handle.sessionFile}`;
|
|
2197
2181
|
}
|
|
2198
2182
|
}));
|
|
2199
|
-
if (input.signal?.aborted) return ok("interrupted (parent turn
|
|
2183
|
+
if (input.signal?.aborted) return ok("interrupted (parent turn ended; children still running)");
|
|
2200
2184
|
results.push(...collected);
|
|
2201
2185
|
return {
|
|
2202
2186
|
content: [{ type: "text", text: results.join("\n\n") || "No utility agents were launched." }],
|
|
@@ -2211,6 +2195,41 @@ async function launchUtilityFamily(input: {
|
|
|
2211
2195
|
};
|
|
2212
2196
|
}
|
|
2213
2197
|
|
|
2198
|
+
/**
|
|
2199
|
+
* Resolve an explicit `name=` on launch.
|
|
2200
|
+
*
|
|
2201
|
+
* Prefer honoring the caller's handle over auto `role-N`, so `/legion status <name>`
|
|
2202
|
+
* and control actions match the tool parameter. Multi-step `tasks[]` cannot share
|
|
2203
|
+
* one name; invalid grammar is refused rather than silently sanitized into a
|
|
2204
|
+
* different handle the caller would not find.
|
|
2205
|
+
*/
|
|
2206
|
+
export function resolveExplicitLaunchName(
|
|
2207
|
+
requested: string | undefined,
|
|
2208
|
+
stepCount: number,
|
|
2209
|
+
): { ok: true; name?: string } | { ok: false; message: string } {
|
|
2210
|
+
if (requested === undefined) return { ok: true };
|
|
2211
|
+
const trimmed = requested.trim();
|
|
2212
|
+
if (!trimmed) {
|
|
2213
|
+
return { ok: false, message: "`name` must not be blank when provided." };
|
|
2214
|
+
}
|
|
2215
|
+
if (stepCount > 1) {
|
|
2216
|
+
return {
|
|
2217
|
+
ok: false,
|
|
2218
|
+
message:
|
|
2219
|
+
"`name` applies only to a single (agent+task) launch, not tasks[] with multiple steps.",
|
|
2220
|
+
};
|
|
2221
|
+
}
|
|
2222
|
+
const normalized = trimmed.toLowerCase();
|
|
2223
|
+
if (!isValidAgentName(normalized)) {
|
|
2224
|
+
return {
|
|
2225
|
+
ok: false,
|
|
2226
|
+
message:
|
|
2227
|
+
`invalid child name "${trimmed}": must match [a-z][a-z0-9_-]{0,31} (lowercase letter first, max 32 chars).`,
|
|
2228
|
+
};
|
|
2229
|
+
}
|
|
2230
|
+
return { ok: true, name: normalized };
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2214
2233
|
export function buildPlan(params: {
|
|
2215
2234
|
agent?: string;
|
|
2216
2235
|
task?: string;
|
|
@@ -2319,7 +2338,7 @@ function followJob(
|
|
|
2319
2338
|
orchestrator: Orchestrator;
|
|
2320
2339
|
store: RunStore;
|
|
2321
2340
|
watch: boolean;
|
|
2322
|
-
retire?: () => Promise<void>;
|
|
2341
|
+
retire?: (opts?: RetireOptions) => Promise<void>;
|
|
2323
2342
|
treeIdentity?: { nodeId: string; parentId: string | null; depth: number };
|
|
2324
2343
|
},
|
|
2325
2344
|
): void {
|
|
@@ -2350,12 +2369,12 @@ function followJob(
|
|
|
2350
2369
|
isAlive: () => input.orchestrator.isChildAlive(input.name),
|
|
2351
2370
|
persist: async () =>
|
|
2352
2371
|
persistChild(input.store, input.runId, input.orchestrator, input.name),
|
|
2353
|
-
retire: async () => {
|
|
2372
|
+
retire: async (opts) => {
|
|
2354
2373
|
if (input.retire) {
|
|
2355
|
-
await input.retire();
|
|
2374
|
+
await input.retire(opts);
|
|
2356
2375
|
return;
|
|
2357
2376
|
}
|
|
2358
|
-
await input.orchestrator.retire(input.name);
|
|
2377
|
+
await input.orchestrator.retire(input.name, opts);
|
|
2359
2378
|
await persistChild(
|
|
2360
2379
|
input.store,
|
|
2361
2380
|
input.runId,
|
|
@@ -2479,18 +2498,10 @@ async function abandonLaunchedChild(session: LaunchSession, name: string): Promi
|
|
|
2479
2498
|
session.runtime.release(name);
|
|
2480
2499
|
return;
|
|
2481
2500
|
}
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
await persistChild(session.store, session.runId, session.orchestrator, name);
|
|
2487
|
-
session.runtime.release(name);
|
|
2488
|
-
session.results.push(`✗ ${name}: abandoned because parent turn aborted (child retired)`);
|
|
2489
|
-
} catch (error) {
|
|
2490
|
-
await persistChild(session.store, session.runId, session.orchestrator, name);
|
|
2491
|
-
followLaunched(session, name, { watch: true });
|
|
2492
|
-
session.results.push(`✗ ${name}: abandon retire failed: ${String(error)}; completion remains watched`);
|
|
2493
|
-
}
|
|
2501
|
+
// Esc / parent-turn abort must not retire: child keeps running; follow+watch
|
|
2502
|
+
// so settle still notifies the parent (esc-no-retire-v3).
|
|
2503
|
+
followLaunched(session, name, { watch: true });
|
|
2504
|
+
session.results.push(`▶ ${name}: parent turn ended; child still running (watched)`);
|
|
2494
2505
|
}
|
|
2495
2506
|
|
|
2496
2507
|
// ---------------------------------------------------------------------------
|
|
@@ -2543,7 +2554,7 @@ export function renderWait(results: WaitResult[]): string {
|
|
|
2543
2554
|
parts.push(`── ${result.name} ──\nstill running`);
|
|
2544
2555
|
} else if (result.interrupted) {
|
|
2545
2556
|
interrupted += 1;
|
|
2546
|
-
parts.push(`── ${result.name} ──\ninterrupted (parent turn
|
|
2557
|
+
parts.push(`── ${result.name} ──\ninterrupted (parent turn ended; children still running)`);
|
|
2547
2558
|
} else {
|
|
2548
2559
|
parts.push(`── ${result.name} ──\nnot tracked (no live job, no finished cache)`);
|
|
2549
2560
|
}
|
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.2",
|
|
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
|
],
|