pi-legion 2.0.3 → 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 (52) hide show
  1. package/CHANGELOG.md +93 -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 +396 -62
  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 +15 -3
  23. package/src/extension/legion-doctor.ts +55 -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/extension/timing.ts +391 -0
  30. package/src/runs/args.ts +8 -0
  31. package/src/runs/orchestrator.ts +125 -16
  32. package/src/shared/output-cap.ts +2 -2
  33. package/src/shared/session.ts +2 -1
  34. package/src/shared/timing-breakdown.ts +23 -0
  35. package/src/shared/types.ts +74 -0
  36. package/src/supervisor/child-context.ts +90 -0
  37. package/src/supervisor/child-memory.ts +27 -3
  38. package/src/supervisor/inprocess-depth.ts +71 -0
  39. package/src/supervisor/inprocess-supervisor.ts +1011 -0
  40. package/src/supervisor/routing-supervisor.ts +179 -0
  41. package/src/supervisor/rpc-supervisor.ts +14 -0
  42. package/src/supervisor/spawn-mode.ts +35 -0
  43. package/src/supervisor/types.ts +13 -0
  44. package/src/supervisor/ui-proxy.ts +78 -0
  45. package/src/tui/live-overlay.ts +297 -24
  46. package/src/tui/status.ts +71 -35
  47. package/src/tui/viz/layout.ts +50 -6
  48. package/src/tui/viz/model.ts +6 -0
  49. package/src/tui/viz/panels.ts +164 -12
  50. package/src/tui/viz/railway.ts +154 -21
  51. package/src/tui/viz/rate.ts +36 -14
  52. package/src/tui/viz/view.ts +69 -10
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 (status/wait/stop/…): addresses that 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",
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,18 +1336,52 @@ 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
  });
1168
1350
  }
1169
1351
 
