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/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 full self-check (extensions/models/spawn/memory/tools/storage/runtime)." },
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({ description: "Child handle name (control actions, including wait and stop)" }),
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 herdrSubagents(pi: ExtensionAPI, deps: ExtensionSupervisorDeps = {}) {
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
- // Let `launch()` allocate from the supervisor-local registry; the index
1704
- // must not race separate subagent tool calls.
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.0",
4
- "description": "Herdr-backed subagents for Pi: visible, steerable, resumable child agents in Herdr panes",
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
- "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
  ],
@@ -32,14 +32,20 @@ directly.
32
32
 
33
33
  ## Legion visualization
34
34
 
35
- In an interactive Pi session, `/legion` opens the paged Legion tree. Navigate
36
- with `j`/`k` or the arrow keys, use Enter/Space to expand or collapse, `/` to
37
- search, and PageUp/PageDown to scroll; `q` or Esc closes the tree. Press `o` on
38
- the selected node, or use a component-routed mouse click in the full-screen
39
- tree, to open its live overlay. `/legion <name>` opens a node directly. If
40
- short names are ambiguous, use the full node ID shown in command completion.
41
- Esc closes an overlay and unsubscribes its node's live feed. OSC 8 links are
42
- visual anchors only; they do not open overlays or route clicks.
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`.
@@ -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 = "PI_HERDR_SUBAGENTS_EXTRA_AGENT_DIRS";
71
+ export const EXTRA_AGENT_DIRS_ENV = "PI_LEGION_EXTRA_AGENT_DIRS";
68
72
 
69
- /** Split `PI_HERDR_SUBAGENTS_EXTRA_AGENT_DIRS` into absolute directory paths. */
70
- function extraAgentDirs(): string[] {
71
- const raw = process.env[EXTRA_AGENT_DIRS_ENV];
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
- " /legion team [list|use <name> [--global]|create <name> a,b,c [--global]]",
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);