pi-legion 2.0.4 → 3.0.0

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +71 -1
  2. package/CONTRIBUTING.md +27 -24
  3. package/README.md +103 -19
  4. package/docs/contracts/osc-busy-plugin.md +72 -0
  5. package/docs/contracts/spawn-default-inprocess-v3.md +322 -0
  6. package/docs/contracts/wait-not-stop.md +30 -0
  7. package/docs/design.md +2 -2
  8. package/docs/headless-child-contract.md +83 -0
  9. package/docs/inprocess-supervisor.md +140 -0
  10. package/docs/qa-hunting-round.md +150 -0
  11. package/docs/subagents-teams.md +55 -18
  12. package/docs/tui-qa-playbook.md +284 -0
  13. package/index.ts +315 -49
  14. package/package.json +15 -8
  15. package/skills/pi-legion/SKILL.md +5 -0
  16. package/src/agents/agents.ts +34 -6
  17. package/src/agents/context.ts +83 -1
  18. package/src/agents/overrides.ts +1 -0
  19. package/src/agents/settings.ts +122 -1
  20. package/src/api.ts +23 -0
  21. package/src/extension/child-guard.ts +42 -3
  22. package/src/extension/legion-commands.ts +9 -2
  23. package/src/extension/legion-doctor.ts +54 -0
  24. package/src/extension/legion-stop.ts +6 -2
  25. package/src/extension/playbook.ts +3 -3
  26. package/src/extension/runtime.ts +57 -8
  27. package/src/extension/scout-guard.ts +39 -1
  28. package/src/extension/session-errors.ts +191 -0
  29. package/src/runs/args.ts +8 -0
  30. package/src/runs/orchestrator.ts +125 -16
  31. package/src/shared/output-cap.ts +2 -2
  32. package/src/shared/session.ts +2 -1
  33. package/src/shared/types.ts +74 -0
  34. package/src/supervisor/child-context.ts +90 -0
  35. package/src/supervisor/child-memory.ts +27 -3
  36. package/src/supervisor/inprocess-depth.ts +71 -0
  37. package/src/supervisor/inprocess-supervisor.ts +1011 -0
  38. package/src/supervisor/routing-supervisor.ts +179 -0
  39. package/src/supervisor/rpc-supervisor.ts +14 -0
  40. package/src/supervisor/spawn-mode.ts +35 -0
  41. package/src/supervisor/types.ts +13 -0
  42. package/src/supervisor/ui-proxy.ts +78 -0
  43. package/src/tui/live-overlay.ts +147 -55
  44. package/src/tui/status.ts +71 -35
  45. package/src/tui/viz/model.ts +6 -0
  46. package/src/tui/viz/panels.ts +8 -9
  47. package/src/tui/viz/railway.ts +154 -21
  48. package/src/tui/viz/rate.ts +15 -14
package/index.ts CHANGED
@@ -20,6 +20,8 @@ import type {
20
20
  } from "@earendil-works/pi-coding-agent";
21
21
  import { Type, type Static } from "typebox";
22
22
  import { RpcSupervisor } from "./src/supervisor/rpc-supervisor.ts";
23
+ import { InProcessSupervisor } from "./src/supervisor/inprocess-supervisor.ts";
24
+ import { RoutingSupervisor } from "./src/supervisor/routing-supervisor.ts";
23
25
  import type { LegionSupervisor } from "./src/supervisor/types.ts";
24
26
  import { InMemoryUIProxy, type UIProxy } from "./src/supervisor/ui-proxy.ts";
25
27
  import { discoverAgents, findAgent, formatAgentRoster, BUILTIN_AGENTS_DIR } from "./src/agents/agents.ts";
@@ -42,6 +44,7 @@ import {
42
44
  loadSubagentSettings,
43
45
  resolveSubagentSettings,
44
46
  } from "./src/agents/settings.ts";
47
+ import { resolveTeamContext } from "./src/agents/context.ts";
45
48
  import { createSessionRuntime, renderLiveChildStatus, type SessionRuntime, shouldRecycleAfterCollect, type WaitResult } from "./src/extension/runtime.ts";
46
49
  import { createDbEventSource, createLegionEventBus, type DbEventSource, type LegionEventBus } from "./src/extension/event-bus.ts";
47
50
  import { LegionPostman, registerLegionTools, type LegionMailCommand } from "./src/extension/legion-tools.ts";
@@ -56,6 +59,7 @@ import { parseNestedPathEnv } from "./src/shared/nested-path.ts";
56
59
  import { allocateSiblingNames, isValidAgentName, makeName } from "./src/shared/name.ts";
57
60
  import { deriveOutcome, isLastTurnComplete, parseSessionFile } from "./src/shared/session.ts";
58
61
  import { restoreHostNodeOptions } from "./src/supervisor/child-memory.ts";
62
+ import { currentChildContext } from "./src/supervisor/child-context.ts";
59
63
  import { setVerifyQueueListener } from "./src/runs/verify-queue.ts";
60
64
  import { applyEventRetention, checkpoint, formatHygieneReport, hygieneEnabled, runStartupHygiene } from "./src/runs/hygiene.ts";