1352
+ // status: no name (or all:true) → live-children overview; named → one child.
1353
+ if (action === "status") {
1354
+ if (params.name && params.all) {
1355
+ return fail(
1356
+ "`name` and `all` are mutually exclusive for status.",
1357
+ ErrorCodes.INVALID_PARAMS,
1358
+ );
1359
+ }
1360
+ if (!params.name) {
1361
+ const live = listLiveChildrenForStatus(store.listRuns());
1362
+ return ok(
1363
+ renderStatusOverview(
1364
+ live.map((child) => ({
1365
+ child,
1366
+ alive: input.supervisor.isAlive(child.name),
1367
+ })),
1368
+ ),
1369
+ );
1370
+ }
1371
+ const found = findChild(store.listRuns(), params.name);
1372
+ if (!found) return unknownChild(params.name, store, cwd);
1373
+ return ok(renderLiveChildStatus({
1374
+ name: found.child.name,
1375
+ state: found.child.state,
1376
+ agent: found.child.agent,
1377
+ kind: found.child.kind,
1378
+ sessionFile: found.child.sessionFile,
1379
+ execution: found.child.execution,
1380
+ acceptance: found.child.acceptance,
1381
+ alive: input.supervisor.isAlive(params.name),
1382
+ }));
1383
+ }
1384
+
1170
1385
  if (!params.name) {
1171
1386
  return fail(
1172
1387
  `\`name\` is required for the "${action}" action.`,
@@ -1183,11 +1398,12 @@ async function controlAction(input: {
1183
1398
  runDir: store.runDir(found.runId),
1184
1399
  cwd,
1185
1400
  maxDepth: input.settings.legion?.maxDepth,
1186
- childContext: input.settings.childContext,
1401
+ childContext: resolveTeamContext("child", input.settings, input.team),
1187
1402
  verifyRunner: input.verifyRunner,
1188
1403
  worktreeBootstrap: input.settings.legion?.worktreeBootstrap,
1189
1404
  worktreeBootstrapTimeoutMs: input.settings.legion?.worktreeBootstrapTimeoutMs,
1190
1405
  ...teamForChildren(input.team),
1406
+ ...rpcApproveFrom(input.settings),
1191
1407
  ...(input.legion ? { legion: { ...input.legion, runId: found.runId, runStore: store } } : {}),
1192
1408
  onBudgetRefused: input.onBudgetRefused,
1193
1409
  });
@@ -1200,18 +1416,6 @@ async function controlAction(input: {
1200
1416
  orchestrator,
1201
1417
  };
1202
1418
 
1203
- if (action === "status") {
1204
- return ok(renderLiveChildStatus({
1205
- name: found.child.name,
1206
- state: found.child.state,
1207
- agent: found.child.agent,
1208
- kind: found.child.kind,
1209
- sessionFile: found.child.sessionFile,
1210
- execution: found.child.execution,
1211
- acceptance: found.child.acceptance,
1212
- alive: input.supervisor.isAlive(params.name),
1213
- }));
1214
- }
1215
1419
  if (action === "steer") return steerChild(ctx);
1216
1420
  if (action === "collect") return collectChild(ctx, input.signal);
1217
1421
  if (action === "retire") return retireChild(ctx);
@@ -1224,7 +1428,7 @@ async function controlAction(input: {
1224
1428
  interface ChildContext {
1225
1429
  action: string;
1226
1430
  params: SubagentParams;
1227
- /** Always set: `controlAction` rejects a request without a name. */
1431
+ /** Always set: named control actions reject a request without a name. */
1228
1432
  name: string;
1229
1433
  supervisor: LegionSupervisor;
1230
1434
  uiProxy: UIProxy;
@@ -1479,11 +1683,12 @@ async function launchFamily(input: {
1479
1683
  cwd,
1480
1684
  maxDepth: settings.legion?.maxDepth,
1481
1685
  maxSpawns: settings.maxSubagentSpawnsPerSession ?? null,
1482
- childContext: settings.childContext,
1686
+ childContext: resolveTeamContext("child", settings, input.team),
1483
1687
  verifyRunner: input.verifyRunner,
1484
1688
  worktreeBootstrap: settings.legion?.worktreeBootstrap,
1485
1689
  worktreeBootstrapTimeoutMs: settings.legion?.worktreeBootstrapTimeoutMs,
1486
1690
  ...teamForChildren(input.team),
1691
+ ...rpcApproveFrom(settings),
1487
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 } } : {}),
1488
1693
  onBudgetRefused: input.onBudgetRefused,
1489
1694
  });
@@ -2050,8 +2255,9 @@ async function launchUtilityFamily(input: {
2050
2255
  cwd,
2051
2256
  maxDepth: catalog.settings.legion?.maxDepth,
2052
2257
  maxSpawns: catalog.settings.maxSubagentSpawnsPerSession ?? null,
2053
- childContext: catalog.settings.childContext,
2258
+ childContext: resolveTeamContext("child", catalog.settings, catalog.team),
2054
2259
  team: catalog.team,
2260
+ ...rpcApproveFrom(catalog.settings),
2055
2261
  onBudgetRefused: input.onBudgetRefused,
2056
2262
  ...(legion ? {
2057
2263
  legion: {
@@ -2508,6 +2714,53 @@ async function abandonLaunchedChild(session: LaunchSession, name: string): Promi
2508
2714
  // wait (§1.5): block for results instead of relying on async notices
2509
2715
  // ---------------------------------------------------------------------------
2510
2716
 
2717
+ /**
2718
+ * Live (non-retired / non-exited) children under this cwd for status overview.
2719
+ *
2720
+ * Names can be reused after retire; prefer the newest live record per name
2721
+ * (same preference as {@link pickChildByName}).
2722
+ */
2723
+ export function listLiveChildrenForStatus(
2724
+ runs: ReturnType<RunStore["listRuns"]>,
2725
+ ): NonNullable<ReturnType<RunStore["findChild"]>>[] {
2726
+ const ranked = [...runs].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
2727
+ const seen = new Set<string>();
2728
+ const out: NonNullable<ReturnType<RunStore["findChild"]>>[] = [];
2729
+ for (const run of ranked) {
2730
+ for (const child of run.children) {
2731
+ if (child.state === "retired" || child.state === "exited") continue;
2732
+ if (seen.has(child.name)) continue;
2733
+ seen.add(child.name);
2734
+ out.push(child);
2735
+ }
2736
+ }
2737
+ return out;
2738
+ }
2739
+
2740
+ /** Multi-child status text: one {@link renderLiveChildStatus} block per live child. */
2741
+ export function renderStatusOverview(
2742
+ children: Array<{
2743
+ child: NonNullable<ReturnType<RunStore["findChild"]>>;
2744
+ alive: boolean;
2745
+ }>,
2746
+ ): string {
2747
+ if (children.length === 0) return "No live children.";
2748
+ const noun = children.length === 1 ? "child" : "children";
2749
+ const blocks = children.map(({ child, alive }) =>
2750
+ renderLiveChildStatus({
2751
+ name: child.name,
2752
+ state: child.state,
2753
+ agent: child.agent,
2754
+ kind: child.kind,
2755
+ sessionFile: child.sessionFile,
2756
+ execution: child.execution,
2757
+ acceptance: child.acceptance,
2758
+ alive,
2759
+ }),
2760
+ );
2761
+ return [`Status · ${children.length} live ${noun}`, "", blocks.join("\n\n")].join("\n");
2762
+ }
2763
+
2511
2764
  /**
2512
2765
  * Resolve which children a wait targets.
2513
2766
  *
@@ -2529,7 +2782,7 @@ export function resolveWaitTargets(
2529
2782
  return { ok: true, names: live.map((job) => job.name) };
2530
2783
  }
2531
2784
  const name = params.name;
2532
- 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\")" };
2533
2786
  if (live.some((job) => job.name === name)) {
2534
2787
  return { ok: true, names: [name] };
2535
2788
  }
@@ -2539,6 +2792,63 @@ export function resolveWaitTargets(
2539
2792
  };
2540
2793
  }
2541
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
+
2542
2852
  /** Render one wait result, reusing the collect shape for finished children. */
2543
2853
  export function renderWait(results: WaitResult[]): string {
2544
2854
  let done = 0;
@@ -2551,7 +2861,9 @@ export function renderWait(results: WaitResult[]): string {
2551
2861
  parts.push(renderCollect(result.name, result.snapshot));
2552
2862
  } else if (result.stillRunning) {
2553
2863
  running += 1;
2554
- 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}`);
2555
2867
  } else if (result.interrupted) {
2556
2868
  interrupted += 1;
2557
2869
  parts.push(`── ${result.name} ──\ninterrupted (parent turn ended; children still running)`);
@@ -2561,14 +2873,23 @@ export function renderWait(results: WaitResult[]): string {
2561
2873
  }
2562
2874
  parts.push(`${done} done, ${running} still running`);
2563
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
+ }
2564
2882
  return parts.join("\n");
2565
2883
  }
2566
2884
 
2567
- /** 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
+ */
2568
2889
  export async function stopAction(input: {
2569
- params: { name?: string; all?: boolean };
2890
+ params: { name?: string; all?: boolean; force?: boolean };
2570
2891
  runtime: SessionRuntime;
2571
- retire: (name: string) => Promise<void>;
2892
+ retire: (name: string, opts?: RetireOptions) => Promise<void>;
2572
2893
  }): Promise<AgentToolResult<unknown>> {
2573
2894
  const { params, runtime, retire } = input;
2574
2895
  if (params.name && params.all) {
@@ -2577,6 +2898,8 @@ export async function stopAction(input: {
2577
2898
  if (!params.name && !params.all) {
2578
2899
  return fail("`name` or `all` is required for stop", ErrorCodes.INVALID_PARAMS);
2579
2900
  }
2901
+ const force = params.force === true;
2902
+ const retireOpts: RetireOptions | undefined = force ? { graceful: false } : undefined;
2580
2903
  const jobs = runtime.activeJobs();
2581
2904
  const active = jobs.filter((job) => job.state !== "retired");
2582
2905
  let targets: string[];
@@ -2601,18 +2924,19 @@ export async function stopAction(input: {
2601
2924
  try {
2602
2925
  // The runtime job may have been released after the target snapshot was
2603
2926
  // taken. The retire adapter can then resolve its persisted run record.
2604
- await retire(name);
2927
+ await retire(name, retireOpts);
2605
2928
  runtime.release(name);
2606
2929
  stopped.push(name);
2607
2930
  } catch (error) {
2608
- // If the graceful-retire chain fails, restore the completion watcher so a
2931
+ // If the retire chain fails, restore the completion watcher so a
2609
2932
  // still-live child is not orphaned by the explicit stop attempt.
2610
2933
  if (runtime.get(name)) runtime.rewatch(name);
2611
2934
  failures.push(`${name}: ${String(error)}`);
2612
2935
  }
2613
2936
  }
2937
+ const mode = force ? "force-stopped" : "Stopped";
2614
2938
  const summary = stopped.length
2615
- ? `Stopped ${stopped.length} child${stopped.length === 1 ? "" : "ren"}: ${stopped.join(", ")}.`
2939
+ ? `${mode} ${stopped.length} child${stopped.length === 1 ? "" : "ren"}: ${stopped.join(", ")}.`
2616
2940
  : "No children were stopped.";
2617
2941
  return failures.length
2618
2942
  ? fail(`${summary}\nFailed to stop: ${failures.join("; ")}`, ErrorCodes.RETIRE_FAILED)
@@ -2664,7 +2988,11 @@ export async function waitAction(input: {
2664
2988
  }),
2665
2989
  );
2666
2990
  const results = await runtime.wait(targets.names, { timeoutMs, ...(signal ? { signal } : {}) });
2667
- 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);
2668
2996
  }
2669
2997
 
2670
2998
  export function renderAgentList(
@@ -2721,6 +3049,11 @@ function teamForChildren(team: ActiveTeam): { team?: ActiveTeam } {
2721
3049
  return team.name !== DEFAULT_TEAM || team.source === "env" ? { team } : {};
2722
3050
  }
2723
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
+
2724
3057
  /** Prefer a role under the active team, then fall back for existing children. */
2725
3058
  function findControlAgent(
2726
3059
  agents: AgentConfig[],
@@ -2843,5 +3176,6 @@ function ok(text: string): AgentToolResult<unknown> {
2843
3176
  * `details` and the message text carries the failure signal.
2844
3177
  */
2845
3178
  function fail(text: string, code: string): AgentToolResult<unknown> {
3179
+ recordSessionError({ code, message: text });
2846
3180
  return { content: [{ type: "text", text }], details: { error: code } };
2847
3181
  }