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/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, type TrackedJob, shouldRecycleAfterCollect, type WaitResult } from "./src/extension/runtime.ts";
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 full self-check (extensions/models/spawn/memory/tools/storage/runtime)." },
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({ description: "Child handle name (control actions, including wait and stop)" }),
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 herdrSubagents(pi: ExtensionAPI, deps: ExtensionSupervisorDeps = {}) {
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
- await cascadeRetireAll();
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
- await cascadeRetireAll();
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
- await cascadeRetireAll();
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: true,
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
- await cascadeRetireAll();
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
- await cascadeRetireAll();
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
- await cascadeRetireAll();
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 aborted)");
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 aborted)");
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 aborted)");
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 aborted)" }],
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
- // Let `launch()` allocate from the supervisor-local registry; the index
1704
- // must not race separate subagent tool calls.
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 aborted)");
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 aborted)");
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 aborted)");
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
- child.execution = { status: "failed", reason: "launch abandoned because parent turn aborted" };
2483
- child.acceptance = { status: "rejected", level: "none", reason: "aborted by parent" };
2484
- try {
2485
- await session.orchestrator.retire(name);
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 aborted)`);
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.0",
4
- "description": "Herdr-backed subagents for Pi: visible, steerable, resumable child agents in Herdr panes",
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
- "herdr",
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
  ],