61
65
  import {
@@ -80,6 +84,7 @@ import {
80
84
  } from "./src/extension/notify.ts";
81
85
  import { renderSubagentNotice } from "./src/extension/notice-renderer.ts";
82
86
  import { PARENT_PLAYBOOK, TOOL_DESCRIPTION } from "./src/extension/playbook.ts";
87
+ import { configureSessionErrors, recordSessionError } from "./src/extension/session-errors.ts";
83
88
  import { getAgentDir } from "./src/agents/paths.ts";
84
89
  import { expectedModelsFromSettings, parseDoctorSections, runLegionDoctor } from "./src/extension/legion-doctor.ts";
85
90
  import { readFileSync } from "node:fs";
@@ -99,6 +104,7 @@ import {
99
104
  type ModelOrigin,
100
105
  type RetireOptions,
101
106
  SubagentError,
107
+ type SubagentsSettings,
102
108
  } from "./src/shared/types.ts";
103
109
 
104
110
  // ---------------------------------------------------------------------------
@@ -143,7 +149,7 @@ const SubagentParams = Type.Object({
143
149
  Type.Literal("stop"),
144
150
  Type.Literal("diagnose"),
145
151
  ],
146
- { description: "Defaults to launch. diagnose = Legion doctor self-check (extensions/models/spawn/memory/tools/storage/runtime); optional section filters like /legion doctor." },
152
+ { description: "Defaults to launch. wait REQUIRES name or all:true (mutually exclusive) — bare wait is rejected. diagnose = Legion doctor self-check (extensions/models/spawn/memory/tools/storage/runtime); optional section filters like /legion doctor." },
147
153
  ),
148
154
  ),
149
155
  agent: Type.Optional(
@@ -188,12 +194,12 @@ const SubagentParams = Type.Object({
188
194
  name: Type.Optional(
189
195
  Type.String({
190
196
  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 (wait/stop/collect/…): addresses that child. For status: omit (or pass all:true) for a live-children overview; pass name for one child.",
197
+ "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 wait: REQUIRED unless all:true (mutually exclusive with all). On other control actions (stop/collect/…): addresses that child. For status: omit (or pass all:true) for a live-children overview; pass name for one child.",
192
198
  }),
193
199
  ),
194
200
  all: Type.Optional(
195
201
  Type.Boolean({
196
- description: "wait: wait for active children; stop: stop every non-retired child; status: overview of all live (non-retired) children under cwd",
202
+ description: "wait: REQUIRED unless name is set — wait for every live (working/blocked) child; mutually exclusive with name. stop: stop every non-retired child; status: overview of all live (non-retired) children under cwd",
197
203
  }),
198
204
  ),
199
205
  timeoutMs: Type.Optional(
@@ -201,6 +207,12 @@ const SubagentParams = Type.Object({
201
207
  description: "wait: per-child timeout in ms; event-driven completion returns early (default: role turnTimeoutMs)",
202
208
  }),
203
209
  ),
210
+ force: Type.Optional(
211
+ Type.Boolean({
212
+ description:
213
+ 'stop only: when true, hard-retire via SIGTERM→SIGKILL + descendant sweep (retire hardening). Omit/false = graceful retire. Example: subagent({ action: "stop", name: "worker-0", force: true }).',
214
+ }),
215
+ ),
204
216
  message: Type.Optional(
205
217
  Type.String({ description: "Text for steer/continue" }),
206
218
  ),
@@ -210,7 +222,7 @@ const SubagentParams = Type.Object({
210
222
  section: Type.Optional(
211
223
  Type.String({
212
224
  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.',
225
+ 'diagnose only: optional section filter (summary|extensions|models|spawn|memory|tools|storage|runtime|errors|next), same as `/legion doctor [section]`. Space/comma-separated for multiple.',
214
226
  }),
215
227
  ),
216
228
  });
@@ -272,8 +284,8 @@ function settingsForLegion(settings: ReturnType<typeof loadSubagentSettings>): L
272
284
  return { ...DEFAULT_LEGION_SETTINGS, ...(settings.legion ?? {}), phaseTimeouts: { ...DEFAULT_LEGION_SETTINGS.phaseTimeouts, ...(settings.legion?.phaseTimeouts ?? {}) } };
273
285
  }
274
286
 
275
- function runStoreFor(cwd: string): RunStore {
276
- const sharedDbPath = process.env[LEGION_DB_ENV];
287
+ function runStoreFor(cwd: string, dbPathOverride?: string): RunStore {
288
+ const sharedDbPath = dbPathOverride ?? process.env[LEGION_DB_ENV];
277
289
  if (sharedDbPath && path.isAbsolute(sharedDbPath)) {
278
290
  const storeRoot = path.dirname(path.dirname(path.dirname(sharedDbPath)));
279
291
  return new RunStore({ rootDir: storeRoot });
@@ -350,9 +362,17 @@ function resolveLegionIdentity(input: {
350
362
  cached?: LegionRuntimeIdentity;
351
363
  /** This process's session file (identity credential). From ctx.sessionManager. */
352
364
  sessionFile?: string | undefined;
365
+ /**
366
+ * In-process child identity (#197 D8): the orchestrator names the ledger row
367
+ * this session owns. Wins over `process.env.PI_LEGION_DB/NODE_ID` because an
368
+ * in-process child shares the parent's env and would otherwise read the
369
+ * *parent's* identity — or nothing at all when the parent is root.
370
+ */
371
+ legionNode?: { dbPath: string; nodeId: string } | undefined;
353
372
  }): LegionRuntimeIdentity | undefined {
354
- const dbPath = process.env[LEGION_DB_ENV];
355
- const nodeId = process.env[LEGION_NODE_ID_ENV];
373
+ const injected = input.legionNode;
374
+ const dbPath = injected?.dbPath ?? process.env[LEGION_DB_ENV];
375
+ const nodeId = injected?.nodeId ?? process.env[LEGION_NODE_ID_ENV];
356
376
  if (Boolean(dbPath) !== Boolean(nodeId)) throw new Error("PI_LEGION_DB and PI_LEGION_NODE_ID must be set together");
357
377
  if (dbPath && nodeId) {
358
378
  if (!path.isAbsolute(dbPath)) throw new Error("PI_LEGION_DB must be an absolute path");
@@ -469,15 +489,26 @@ async function resumeLegionChild(input: { pi: ExtensionAPI; supervisor: LegionSu
469
489
  const childRun = store.readRun(input.node.runId);
470
490
  if (!childRun) throw new Error(`child run.json not found: ${input.node.runId}`);
471
491
  const parentOfSupervisor = parent.parentId ? getNode(input.db, parent.parentId) : null;
472
- const orchestrator = new Orchestrator({ supervisor: input.supervisor, runDir: store.runDir(parentRun.runId), cwd, maxDepth: catalog.settings.legion?.maxDepth, maxSpawns: catalog.settings.maxSubagentSpawnsPerSession ?? null, team: resumedTeam.team, childContext: catalog.settings.childContext, parentPath: parentRun.path, legion: { db: input.db, dbPath: input.dbPath, nodeId: parent.id, parentNodeId: parent.parentId, parentRunId: parentOfSupervisor?.runId ?? null, runId: parentRun.runId, runStore: store, settings: input.settings, eventBus: input.eventBus } });
492
+ const orchestrator = new Orchestrator({ supervisor: input.supervisor, runDir: store.runDir(parentRun.runId), cwd, maxDepth: catalog.settings.legion?.maxDepth, maxSpawns: catalog.settings.maxSubagentSpawnsPerSession ?? null, team: resumedTeam.team, childContext: resolveTeamContext("child", catalog.settings, resumedTeam.team), parentPath: parentRun.path, ...rpcApproveFrom(catalog.settings), legion: { db: input.db, dbPath: input.dbPath, nodeId: parent.id, parentNodeId: parent.parentId, parentRunId: parentOfSupervisor?.runId ?? null, runId: parentRun.runId, runStore: store, settings: input.settings, eventBus: input.eventBus } });
473
493
  orchestrator.restore(parentRun);
474
494
  const handle = await orchestrator.launch({ agent, task: input.message, name: saved.name, ...(input.node.team ? { team: input.node.team } : {}), ...(saved.model ? { model: saved.model } : {}), worktree: Boolean(saved.worktreePath) });
475
495
  await store.updateChild(parentRun.runId, saved.name, (child) => Object.assign(child, handle.child));
476
496
  }
477
497
 
478
498
  export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps = {}) {
479
- const isChild = process.env.PI_SUBAGENT_CHILD === "1";
480
- const allowNested = process.env[ALLOW_NESTED_ENV] === "1";
499
+ // #197 D8: a session is a child if either transport says so. RPC children
500
+ // are separate processes marked in env; in-process children share the
501
+ // parent's `process.env`, so their marker can only come from the
502
+ // AsyncLocalStorage child context the orchestrator injects.
503
+ //
504
+ // Security note: the ALS context does not survive `spawn()`, so a real
505
+ // subprocess (`bash npm test`, a nested CLI, …) cannot inherit this
506
+ // identity. That is exactly the isolation the env channel lacked — see the
507
+ // "healthy ghost nodes" incident documented at the identity credential
508
+ // check in `resolveLegionIdentity`. Do not replace this with env injection.
509
+ const childContext = currentChildContext();
510
+ const isChild = process.env.PI_SUBAGENT_CHILD === "1" || childContext !== undefined;
511
+ const allowNested = process.env[ALLOW_NESTED_ENV] === "1" || childContext?.allowNested === true;
481
512
  if (isChild) restoreHostNodeOptions();
482
513
  else {
483
514
  // Root UI: surface verify-queue waits instead of a silent stall.
@@ -488,10 +519,28 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
488
519
  } catch { /* no UI */ }
489
520
  });
490
521
  }
491
- const supervisor = deps.supervisor ?? new RpcSupervisor({
492
- onSpawnRefused: (message) => {
493
- try { lastUi?.notify(`pi-legion: ${message}`, "error"); } catch { /* no UI (child / print mode) */ }
494
- },
522
+ const onSpawnRefused = (message: string) => {
523
+ try { lastUi?.notify(`pi-legion: ${message}`, "error"); } catch { /* no UI (child / print mode) */ }
524
+ };
525
+ // Mutable host cwd so inprocess limits can re-read project settings as the
526
+ // extension follows the parent session's working directory (#197).
527
+ let lastCwd = process.cwd();
528
+ const supervisor = deps.supervisor ?? new RoutingSupervisor({
529
+ rpc: new RpcSupervisor({ onSpawnRefused }),
530
+ inprocess: new InProcessSupervisor({
531
+ onSpawnRefused,
532
+ getLimits: () => {
533
+ try {
534
+ return loadSubagentSettings({
535
+ userSettingsPath: path.join(getAgentDir(), "settings.json"),
536
+ projectSettingsPath: path.join(lastCwd, ".pi", "settings.json"),
537
+ }).inprocess;
538
+ } catch {
539
+ return undefined;
540
+ }
541
+ },
542
+ }),
543
+ onSpawnRefused,
495
544
  });
496
545
  const verifyRunner = deps.verifyRunner;
497
546
  const legionBus = createLegionEventBus();
@@ -516,7 +565,6 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
516
565
  return true;
517
566
  }
518
567
  };
519
- let lastCwd = process.cwd();
520
568
  let legionIdentity: LegionRuntimeIdentity | undefined;
521
569
  const stopLegionRuntime = (): void => {
522
570
  if (!legionIdentity) return;
@@ -528,13 +576,15 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
528
576
  legionIdentity.db.close();
529
577
  legionIdentity = undefined;
530
578
  };
531
- const initializeLegionRuntime = (ctx: Pick<ExtensionContext, "cwd"> & { sessionFile?: string }): void => {
532
- const store = runStoreFor(ctx.cwd);
579
+ const initializeLegionRuntime = (ctx: Pick<ExtensionContext, "cwd"> & { sessionFile?: string; legionNode?: { dbPath: string; nodeId: string } }): void => {
580
+ // The injected node's dbPath is authoritative: an in-process child shares the
581
+ // parent's env, so PI_LEGION_DB here belongs to the parent (or is unset).
582
+ const store = runStoreFor(ctx.cwd, ctx.legionNode?.dbPath);
533
583
  const settings = loadSubagentSettings({
534
584
  userSettingsPath: path.join(getAgentDir(), "settings.json"),
535
585
  projectSettingsPath: path.join(ctx.cwd, ".pi", "settings.json"),
536
586
  });
537
- const next = resolveLegionIdentity({ pi, supervisor, store, cwd: ctx.cwd, settings, eventBus: legionBus, cached: legionIdentity, sessionFile: ctx.sessionFile });
587
+ const next = resolveLegionIdentity({ pi, supervisor, store, cwd: ctx.cwd, settings, eventBus: legionBus, cached: legionIdentity, sessionFile: ctx.sessionFile, legionNode: ctx.legionNode });
538
588
  if (legionIdentity && next !== legionIdentity) stopLegionRuntime();
539
589
  legionIdentity = next;
540
590
  };
@@ -546,30 +596,80 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
546
596
  try { initializeLegionRuntime({ cwd: process.cwd() }); }
547
597
  catch (error) { console.error(`[pi-legion] legion initialization failed: ${error instanceof Error ? error.message : String(error)}`); }
548
598
  }
599
+ // #197 D8: an in-process child never sees `session_start` — the SDK emits it
600
+ // from `bindExtensions`, which only rpc / print / interactive modes call;
601
+ // `createAgentSession` does not. So the handler further down is dead for
602
+ // this transport and the injected identity is attached here instead.
603
+ //
604
+ // It is attached on the first ctx-bearing event rather than at extension
605
+ // load: the identity credential (this session's transcript file) only
606
+ // exists on `ctx.sessionManager`, and skipping it would defeat the
607
+ // ghost-node guard inside `resolveLegionIdentity`.
608
+ if (childContext?.nodeId && childContext.dbPath) {
609
+ const legionNode = { dbPath: childContext.dbPath, nodeId: childContext.nodeId };
610
+ let attached = false;
611
+ const attachInjectedChildIdentity = (_event: unknown, ctx?: { cwd?: string; sessionManager?: { getSessionFile?: () => string | undefined } }): void => {
612
+ if (attached) return;
613
+ attached = true;
614
+ try {
615
+ initializeLegionRuntime({
616
+ cwd: ctx?.cwd ?? process.cwd(),
617
+ legionNode,
618
+ sessionFile: ctx?.sessionManager?.getSessionFile?.(),
619
+ });
620
+ } catch (error) {
621
+ console.error(`[pi-legion] legion initialization failed: ${error instanceof Error ? error.message : String(error)}`);
622
+ }
623
+ };
624
+ pi.on("before_agent_start", attachInjectedChildIdentity);
625
+ }
549
626
  }
627
+ // #37/#38/#40/#181: one best-effort storage sweep per root process.
628
+ // Deferred 1.5s so interactive first paint is not blocked; unref so a long
629
+ // idle session can exit. Short --print sessions exit before the timer —
630
+ // flushHygieneIfPending on session_shutdown still runs the sweep once.
550
631
  let hygieneScheduled = false;
632
+ let hygieneCompleted = false;
633
+ let pendingHygiene: { storeRoot: string; timer?: ReturnType<typeof setTimeout>; notify?: (text: string) => void } | undefined;
634
+ const flushHygieneIfPending = (): void => {
635
+ if (hygieneCompleted || !pendingHygiene || !hygieneEnabled()) return;
636
+ hygieneCompleted = true;
637
+ if (pendingHygiene.timer) {
638
+ clearTimeout(pendingHygiene.timer);
639
+ pendingHygiene.timer = undefined;
640
+ }
641
+ const { storeRoot, notify } = pendingHygiene;
642
+ try {
643
+ const report = runStartupHygiene({ storeRoot, protectRunIds: legionIdentity ? [legionIdentity.rootRunId] : [] });
644
+ const text = formatHygieneReport(report);
645
+ if (text) notify?.(text);
646
+ } catch (error) {
647
+ console.error(`[pi-legion] storage sweep failed: ${error instanceof Error ? error.message : String(error)}`);
648
+ }
649
+ };
551
650
  pi.on("session_start", (_event, ctx) => {
552
651
  try { initializeLegionRuntime({ cwd: ctx.cwd, sessionFile: ctx.sessionManager?.getSessionFile() }); }
553
652
  catch (error) { console.error(`[pi-legion] legion initialization failed: ${error instanceof Error ? error.message : String(error)}`); }
554
- // #37/#38/#40: one deferred, best-effort storage sweep per root process.
555
653
  if (!isChild && !hygieneScheduled && hygieneEnabled()) {
556
654
  hygieneScheduled = true;
557
655
  const storeRoot = runStoreFor(ctx.cwd).rootDir;
558
656
  const timer = setTimeout(() => {
559
- try {
560
- const report = runStartupHygiene({ storeRoot, protectRunIds: legionIdentity ? [legionIdentity.rootRunId] : [] });
561
- const text = formatHygieneReport(report);
562
- if (text) ctx.ui?.notify?.(text, "info");
563
- } catch (error) {
564
- console.error(`[pi-legion] storage sweep failed: ${error instanceof Error ? error.message : String(error)}`);
565
- }
657
+ flushHygieneIfPending();
566
658
  }, 1_500);
567
659
  timer.unref?.();
660
+ pendingHygiene = {
661
+ storeRoot,
662
+ timer,
663
+ notify: (text) => ctx.ui?.notify?.(text, "info"),
664
+ };
568
665
  }
569
666
  });
570
667
  const pendingUtilityRetires: Array<(opts?: RetireOptions) => Promise<void>> = [];
571
668
  let sessionRuntime: SessionRuntime | undefined;
572
669
  const shutdownRetireAll = async (_event?: unknown, ctx?: { mode?: string }): Promise<void> => {
670
+ // #181: short --print exits before the deferred timer; flush now so
671
+ // orphan rpc-child-* dirs still get swept on a clean exit.
672
+ if (!isChild) flushHygieneIfPending();
573
673
  // pi --print / JSON exits when the parent turn ends. Async children
574
674
  // have only just been spawned; retiring them here aborts the run
575
675
  // before the first model call. Interactive quit still retires immediately.
@@ -636,7 +736,11 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
636
736
  if (Array.isArray(tools) && !tools.includes("subagent") && !tools.includes("vexillatio")) return;
637
737
  if (ctx?.cwd) lastCwd = ctx.cwd;
638
738
  const catalog = loadCatalog(lastCwd, lastCwd, undefined);
639
- const parentContext = catalog.settings.parentContext?.trim();
739
+ const parentContext = resolveTeamContext(
740
+ "parent",
741
+ catalog.settings,
742
+ catalog.team,
743
+ );
640
744
  return {
641
745
  systemPrompt: `${event.systemPrompt}\n\n${PARENT_PLAYBOOK}\n\n${renderParentRoster(
642
746
  catalog.agents,
@@ -650,6 +754,10 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
650
754
  return;
651
755
  }
652
756
  let lastUi: ExtensionUIContext | undefined;
757
+ /** Child slugs currently reported as OSC id=legion/<slug>. */
758
+ const childOscIds = new Set<string>();
759
+ /** Extension activity sources forwarded from children via RPC. */
760
+ const childActivitySources = new Set<string>();
653
761
  let statusNodeOpener: ((nodeId: string) => void) | undefined;
654
762
  let lastConfirm: ((message: string) => Promise<boolean>) | undefined;
655
763
  // turn_start/turn_end maintain this so a flush window can wait out a
@@ -686,9 +794,41 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
686
794
  },
687
795
  options,
688
796
  ) ?? (() => {}),
689
- emitBusy: (active, label) => {
797
+ emitBusy: (active, label, children = []) => {
690
798
  try { pi.events.emit("pi-legion:busy", active ? { active: true, label } : { active: false }); }
691
799
  catch { /* optional display integration */ }
800
+ const ui = lastUi as (ExtensionUIContext & {
801
+ setActivity?: (source: string, activity: { message?: string } | undefined) => void;
802
+ setProgramRecord?: (status: { state: "working" | "clear"; id: string; message?: string }) => void;
803
+ }) | undefined;
804
+ const next = new Set(active ? children : []);
805
+ // Drop RPC-fan-in activities for retired children (and everything on full clear).
806
+ if (typeof ui?.setActivity === "function") {
807
+ for (const source of [...childActivitySources]) {
808
+ const slug = source.startsWith("legion/") ? source.slice("legion/".length).split("/")[0] : undefined;
809
+ if (!active || !slug || !next.has(slug)) {
810
+ try { ui.setActivity(source, undefined); } catch { /* ignore */ }
811
+ childActivitySources.delete(source);
812
+ }
813
+ }
814
+ try {
815
+ ui.setActivity("legion", active ? { message: label } : undefined);
816
+ } catch { /* setActivity is optional until Pi ships it */ }
817
+ }
818
+ if (typeof ui?.setProgramRecord === "function") {
819
+ for (const slug of [...childOscIds]) {
820
+ if (!next.has(slug)) {
821
+ try { ui.setProgramRecord({ id: `legion/${slug}`, state: "clear" }); } catch { /* ignore */ }
822
+ childOscIds.delete(slug);
823
+ }
824
+ }
825
+ for (const slug of next) {
826
+ try {
827
+ ui.setProgramRecord({ id: `legion/${slug}`, state: "working", message: slug });
828
+ childOscIds.add(slug);
829
+ } catch { /* ignore */ }
830
+ }
831
+ }
692
832
  },
693
833
  });
694
834
  sessionRuntime = runtime;
@@ -723,6 +863,8 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
723
863
  userSettingsPath: path.join(getAgentDir(), "settings.json"),
724
864
  projectSettingsPath: path.join(cwd, ".pi", "settings.json"),
725
865
  }),
866
+ // #182: suppress "拆 scout" nag while launches are forbidden.
867
+ subagentsEnabled: (cwd) => subagentsEnabled(cwd),
726
868
  });
727
869
  }
728
870
  // The parent transcript records every finish; the renderer keeps a
@@ -731,12 +873,17 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
731
873
 
732
874
  // User stop controls (/legion stop, idle Esc, `x` in /legion): a thin wrapper
733
875
  // over the subagent tool's `stop` action — graceful retire of tracked children.
734
- const stopChildren = async (params: { name?: string; all?: boolean }): Promise<{ ok: boolean; text: string }> => {
876
+ // Nova follow-up: `/legion stop --force` can pass `force: true` here (#179).
877
+ const stopChildren = async (params: {
878
+ name?: string;
879
+ all?: boolean;
880
+ force?: boolean;
881
+ }): Promise<{ ok: boolean; text: string }> => {
735
882
  const result = await stopAction({
736
883
  params,
737
884
  runtime,
738
- retire: async (name) => {
739
- await runtime.get(name)?.retire?.();
885
+ retire: async (name, opts) => {
886
+ await runtime.get(name)?.retire?.(opts);
740
887
  },
741
888
  });
742
889
  const text = result.content.map((part) => (part.type === "text" ? part.text : "")).join("\n");
@@ -837,6 +984,7 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
837
984
  }
838
985
  const cwd = params.cwd ?? ctx.cwd;
839
986
  const store = runStoreFor(cwd);
987
+ configureSessionErrors({ storeRoot: store.rootDir, tool: "subagent", action });
840
988
  const catalog = loadCatalog(ctx.cwd, cwd, params.agentScope);
841
989
  const { allAgents, settings } = catalog;
842
990
  const teamName = action === "launch" ? params.team : undefined;
@@ -990,6 +1138,36 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
990
1138
  else if (request.method === "setWidget") ui.setWidget(request.widgetKey, request.widgetLines, { placement: request.widgetPlacement });
991
1139
  else if (request.method === "setTitle") ui.setTitle(request.title);
992
1140
  else if (request.method === "set_editor_text") ui.setEditorText(request.text);
1141
+ else if (request.method === "setActivity") {
1142
+ const uiExt = ui as ExtensionUIContext & {
1143
+ setActivity?: (source: string, activity: { message?: string } | undefined) => void;
1144
+ };
1145
+ if (typeof uiExt.setActivity !== "function") return;
1146
+ const source = `legion/${pending.childName}/${request.source}`;
1147
+ if (request.activity) {
1148
+ childActivitySources.add(source);
1149
+ uiExt.setActivity(source, request.activity);
1150
+ } else {
1151
+ childActivitySources.delete(source);
1152
+ uiExt.setActivity(source, undefined);
1153
+ }
1154
+ } else if (request.method === "setProgramRecord") {
1155
+ const record = request.record;
1156
+ const uiExt = ui as ExtensionUIContext & {
1157
+ setProgramRecord?: (status: {
1158
+ state: "idle" | "working" | "blocked" | "done" | "error" | "clear";
1159
+ id: string;
1160
+ app?: string;
1161
+ kind?: "permission" | "question" | "auth";
1162
+ message?: string;
1163
+ }) => void;
1164
+ };
1165
+ if (typeof uiExt.setProgramRecord !== "function") return;
1166
+ const id = record.id.startsWith("legion/")
1167
+ ? record.id
1168
+ : `legion/${pending.childName}/${record.id}`;
1169
+ uiExt.setProgramRecord({ ...record, id });
1170
+ }
993
1171
  });
994
1172
 
995
1173
  pi.on("session_start", (event, ctx) => {
@@ -1118,7 +1296,7 @@ async function controlAction(input: {
1118
1296
  }
1119
1297
  if (!params.name && !params.all) {
1120
1298
  return fail(
1121
- "`name` or `all` is required for wait",
1299
+ "`name` or `all` is required for wait (try all:true or name:\"child-name\")",
1122
1300
  ErrorCodes.INVALID_PARAMS,
1123
1301
  );
1124
1302
  }
@@ -1134,13 +1312,16 @@ async function controlAction(input: {
1134
1312
  }
1135
1313
 
1136
1314
  if (action === "stop") {
1315
+ const force = params.force === true;
1316
+ const retireOpts = force ? { graceful: false as const } : undefined;
1137
1317
  return stopAction({
1138
1318
  params,
1139
1319
  runtime: input.runtime,
1140
- retire: async (name) => {
1320
+ retire: async (name, opts) => {
1321
+ const merged = opts ?? retireOpts;
1141
1322
  const job = input.runtime.get(name);
1142
1323
  if (job?.retire) {
1143
- await job.retire();
1324
+ await job.retire(merged);
1144
1325
  return;
1145
1326
  }
1146
1327
  // A completion watch can retire/release a target after stop snapshots
@@ -1155,13 +1336,14 @@ async function controlAction(input: {
1155
1336
  runDir: input.store.runDir(found.runId),
1156
1337
  cwd: input.cwd,
1157
1338
  maxDepth: input.settings.legion?.maxDepth,
1158
- childContext: input.settings.childContext,
1339
+ childContext: resolveTeamContext("child", input.settings, input.team),
1159
1340
  ...teamForChildren(input.team),
1341
+ ...rpcApproveFrom(input.settings),
1160
1342
  ...(input.legion ? { legion: { ...input.legion, runId: found.runId, runStore: input.store } } : {}),
1161
1343
  onBudgetRefused: input.onBudgetRefused,
1162
1344
  });
1163
1345
  orchestrator.restore(found.run);
1164
- await orchestrator.retire(name);
1346
+ await orchestrator.retire(name, merged);
1165
1347
  await persistChild(input.store, found.runId, orchestrator, name);
1166
1348
  },
1167
1349
  });
@@ -1216,11 +1398,12 @@ async function controlAction(input: {
1216
1398
  runDir: store.runDir(found.runId),
1217
1399
  cwd,
1218
1400
  maxDepth: input.settings.legion?.maxDepth,
1219
- childContext: input.settings.childContext,
1401
+ childContext: resolveTeamContext("child", input.settings, input.team),
1220
1402
  verifyRunner: input.verifyRunner,
1221
1403
  worktreeBootstrap: input.settings.legion?.worktreeBootstrap,
1222
1404
  worktreeBootstrapTimeoutMs: input.settings.legion?.worktreeBootstrapTimeoutMs,
1223
1405
  ...teamForChildren(input.team),
1406
+ ...rpcApproveFrom(input.settings),
1224
1407
  ...(input.legion ? { legion: { ...input.legion, runId: found.runId, runStore: store } } : {}),
1225
1408
  onBudgetRefused: input.onBudgetRefused,
1226
1409
  });
@@ -1500,11 +1683,12 @@ async function launchFamily(input: {
1500
1683
  cwd,
1501
1684
  maxDepth: settings.legion?.maxDepth,
1502
1685
  maxSpawns: settings.maxSubagentSpawnsPerSession ?? null,
1503
- childContext: settings.childContext,
1686
+ childContext: resolveTeamContext("child", settings, input.team),
1504
1687
  verifyRunner: input.verifyRunner,
1505
1688
  worktreeBootstrap: settings.legion?.worktreeBootstrap,
1506
1689
  worktreeBootstrapTimeoutMs: settings.legion?.worktreeBootstrapTimeoutMs,
1507
1690
  ...teamForChildren(input.team),
1691
+ ...rpcApproveFrom(settings),
1508
1692
  ...(legion ? { legion: { db: legion.db, dbPath: legion.dbPath, nodeId: legion.nodeId, runId: run.runId, parentNodeId: legion.parentNodeId ?? undefined, parentRunId: legion.parentRunId ?? undefined, runStore: store, settings: legion.settings, eventBus: legion.eventBus } } : {}),
1509
1693
  onBudgetRefused: input.onBudgetRefused,
1510
1694
  });
@@ -2071,8 +2255,9 @@ async function launchUtilityFamily(input: {
2071
2255
  cwd,
2072
2256
  maxDepth: catalog.settings.legion?.maxDepth,
2073
2257
  maxSpawns: catalog.settings.maxSubagentSpawnsPerSession ?? null,
2074
- childContext: catalog.settings.childContext,
2258
+ childContext: resolveTeamContext("child", catalog.settings, catalog.team),
2075
2259
  team: catalog.team,
2260
+ ...rpcApproveFrom(catalog.settings),
2076
2261
  onBudgetRefused: input.onBudgetRefused,
2077
2262
  ...(legion ? {
2078
2263
  legion: {
@@ -2597,7 +2782,7 @@ export function resolveWaitTargets(
2597
2782
  return { ok: true, names: live.map((job) => job.name) };
2598
2783
  }
2599
2784
  const name = params.name;
2600
- if (!name) return { ok: false, message: "`name` or `all` is required for wait" };
2785
+ if (!name) return { ok: false, message: "`name` or `all` is required for wait (try all:true or name:\"child-name\")" };
2601
2786
  if (live.some((job) => job.name === name)) {
2602
2787
  return { ok: true, names: [name] };
2603
2788
  }
@@ -2607,6 +2792,63 @@ export function resolveWaitTargets(
2607
2792
  };
2608
2793
  }
2609
2794
 
2795
+ /** Parent-side decision hints when wait leaves children live (#179). */
2796
+ export const WAIT_PARENT_DECISIONS = [
2797
+ "extend",
2798
+ "steer_again",
2799
+ "stop",
2800
+ "force_stop",
2801
+ ] as const;
2802
+
2803
+ export type WaitParentDecision = (typeof WAIT_PARENT_DECISIONS)[number];
2804
+
2805
+ export const WAIT_TIMEOUT_HINT =
2806
+ "wait does not stop children; choose stop/force_stop or extend/steer";
2807
+
2808
+ export interface WaitStillRunningChild {
2809
+ name: string;
2810
+ elapsedMs: number;
2811
+ budgetMs?: number;
2812
+ }
2813
+
2814
+ /** Structured wait result when children are still live after wait (#179). */
2815
+ export interface WaitTimeoutDetails {
2816
+ status: "wait_timeout" | "budget_exceeded" | "still_running";
2817
+ done: number;
2818
+ stillRunning: WaitStillRunningChild[];
2819
+ decisions: WaitParentDecision[];
2820
+ hint: string;
2821
+ }
2822
+
2823
+ /** Build parent-decision payload when wait leaves children live. Does not stop them. */
2824
+ export function buildWaitTimeoutDetails(
2825
+ results: WaitResult[],
2826
+ ): WaitTimeoutDetails | undefined {
2827
+ const stillRunning: WaitStillRunningChild[] = [];
2828
+ let timedOut = false;
2829
+ let done = 0;
2830
+ for (const result of results) {
2831
+ if (result.snapshot) {
2832
+ done += 1;
2833
+ continue;
2834
+ }
2835
+ if (!result.stillRunning) continue;
2836
+ if (result.timedOut) timedOut = true;
2837
+ stillRunning.push({
2838
+ name: result.name,
2839
+ elapsedMs: result.elapsedMs ?? 0,
2840
+ });
2841
+ }
2842
+ if (stillRunning.length === 0) return undefined;
2843
+ return {
2844
+ status: timedOut ? "wait_timeout" : "still_running",
2845
+ done,
2846
+ stillRunning,
2847
+ decisions: [...WAIT_PARENT_DECISIONS],
2848
+ hint: WAIT_TIMEOUT_HINT,
2849
+ };
2850
+ }
2851
+
2610
2852
  /** Render one wait result, reusing the collect shape for finished children. */
2611
2853
  export function renderWait(results: WaitResult[]): string {
2612
2854
  let done = 0;
@@ -2619,7 +2861,9 @@ export function renderWait(results: WaitResult[]): string {
2619
2861
  parts.push(renderCollect(result.name, result.snapshot));
2620
2862
  } else if (result.stillRunning) {
2621
2863
  running += 1;
2622
- parts.push(`── ${result.name} ──\nstill running`);
2864
+ const elapsed =
2865
+ result.elapsedMs !== undefined ? ` (elapsed ${result.elapsedMs}ms)` : "";
2866
+ parts.push(`── ${result.name} ──\nstill running${elapsed}`);
2623
2867
  } else if (result.interrupted) {
2624
2868
  interrupted += 1;
2625
2869
  parts.push(`── ${result.name} ──\ninterrupted (parent turn ended; children still running)`);
@@ -2629,14 +2873,23 @@ export function renderWait(results: WaitResult[]): string {
2629
2873
  }
2630
2874
  parts.push(`${done} done, ${running} still running`);
2631
2875
  if (interrupted > 0) parts.push(`${interrupted} interrupted`);
2876
+ const details = buildWaitTimeoutDetails(results);
2877
+ if (details) {
2878
+ parts.push(`status: ${details.status}`);
2879
+ parts.push(`decisions: ${details.decisions.join(", ")}`);
2880
+ parts.push(`hint: ${details.hint}`);
2881
+ }
2632
2882
  return parts.join("\n");
2633
2883
  }
2634
2884
 
2635
- /** Gracefully retire a named child or every child tracked by this session. */
2885
+ /**
2886
+ * Retire a named child or every child tracked by this session.
2887
+ * `force: true` → hard path (graceful:false / SIGTERM→SIGKILL); omit/false → graceful.
2888
+ */
2636
2889
  export async function stopAction(input: {
2637
- params: { name?: string; all?: boolean };
2890
+ params: { name?: string; all?: boolean; force?: boolean };
2638
2891
  runtime: SessionRuntime;
2639
- retire: (name: string) => Promise<void>;
2892
+ retire: (name: string, opts?: RetireOptions) => Promise<void>;
2640
2893
  }): Promise<AgentToolResult<unknown>> {
2641
2894
  const { params, runtime, retire } = input;
2642
2895
  if (params.name && params.all) {
@@ -2645,6 +2898,8 @@ export async function stopAction(input: {
2645
2898
  if (!params.name && !params.all) {
2646
2899
  return fail("`name` or `all` is required for stop", ErrorCodes.INVALID_PARAMS);
2647
2900
  }
2901
+ const force = params.force === true;
2902
+ const retireOpts: RetireOptions | undefined = force ? { graceful: false } : undefined;
2648
2903
  const jobs = runtime.activeJobs();
2649
2904
  const active = jobs.filter((job) => job.state !== "retired");
2650
2905
  let targets: string[];
@@ -2669,18 +2924,19 @@ export async function stopAction(input: {
2669
2924
  try {
2670
2925
  // The runtime job may have been released after the target snapshot was
2671
2926
  // taken. The retire adapter can then resolve its persisted run record.
2672
- await retire(name);
2927
+ await retire(name, retireOpts);
2673
2928
  runtime.release(name);
2674
2929
  stopped.push(name);
2675
2930
  } catch (error) {
2676
- // If the graceful-retire chain fails, restore the completion watcher so a
2931
+ // If the retire chain fails, restore the completion watcher so a
2677
2932
  // still-live child is not orphaned by the explicit stop attempt.
2678
2933
  if (runtime.get(name)) runtime.rewatch(name);
2679
2934
  failures.push(`${name}: ${String(error)}`);
2680
2935
  }
2681
2936
  }
2937
+ const mode = force ? "force-stopped" : "Stopped";
2682
2938
  const summary = stopped.length
2683
- ? `Stopped ${stopped.length} child${stopped.length === 1 ? "" : "ren"}: ${stopped.join(", ")}.`
2939
+ ? `${mode} ${stopped.length} child${stopped.length === 1 ? "" : "ren"}: ${stopped.join(", ")}.`
2684
2940
  : "No children were stopped.";
2685
2941
  return failures.length
2686
2942
  ? fail(`${summary}\nFailed to stop: ${failures.join("; ")}`, ErrorCodes.RETIRE_FAILED)
@@ -2732,7 +2988,11 @@ export async function waitAction(input: {
2732
2988
  }),
2733
2989
  );
2734
2990
  const results = await runtime.wait(targets.names, { timeoutMs, ...(signal ? { signal } : {}) });
2735
- return ok(renderWait(results));
2991
+ const text = renderWait(results);
2992
+ const details = buildWaitTimeoutDetails(results);
2993
+ return details
2994
+ ? { content: [{ type: "text", text }], details }
2995
+ : ok(text);
2736
2996
  }
2737
2997
 
2738
2998
  export function renderAgentList(
@@ -2789,6 +3049,11 @@ function teamForChildren(team: ActiveTeam): { team?: ActiveTeam } {
2789
3049
  return team.name !== DEFAULT_TEAM || team.source === "env" ? { team } : {};
2790
3050
  }
2791
3051
 
3052
+ /** Opt-in `--approve` for RPC children (`subagents.rpc.approve`, #193). */
3053
+ function rpcApproveFrom(settings: Pick<SubagentsSettings, "rpc">): { rpcApprove?: boolean } {
3054
+ return settings.rpc?.approve === true ? { rpcApprove: true } : {};
3055
+ }
3056
+
2792
3057
  /** Prefer a role under the active team, then fall back for existing children. */
2793
3058
  function findControlAgent(
2794
3059
  agents: AgentConfig[],
@@ -2911,5 +3176,6 @@ function ok(text: string): AgentToolResult<unknown> {
2911
3176
  * `details` and the message text carries the failure signal.
2912
3177
  */
2913
3178
  function fail(text: string, code: string): AgentToolResult<unknown> {
3179
+ recordSessionError({ code, message: text });
2914
3180
  return { content: [{ type: "text", text }], details: { error: code } };
2915
3181
  }