pi-legion 0.16.0 → 2.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 (89) hide show
  1. package/CONTRIBUTING.md +58 -0
  2. package/README.md +313 -640
  3. package/agents/advisor.md +7 -5
  4. package/agents/centurion.md +53 -0
  5. package/agents/designer.md +33 -33
  6. package/agents/planner.md +1 -2
  7. package/agents/prototype.md +2 -2
  8. package/agents/reviewer.md +67 -31
  9. package/agents/scout.md +6 -2
  10. package/agents/worker.md +12 -3
  11. package/docs/contracts/esc-abort-v1.md +131 -0
  12. package/docs/contracts/esc-cascade-v2.md +100 -0
  13. package/docs/design.md +44 -25
  14. package/docs/legion-v2-contract.md +461 -0
  15. package/docs/legion-v2-delivery.md +62 -0
  16. package/docs/retire-hardening-contract.md +125 -0
  17. package/docs/subagents-teams.md +76 -0
  18. package/docs/tui-viewer-contract.md +276 -0
  19. package/docs/vexillatio-contract.md +162 -0
  20. package/index.ts +1462 -286
  21. package/package.json +12 -9
  22. package/skills/pi-legion/SKILL.md +74 -96
  23. package/src/agents/agents.ts +6 -11
  24. package/src/agents/overrides.ts +2 -1
  25. package/src/agents/paths.ts +1 -1
  26. package/src/agents/settings.ts +268 -20
  27. package/src/api.ts +47 -48
  28. package/src/{herdr → backends/legacy}/client.ts +14 -69
  29. package/src/{shared → backends/legacy}/cursor-chat.ts +2 -2
  30. package/src/{herdr → backends/legacy}/runner.ts +22 -9
  31. package/src/backends/legacy/types.ts +138 -0
  32. package/src/extension/auto-doctor.ts +144 -0
  33. package/src/extension/budget.ts +2 -2
  34. package/src/extension/child-guard.ts +214 -201
  35. package/src/extension/event-bus.ts +263 -0
  36. package/src/extension/legion-commands.ts +719 -0
  37. package/src/extension/legion-doctor.ts +888 -0
  38. package/src/extension/legion-stop.ts +150 -0
  39. package/src/extension/legion-tools.ts +816 -0
  40. package/src/extension/notify.ts +24 -19
  41. package/src/extension/playbook.ts +20 -89
  42. package/src/extension/recycle.ts +2 -7
  43. package/src/extension/runtime.ts +655 -105
  44. package/src/extension/scout-guard.ts +483 -0
  45. package/src/extension/slash.ts +89 -438
  46. package/src/extension/spawn-hints.ts +79 -0
  47. package/src/extension/summary.ts +17 -58
  48. package/src/legion/budget.ts +165 -0
  49. package/src/legion/db.ts +242 -0
  50. package/src/legion/events.ts +150 -0
  51. package/src/legion/mail.ts +412 -0
  52. package/src/legion/nodes.ts +461 -0
  53. package/src/legion/rebuild.ts +533 -0
  54. package/src/legion/state-machine.ts +313 -0
  55. package/src/legion/usage.ts +101 -0
  56. package/src/runs/acceptance.ts +82 -25
  57. package/src/runs/args.ts +51 -5
  58. package/src/runs/hygiene.ts +335 -0
  59. package/src/runs/orchestrator.ts +790 -1683
  60. package/src/runs/owner.ts +61 -0
  61. package/src/runs/provision.ts +135 -0
  62. package/src/runs/store.ts +44 -17
  63. package/src/runs/verify-queue.ts +195 -0
  64. package/src/shared/name.ts +29 -0
  65. package/src/shared/output-cap.ts +57 -0
  66. package/src/shared/progress.ts +57 -135
  67. package/src/shared/session.ts +243 -171
  68. package/src/shared/types.ts +107 -208
  69. package/src/supervisor/child-memory.ts +140 -0
  70. package/src/supervisor/proc-tree.ts +103 -0
  71. package/src/supervisor/rpc-supervisor.ts +1033 -0
  72. package/src/supervisor/types.ts +153 -0
  73. package/src/supervisor/ui-proxy.ts +226 -0
  74. package/src/tui/chat-items.ts +330 -0
  75. package/src/tui/live-overlay.ts +874 -0
  76. package/src/tui/status.ts +553 -27
  77. package/src/tui/tree-view.ts +808 -0
  78. package/src/tui/viz/canvas.ts +119 -0
  79. package/src/tui/viz/layout.ts +111 -0
  80. package/src/tui/viz/model.ts +123 -0
  81. package/src/tui/viz/palette.ts +188 -0
  82. package/src/tui/viz/panels.ts +530 -0
  83. package/src/tui/viz/railway.ts +313 -0
  84. package/src/tui/viz/rate.ts +211 -0
  85. package/src/tui/viz/view.ts +228 -0
  86. package/src/extension/parent-label.ts +0 -115
  87. package/src/profiles/classify.ts +0 -375
  88. package/src/profiles/profiles.ts +0 -730
  89. package/src/runs/layout.ts +0 -258
package/index.ts CHANGED
@@ -16,9 +16,12 @@ import type { AgentToolResult, AgentToolUpdateCallback } from "@earendil-works/p
16
16
  import type {
17
17
  ExtensionAPI,
18
18
  ExtensionContext,
19
+ ExtensionUIContext,
19
20
  } from "@earendil-works/pi-coding-agent";
20
21
  import { Type, type Static } from "typebox";
21
- import { createHerdrClient } from "./src/herdr/client.ts";
22
+ import { RpcSupervisor } from "./src/supervisor/rpc-supervisor.ts";
23
+ import type { LegionSupervisor } from "./src/supervisor/types.ts";
24
+ import { InMemoryUIProxy, type UIProxy } from "./src/supervisor/ui-proxy.ts";
22
25
  import { discoverAgents, findAgent, formatAgentRoster, BUILTIN_AGENTS_DIR } from "./src/agents/agents.ts";
23
26
  import {
24
27
  applyAgentOverrides,
@@ -28,6 +31,7 @@ import {
28
31
  import {
29
32
  applyTeam,
30
33
  DEFAULT_TEAM,
34
+ TEAM_ENV,
31
35
  type ActiveTeam,
32
36
  } from "./src/agents/teams.ts";
33
37
  import { resolveModel } from "./src/agents/model-resolution.ts";
@@ -37,39 +41,53 @@ import {
37
41
  loadSubagentSettings,
38
42
  resolveSubagentSettings,
39
43
  } from "./src/agents/settings.ts";
40
- import { createSessionRuntime, type SessionRuntime, shouldRecycleAfterCollect, type WaitResult } from "./src/extension/runtime.ts";
44
+ import { createSessionRuntime, renderLiveChildStatus, type SessionRuntime, type TrackedJob, shouldRecycleAfterCollect, type WaitResult } from "./src/extension/runtime.ts";
45
+ import { createDbEventSource, createLegionEventBus, type DbEventSource, type LegionEventBus } from "./src/extension/event-bus.ts";
46
+ import { LegionPostman, registerLegionTools, type LegionMailCommand } from "./src/extension/legion-tools.ts";
47
+ import { LEGION_DB_ENV, LEGION_NODE_ID_ENV } from "./src/runs/args.ts";
48
+ import type { VerifyRunner } from "./src/runs/acceptance.ts";
49
+ import { legionDbPath, openLegionDb, type DatabaseSync } from "./src/legion/db.ts";
50
+ import { appendEvent } from "./src/legion/events.ts";
51
+ import { attachUsageTotals, upsertUsage } from "./src/legion/usage.ts";
52
+ import { getChildByName, getChildren, getNode, getSubtree, insertNode, type LegionNode, updateNode } from "./src/legion/nodes.ts";
53
+ import { DEFAULT_LEGION_SETTINGS, type LegionSettings } from "./src/shared/types.ts";
54
+ import { parseNestedPathEnv } from "./src/shared/nested-path.ts";
55
+ import { allocateSiblingNames, makeName } from "./src/shared/name.ts";
56
+ import { deriveOutcome, isLastTurnComplete, parseSessionFile } from "./src/shared/session.ts";
57
+ import { restoreHostNodeOptions } from "./src/supervisor/child-memory.ts";
58
+ import { setVerifyQueueListener } from "./src/runs/verify-queue.ts";
59
+ import { applyEventRetention, checkpoint, formatHygieneReport, hygieneEnabled, runStartupHygiene } from "./src/runs/hygiene.ts";
60
+ import {
61
+ createStatusNodeOpener,
62
+ publishSupervisorEvent,
63
+ publishUIRequest,
64
+ registerLegionCommand,
65
+ type RpcEventSource,
66
+ confirmAndStopNode,
67
+ } from "./src/extension/legion-commands.ts";
68
+ import { createIdleEscStop, liveJobs } from "./src/extension/legion-stop.ts";
41
69
  import { DEFAULT_FLUSH_MS, DEFAULT_JOIN_MODE } from "./src/extension/join.ts";
42
70
  import { registerChildGuard } from "./src/extension/child-guard.ts";
71
+ import { registerScoutGuard } from "./src/extension/scout-guard.ts";
43
72
  import { ALLOW_NESTED_ENV } from "./src/extension/budget.ts";
44
73
  import {
45
74
  applyOnBlockedPolicy,
46
75
  followUpFor,
47
76
  } from "./src/extension/blocked.ts";
48
77
  import { formatAlreadyRecycled } from "./src/extension/recycle.ts";
49
- import { ParentPaneLabeler } from "./src/extension/parent-label.ts";
50
78
  import {
51
79
  SUBAGENT_NOTIFY_TYPE,
52
80
  } from "./src/extension/notify.ts";
53
81
  import { renderSubagentNotice } from "./src/extension/notice-renderer.ts";
54
- import {
55
- registerProfileCommands,
56
- registerAgentsCommand,
57
- registerTeamCommand,
58
- registerToggleCommand,
59
- } from "./src/extension/slash.ts";
60
- import { registerSummaryCommand } from "./src/extension/summary.ts";
61
- import {
62
- blockMessage,
63
- forbiddenDispatchReason,
64
- PARENT_PLAYBOOK,
65
- TOOL_DESCRIPTION,
66
- } from "./src/extension/playbook.ts";
82
+ import { PARENT_PLAYBOOK, TOOL_DESCRIPTION } from "./src/extension/playbook.ts";
67
83
  import { getAgentDir } from "./src/agents/paths.ts";
68
- import { Orchestrator } from "./src/runs/orchestrator.ts";
84
+ import { expectedModelsFromSettings, runLegionDoctor } from "./src/extension/legion-doctor.ts";
85
+ import { readFileSync } from "node:fs";
69
86
  import {
70
- createSessionLayout,
71
- type SessionLayout,
72
- } from "./src/runs/layout.ts";
87
+ effectiveMaxDepth,
88
+ Orchestrator,
89
+ type OrchestratorLegionContext,
90
+ } from "./src/runs/orchestrator.ts";
73
91
  import { RunStore, pickChildByName } from "./src/runs/store.ts";
74
92
  import { resolveLaunchWorktree } from "./src/runs/worktree.ts";
75
93
  import {
@@ -78,22 +96,18 @@ import {
78
96
  DEFAULTS,
79
97
  DEFAULT_ON_BLOCKED,
80
98
  ErrorCodes,
81
- type HerdrClient,
82
99
  type ModelOrigin,
83
- type Placement,
84
100
  SubagentError,
85
101
  } from "./src/shared/types.ts";
86
102
 
103
+ // The parent turn owns its child lifetime. Parallel tool aborts in one turn
104
+ // share this graceful-retire cascade; turn_start begins a fresh cycle.
105
+ let cascadePromise: Promise<void> | undefined;
106
+
87
107
  // ---------------------------------------------------------------------------
88
108
  // Parameters
89
109
  // ---------------------------------------------------------------------------
90
110
 
91
- const PlacementSchema = Type.Union([
92
- Type.Literal("split-down"),
93
- Type.Literal("split-right"),
94
- Type.Literal("new-tab"),
95
- ]);
96
-
97
111
  const TaskItem = Type.Object({
98
112
  agent: Type.String({ description: "Name of the agent to invoke" }),
99
113
  task: Type.String({ description: "Task to delegate" }),
@@ -108,6 +122,12 @@ const TaskItem = Type.Object({
108
122
  "Parent decides isolation for this child. true = own git branch, child opens an MR. false = write the current checkout.",
109
123
  }),
110
124
  ),
125
+ verify: Type.Optional(
126
+ Type.Array(Type.String(), {
127
+ description:
128
+ "Commands the extension runs itself in the child worktree at collect time; first non-zero exit rejects the verdict.",
129
+ }),
130
+ ),
111
131
  });
112
132
 
113
133
  const SubagentParams = Type.Object({
@@ -123,8 +143,10 @@ const SubagentParams = Type.Object({
123
143
  Type.Literal("collect"),
124
144
  Type.Literal("list"),
125
145
  Type.Literal("wait"),
146
+ Type.Literal("stop"),
147
+ Type.Literal("diagnose"),
126
148
  ],
127
- { description: "Defaults to launch." },
149
+ { description: "Defaults to launch. diagnose = Legion doctor full self-check (extensions/models/spawn/memory/tools/storage/runtime)." },
128
150
  ),
129
151
  ),
130
152
  agent: Type.Optional(
@@ -146,13 +168,18 @@ const SubagentParams = Type.Object({
146
168
  Type.String({ description: "Named kind+model+thinking preset" }),
147
169
  ),
148
170
  cwd: Type.Optional(Type.String()),
149
- placement: Type.Optional(PlacementSchema),
150
171
  worktree: Type.Optional(
151
172
  Type.Boolean({
152
173
  description:
153
174
  "Parent decides isolation. true = isolated git branch, child opens an MR. false = edit the current checkout. Omit to use the role default (bundled worker: true).",
154
175
  }),
155
176
  ),
177
+ verify: Type.Optional(
178
+ Type.Array(Type.String(), {
179
+ description:
180
+ "Verification commands the extension runs itself in the child worktree at collect time (launch only). Each must exit 0; the first non-zero exit rejects the child's verdict as `rejected (verified-path)`. The worker does not need to paste test output.",
181
+ }),
182
+ ),
156
183
  agentScope: Type.Optional(
157
184
  Type.Union(
158
185
  [Type.Literal("user"), Type.Literal("project"), Type.Literal("both")],
@@ -162,26 +189,40 @@ const SubagentParams = Type.Object({
162
189
  ),
163
190
  ),
164
191
  name: Type.Optional(
165
- Type.String({ description: "Child handle name (control actions)" }),
192
+ Type.String({ description: "Child handle name (control actions, including wait and stop)" }),
166
193
  ),
167
194
  all: Type.Optional(
168
195
  Type.Boolean({
169
- description: "wait: wait for every currently-running child in this session",
196
+ description: "wait: wait for active children; stop: stop every non-retired child",
170
197
  }),
171
198
  ),
172
199
  timeoutMs: Type.Optional(
173
200
  Type.Number({
174
- description: "wait: per-child timeout in ms (default: role turnTimeoutMs)",
201
+ description: "wait: per-child timeout in ms; event-driven completion returns early (default: role turnTimeoutMs)",
175
202
  }),
176
203
  ),
177
204
  message: Type.Optional(
178
205
  Type.String({ description: "Text for steer/continue" }),
179
206
  ),
207
+ team: Type.Optional(
208
+ Type.String({ description: "Team to use for the launched child and its nested roster" }),
209
+ ),
180
210
  });
181
211
 
182
212
  /** The validated tool parameters, as the model supplies them. */
183
213
  type SubagentParams = Static<typeof SubagentParams>;
184
214
 
215
+ const VexillatioParams = Type.Object({
216
+ task: Type.String({ description: "Recon task: one clear question list" }),
217
+ agent: Type.Optional(Type.String({ description: "Utility agent name (default: scout)" })),
218
+ tasks: Type.Optional(Type.Array(TaskItem, { description: "Parallel recon launches" })),
219
+ cwd: Type.Optional(Type.String()),
220
+ model: Type.Optional(Type.String()),
221
+ timeoutMs: Type.Optional(Type.Number({ description: "Per-child wait in ms, default 600000" })),
222
+ });
223
+
224
+ const UTILITY_CHILD_ENV = "PI_SUBAGENT_UTILITY_CHILD";
225
+
185
226
  const blockedUi = new WeakMap<
186
227
  SessionRuntime,
187
228
  {
@@ -194,51 +235,495 @@ const blockedUi = new WeakMap<
194
235
  // Extension
195
236
  // ---------------------------------------------------------------------------
196
237
 
197
- export default function herdrSubagents(pi: ExtensionAPI) {
238
+ export interface ExtensionSupervisorDeps {
239
+ supervisor?: LegionSupervisor;
240
+ uiProxy?: UIProxy;
241
+ onBudgetRefused?: NonNullable<ConstructorParameters<typeof Orchestrator>[0]["onBudgetRefused"]>;
242
+ /** Injected verify runner for tests; production falls back to the real shell runner. */
243
+ verifyRunner?: VerifyRunner;
244
+ }
245
+
246
+ interface LegionRuntimeIdentity {
247
+ pi: ExtensionAPI;
248
+ db: DatabaseSync;
249
+ dbPath: string;
250
+ runStore: RunStore;
251
+ nodeId: string;
252
+ parentNodeId: string | null;
253
+ parentRunId: string | null;
254
+ runId: string;
255
+ runDir: string;
256
+ rootRunId: string;
257
+ storeRootDir: string;
258
+ settings: LegionSettings;
259
+ eventBus: LegionEventBus;
260
+ dbEventSource: DbEventSource;
261
+ postman: LegionPostman;
262
+ childNodeId(name: string): string | undefined;
263
+ }
264
+
265
+ function settingsForLegion(settings: ReturnType<typeof loadSubagentSettings>): LegionSettings {
266
+ return { ...DEFAULT_LEGION_SETTINGS, ...(settings.legion ?? {}), phaseTimeouts: { ...DEFAULT_LEGION_SETTINGS.phaseTimeouts, ...(settings.legion?.phaseTimeouts ?? {}) } };
267
+ }
268
+
269
+ function runStoreFor(cwd: string): RunStore {
270
+ const sharedDbPath = process.env[LEGION_DB_ENV];
271
+ if (sharedDbPath && path.isAbsolute(sharedDbPath)) {
272
+ const storeRoot = path.dirname(path.dirname(path.dirname(sharedDbPath)));
273
+ return new RunStore({ rootDir: storeRoot });
274
+ }
275
+ return new RunStore({ rootDir: path.join(cwd, ".pi-subagents") });
276
+ }
277
+
278
+ function notice(pi: ExtensionAPI, text: string): void {
279
+ pi.sendMessage({ customType: SUBAGENT_NOTIFY_TYPE, content: text, display: true }, { triggerTurn: true, deliverAs: "followUp" });
280
+ }
281
+
282
+ function createLegionRuntime(input: {
283
+ pi: ExtensionAPI;
284
+ supervisor: LegionSupervisor;
285
+ db: DatabaseSync;
286
+ dbPath: string;
287
+ nodeId: string;
288
+ rootRunId: string;
289
+ runDir: string;
290
+ cwd: string;
291
+ storeRootDir: string;
292
+ settings: LegionSettings;
293
+ eventBus: LegionEventBus;
294
+ registerTools: boolean;
295
+ }): LegionRuntimeIdentity {
296
+ const self = getNode(input.db, input.nodeId);
297
+ const eventBus = input.eventBus;
298
+ const dbEventSource = createDbEventSource(input.db);
299
+ dbEventSource.start(eventBus);
300
+ const postman = new LegionPostman({
301
+ db: input.db,
302
+ nodeId: input.nodeId,
303
+ supervisor: input.supervisor,
304
+ settings: input.settings,
305
+ eventBus,
306
+ sendRootMessage: (text: string) => notice(input.pi, text),
307
+ sendSelfMessage: (text: string) => notice(input.pi, text),
308
+ resumeChild: (node: LegionNode, mail: LegionMailCommand) => resumeLegionChild({
309
+ pi: input.pi, supervisor: input.supervisor, db: input.db, dbPath: input.dbPath,
310
+ eventBus, settings: input.settings, cwd: input.cwd, storeRootDir: input.storeRootDir,
311
+ parentNodeId: input.nodeId, node, message: mail.text,
312
+ }),
313
+ });
314
+ const identity: LegionRuntimeIdentity = {
315
+ pi: input.pi,
316
+ db: input.db,
317
+ dbPath: input.dbPath,
318
+ runStore: new RunStore({ rootDir: input.storeRootDir }),
319
+ nodeId: input.nodeId,
320
+ parentNodeId: self?.parentId ?? null,
321
+ parentRunId: self?.parentId ? getNode(input.db, self.parentId)?.runId ?? null : null,
322
+ runId: self?.runId ?? input.rootRunId,
323
+ runDir: input.runDir,
324
+ rootRunId: input.rootRunId,
325
+ storeRootDir: input.storeRootDir,
326
+ settings: input.settings,
327
+ eventBus,
328
+ dbEventSource,
329
+ postman,
330
+ childNodeId: (name) => getChildByName(input.db, input.nodeId, name)?.id,
331
+ };
332
+ if (input.registerTools && input.nodeId !== "root") registerLegionTools(input.pi, { db: input.db, nodeId: input.nodeId, role: self?.role, settings: input.settings, cwd: input.cwd, runDir: input.runDir, eventBus, sendSelfMessage: (text: string) => notice(input.pi, text) });
333
+ postman.start();
334
+ return identity;
335
+ }
336
+
337
+ function resolveLegionIdentity(input: {
338
+ pi: ExtensionAPI;
339
+ supervisor: LegionSupervisor;
340
+ store: RunStore;
341
+ cwd: string;
342
+ settings: ReturnType<typeof loadSubagentSettings>;
343
+ eventBus: LegionEventBus;
344
+ cached?: LegionRuntimeIdentity;
345
+ /** This process's session file (identity credential). From ctx.sessionManager. */
346
+ sessionFile?: string | undefined;
347
+ }): LegionRuntimeIdentity | undefined {
348
+ const dbPath = process.env[LEGION_DB_ENV];
349
+ const nodeId = process.env[LEGION_NODE_ID_ENV];
350
+ if (Boolean(dbPath) !== Boolean(nodeId)) throw new Error("PI_LEGION_DB and PI_LEGION_NODE_ID must be set together");
351
+ if (dbPath && nodeId) {
352
+ if (!path.isAbsolute(dbPath)) throw new Error("PI_LEGION_DB must be an absolute path");
353
+ if (input.cached?.dbPath === path.resolve(dbPath) && input.cached.nodeId === nodeId) return input.cached;
354
+ const absoluteDbPath = path.resolve(dbPath);
355
+ const db = openLegionDb(absoluteDbPath);
356
+ const node = getNode(db, nodeId);
357
+ if (!node) { db.close(); throw new Error(`legion node not found: ${nodeId}`); }
358
+ // Identity credential check: the node's recorded sessionFile must match
359
+ // THIS process's session. A child env (PI_LEGION_DB/PI_LEGION_NODE_ID)
360
+ // inherited by an arbitrary subprocess (e.g. a scout running `npm test`
361
+ // whose tests load this extension) must not adopt the scout's tree
362
+ // identity — that produced the "healthy ghost nodes" incident: fake
363
+ // launches persisted into the live ledger. A mismatch means the env
364
+ // belongs to an ancestor process, not to us; refuse to attach.
365
+ const mySession = input.sessionFile;
366
+ if (mySession && node.sessionFile && path.resolve(mySession) !== path.resolve(node.sessionFile)) {
367
+ db.close();
368
+ throw new Error(
369
+ `legion identity mismatch: node ${nodeId} belongs to session ${node.sessionFile}, ` +
370
+ `but this process runs ${mySession}; inherited PI_LEGION_* env is not yours to adopt`,
371
+ );
372
+ }
373
+ const nodeRuns = input.store.listRuns().filter((run) => run.nodeId === nodeId || run.herdr.node_id === nodeId);
374
+ let nodeRun = nodeRuns.at(-1);
375
+ if (!nodeRun) {
376
+ nodeRun = input.store.createRun({ task: node.taskSummary ?? `Resume ${node.role} node ${node.id}`, cwd: input.cwd, path: parseNestedPathEnv(process.env.PI_SUBAGENT_PARENT_PATH), maxDepth: effectiveMaxDepth(input.settings.legion?.maxDepth), nodeId, herdr: { supervisor: "rpc", node_id: nodeId } });
377
+ }
378
+ if (node.runId !== nodeRun.runId) updateLegionRunId(db, nodeId, nodeRun.runId);
379
+ const identity = createLegionRuntime({ pi: input.pi, supervisor: input.supervisor, db, dbPath: absoluteDbPath, nodeId, rootRunId: getNode(db, "root")?.runId ?? path.basename(path.dirname(absoluteDbPath)), runDir: input.store.runDir(nodeRun.runId), cwd: input.cwd, storeRootDir: input.store.rootDir, settings: settingsForLegion(input.settings), eventBus: input.eventBus, registerTools: true });
380
+ if (node.role === "centurion") void resumeLegionTeam({ identity, input });
381
+ return identity;
382
+ }
383
+ if (input.cached?.nodeId === "root" && path.resolve(input.cached.storeRootDir) === path.resolve(input.store.rootDir)) return input.cached;
384
+ // A root runtime belongs to the current Pi session. Do not adopt a different
385
+ // session's most recent tree merely because it shares the same project cwd.
386
+ return undefined;
387
+ }
388
+
389
+ function updateLegionRunId(db: DatabaseSync, nodeId: string, runId: string): void {
390
+ db.prepare("UPDATE nodes SET run_id = ?, updated_at = ? WHERE id = ?").run(runId, Date.now(), nodeId);
391
+ }
392
+
393
+ async function resumeLegionTeam(input: { identity: LegionRuntimeIdentity; input: { pi: ExtensionAPI; supervisor: LegionSupervisor; cwd: string } }): Promise<void> {
394
+ for (const node of getChildren(input.identity.db, input.identity.nodeId)) {
395
+ if (["settled", "failed"].includes(node.status) || ["done", "failed", "aborted"].includes(node.phase ?? "") || input.input.supervisor.isAlive(node.name)) continue;
396
+ if (node.sessionFile) {
397
+ const parsed = parseSessionFile(node.sessionFile);
398
+ const recovered = deriveOutcome(parsed);
399
+ if (isLastTurnComplete(parsed) && recovered.status === "success") {
400
+ updateNode(input.identity.db, node.id, { status: "settled" });
401
+ appendEvent(input.identity.db, node.id, "node_settled", { recoveredFromSession: true, execution: recovered.status });
402
+ const usage = recovered.usage;
403
+ if (usage) upsertUsage(input.identity.db, { nodeId: node.id, tokensIn: usage.input + usage.cacheRead + usage.cacheWrite, tokensOut: usage.output, costUsd: usage.cost });
404
+ continue;
405
+ }
406
+ if (isLastTurnComplete(parsed) && recovered.status !== "running" && recovered.status !== "unknown") {
407
+ updateNode(input.identity.db, node.id, { status: "failed" });
408
+ appendEvent(input.identity.db, node.id, "node_failed", { recoveredFromSession: true, execution: recovered.status, reason: recovered.reason });
409
+ continue;
410
+ }
411
+ }
412
+ try {
413
+ await resumeLegionChild({ pi: input.input.pi, supervisor: input.input.supervisor, db: input.identity.db, dbPath: input.identity.dbPath, eventBus: input.identity.eventBus, settings: input.identity.settings, cwd: input.input.cwd, storeRootDir: input.identity.storeRootDir, parentNodeId: input.identity.nodeId, node, message: `Resume from saved session ${node.sessionFile} and continue interrupted work.` });
414
+ } catch (error) { notice(input.input.pi, `Could not resume ${node.id}: ${error instanceof Error ? error.message : String(error)}`); }
415
+ }
416
+ }
417
+
418
+ function createRootLegion(input: { pi: ExtensionAPI; supervisor: LegionSupervisor; store: RunStore; cwd: string; task: string; settings: ReturnType<typeof loadSubagentSettings>; maxDepth: number; eventBus: LegionEventBus }): LegionRuntimeIdentity {
419
+ const rootRun = input.store.createRun({ task: input.task, cwd: input.cwd, maxDepth: input.maxDepth, nodeId: "root", herdr: { supervisor: "rpc" } });
420
+ const dbPath = path.resolve(legionDbPath(input.store.runDir(rootRun.runId)));
421
+ const db = openLegionDb(dbPath);
422
+ const now = Date.now();
423
+ insertNode(db, { id: "root", parentId: null, name: "root", role: "root", kind: "pi", depth: 0, runId: rootRun.runId, status: "running", taskSummary: input.task.slice(0, 2_000), createdAt: now, updatedAt: now }, () => now);
424
+ appendEvent(db, "root", "node_launched", { runId: rootRun.runId, task: input.task }, { now: () => now });
425
+ return createLegionRuntime({ pi: input.pi, supervisor: input.supervisor, db, dbPath, nodeId: "root", rootRunId: rootRun.runId, runDir: input.store.runDir(rootRun.runId), cwd: input.cwd, storeRootDir: input.store.rootDir, settings: settingsForLegion(input.settings), eventBus: input.eventBus, registerTools: false });
426
+ }
427
+
428
+ async function resumeLegionChild(input: { pi: ExtensionAPI; supervisor: LegionSupervisor; db: DatabaseSync; dbPath: string; eventBus: LegionEventBus; settings: LegionSettings; cwd: string; storeRootDir: string; parentNodeId: string; node: LegionNode; message: string }): Promise<void> {
429
+ const parent = getNode(input.db, input.parentNodeId);
430
+ if (!parent || !input.node.runId || !input.node.sessionFile) throw new Error(`resume credentials missing for ${input.node.id}`);
431
+ const store = new RunStore({ rootDir: input.storeRootDir });
432
+ const nodeRun = store.readRun(input.node.runId);
433
+ if (!nodeRun) throw new Error(`resume run.json not found: ${input.node.runId}`);
434
+ const parentRunId = parent.runId;
435
+ if (!parentRunId) throw new Error(`parent run id missing for ${input.node.id}`);
436
+ let parentRun = store.readRun(parentRunId);
437
+ if (!parentRun) throw new Error(`parent run.json not found: ${parentRunId}`);
438
+ let saved = parentRun.children.find((child) => child.nodeId === input.node.id || child.sessionFile === input.node.sessionFile);
439
+ if (!saved) {
440
+ await store.addChild(parentRunId, {
441
+ name: input.node.name,
442
+ nodeId: input.node.id,
443
+ sessionFile: input.node.sessionFile,
444
+ ownerToken: `legion-${input.node.id}`,
445
+ state: input.node.status === "settled" ? "awaiting" : "retired",
446
+ spawnedAt: new Date(input.node.createdAt).toISOString(),
447
+ agent: input.node.role,
448
+ kind: input.node.kind as AgentConfig["kind"],
449
+ ...(input.node.model ? { model: input.node.model } : {}),
450
+ ...(input.node.worktreePath ? { worktreePath: input.node.worktreePath } : {}),
451
+ });
452
+ parentRun = store.readRun(parentRunId)!;
453
+ saved = parentRun.children.find((child) => child.nodeId === input.node.id);
454
+ }
455
+ if (!saved) throw new Error(`resume child record not found: ${input.node.id}`);
456
+ const cwd = input.node.worktreePath ?? parent.worktreePath ?? parentRun.cwd ?? input.cwd;
457
+ const catalog = loadCatalog(cwd, cwd, undefined);
458
+ const resumedTeam = input.node.team
459
+ ? applyTeam(catalog.allAgents, { ...catalog.settings, team: input.node.team }, { ...process.env, [TEAM_ENV]: input.node.team })
460
+ : { agents: catalog.agents, team: catalog.team, warnings: catalog.teamWarnings };
461
+ const agent = findControlAgent(resumedTeam.agents, catalog.allAgents, input.node.role);
462
+ if (!agent) throw new Error(`resume role no longer exists: ${input.node.role}`);
463
+ const childRun = store.readRun(input.node.runId);
464
+ if (!childRun) throw new Error(`child run.json not found: ${input.node.runId}`);
465
+ const parentOfSupervisor = parent.parentId ? getNode(input.db, parent.parentId) : null;
466
+ 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 } });
467
+ orchestrator.restore(parentRun);
468
+ 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) });
469
+ await store.updateChild(parentRun.runId, saved.name, (child) => Object.assign(child, handle.child));
470
+ }
471
+
472
+ export default function herdrSubagents(pi: ExtensionAPI, deps: ExtensionSupervisorDeps = {}) {
473
+ cascadePromise = undefined;
198
474
  const isChild = process.env.PI_SUBAGENT_CHILD === "1";
199
475
  const allowNested = process.env[ALLOW_NESTED_ENV] === "1";
476
+ if (isChild) restoreHostNodeOptions();
477
+ else {
478
+ // Root UI: surface verify-queue waits instead of a silent stall.
479
+ setVerifyQueueListener((event) => {
480
+ try {
481
+ if (event.phase === "waiting") lastUi?.setStatus("legion-verify", `verify queued ${Math.round(event.waitedMs / 1000)}s · ${event.holders}/${event.limit} slots busy`);
482
+ else lastUi?.setStatus("legion-verify", undefined);
483
+ } catch { /* no UI */ }
484
+ });
485
+ }
486
+ const supervisor = deps.supervisor ?? new RpcSupervisor({
487
+ onSpawnRefused: (message) => {
488
+ try { lastUi?.notify(`pi-legion: ${message}`, "error"); } catch { /* no UI (child / print mode) */ }
489
+ },
490
+ });
491
+ const verifyRunner = deps.verifyRunner;
492
+ const legionBus = createLegionEventBus();
493
+ const rpcUIProxy = (supervisor as LegionSupervisor & { uiProxy?: UIProxy }).uiProxy;
494
+ const uiProxy = deps.uiProxy ?? rpcUIProxy ?? new InMemoryUIProxy();
495
+ const onBudgetRefused = deps.onBudgetRefused ?? ((event) => {
496
+ try { pi.events.emit("pi-legion:budget_refused", event); } catch { /* optional host event */ }
497
+ });
498
+ let sessionEnabledOverride: boolean | undefined;
499
+ const subagentsEnabled = (cwd: string): boolean => {
500
+ if (sessionEnabledOverride !== undefined) return sessionEnabledOverride;
501
+ try {
502
+ return loadSubagentSettings({
503
+ userSettingsPath: path.join(getAgentDir(), "settings.json"),
504
+ projectSettingsPath: path.join(cwd, ".pi", "settings.json"),
505
+ }).enabled !== false;
506
+ } catch (error) {
507
+ console.warn(
508
+ "[pi-legion] Unable to load settings while checking whether subagents are enabled; defaulting to enabled.",
509
+ error,
510
+ );
511
+ return true;
512
+ }
513
+ };
514
+ let lastCwd = process.cwd();
515
+ let legionIdentity: LegionRuntimeIdentity | undefined;
516
+ const stopLegionRuntime = (): void => {
517
+ if (!legionIdentity) return;
518
+ legionIdentity.postman.stop();
519
+ legionIdentity.dbEventSource.stop();
520
+ // #40: bound the ledger and fold the WAL back before closing.
521
+ if (hygieneEnabled() && !isChild) applyEventRetention(legionIdentity.db);
522
+ else checkpoint(legionIdentity.db);
523
+ legionIdentity.db.close();
524
+ legionIdentity = undefined;
525
+ };
526
+ const initializeLegionRuntime = (ctx: Pick<ExtensionContext, "cwd"> & { sessionFile?: string }): void => {
527
+ const store = runStoreFor(ctx.cwd);
528
+ const settings = loadSubagentSettings({
529
+ userSettingsPath: path.join(getAgentDir(), "settings.json"),
530
+ projectSettingsPath: path.join(ctx.cwd, ".pi", "settings.json"),
531
+ });
532
+ const next = resolveLegionIdentity({ pi, supervisor, store, cwd: ctx.cwd, settings, eventBus: legionBus, cached: legionIdentity, sessionFile: ctx.sessionFile });
533
+ if (legionIdentity && next !== legionIdentity) stopLegionRuntime();
534
+ legionIdentity = next;
535
+ };
200
536
  if (isChild) {
201
537
  registerChildGuard(pi);
202
- if (!allowNested) return;
538
+ if (process.env[LEGION_DB_ENV] && process.env[LEGION_NODE_ID_ENV]) {
539
+ // Boot-time attach: no ExtensionContext yet, so no session credential —
540
+ // the session_start handler re-runs this with the real credential.
541
+ try { initializeLegionRuntime({ cwd: process.cwd() }); }
542
+ catch (error) { console.error(`[pi-legion] legion initialization failed: ${error instanceof Error ? error.message : String(error)}`); }
543
+ }
203
544
  }
204
-
205
- // The parent transcript records every finish; the renderer keeps a
206
- // completed child to one line so visibility does not cost a screen.
207
- pi.registerMessageRenderer(SUBAGENT_NOTIFY_TYPE, renderSubagentNotice);
208
-
209
- // The parent pane's own id: absent when pi runs outside herdr, in which
210
- // case every labeler call is a no-op.
211
- const parentLabeler = new ParentPaneLabeler({
212
- client: createHerdrClient(),
213
- paneId: process.env.HERDR_PANE_ID,
545
+ let hygieneScheduled = false;
546
+ pi.on("session_start", (_event, ctx) => {
547
+ try { initializeLegionRuntime({ cwd: ctx.cwd, sessionFile: ctx.sessionManager?.getSessionFile() }); }
548
+ catch (error) { console.error(`[pi-legion] legion initialization failed: ${error instanceof Error ? error.message : String(error)}`); }
549
+ // #37/#38/#40: one deferred, best-effort storage sweep per root process.
550
+ if (!isChild && !hygieneScheduled && hygieneEnabled()) {
551
+ hygieneScheduled = true;
552
+ const storeRoot = runStoreFor(ctx.cwd).rootDir;
553
+ const timer = setTimeout(() => {
554
+ try {
555
+ const report = runStartupHygiene({ storeRoot, protectRunIds: legionIdentity ? [legionIdentity.rootRunId] : [] });
556
+ const text = formatHygieneReport(report);
557
+ if (text) ctx.ui?.notify?.(text, "info");
558
+ } catch (error) {
559
+ console.error(`[pi-legion] storage sweep failed: ${error instanceof Error ? error.message : String(error)}`);
560
+ }
561
+ }, 1_500);
562
+ timer.unref?.();
563
+ }
564
+ });
565
+ const pendingUtilityRetires: Array<() => Promise<void>> = [];
566
+ let sessionRuntime: SessionRuntime | undefined;
567
+ const shutdownRetireAll = async (): Promise<void> => {
568
+ const children = sessionRuntime?.activeJobs() ?? [];
569
+ sessionRuntime?.dispose();
570
+ await Promise.all([
571
+ ...children.map((job) => job.retire?.().catch(() => {})),
572
+ ...pendingUtilityRetires.map((retire) => retire().catch(() => {})),
573
+ ]);
574
+ stopLegionRuntime();
575
+ };
576
+ let cascadingJobs = new Set<TrackedJob>();
577
+ let cascadingUtilityRetires = new Set<() => Promise<void>>();
578
+ const cascadeRetireAll = (): Promise<void> => {
579
+ const jobs = (sessionRuntime?.activeJobs() ?? []).filter(
580
+ (job) => job.state !== "retired" && !cascadingJobs.has(job),
581
+ );
582
+ const utilityRetires = pendingUtilityRetires.filter(
583
+ (retire) => !cascadingUtilityRetires.has(retire),
584
+ );
585
+ if (!cascadePromise) cascadePromise = Promise.resolve();
586
+ if (jobs.length === 0 && utilityRetires.length === 0) return cascadePromise;
587
+ for (const job of jobs) {
588
+ cascadingJobs.add(job);
589
+ sessionRuntime?.cancelWatch(job.name);
590
+ }
591
+ for (const retire of utilityRetires) cascadingUtilityRetires.add(retire);
592
+ const retirements = [
593
+ ...jobs.map((job) => async () => job.retire?.()),
594
+ ...utilityRetires.map((retire) => async () => retire()),
595
+ ];
596
+ cascadePromise = cascadePromise.then(async () => {
597
+ // allSettled waits for every graceful retire without turning a child
598
+ // failure into a user-visible result or skipping runtime release.
599
+ await Promise.allSettled(retirements.map((retire) => retire()));
600
+ for (const job of jobs) sessionRuntime?.release(job.name);
601
+ });
602
+ return cascadePromise;
603
+ };
604
+ // Register before the child early-return paths below: a command-chain child
605
+ // may use vexillatio across multiple turns without creating a session runtime.
606
+ pi.on("turn_start", () => {
607
+ cascadePromise = undefined;
608
+ cascadingJobs = new Set();
609
+ cascadingUtilityRetires = new Set();
610
+ });
611
+ // An async launch has already returned when ESC lands: no subagent-family
612
+ // tool call is in flight to observe the abort signal, so the run's settle
613
+ // event is the last chance to retire this turn's children. Never awaited —
614
+ // a graceful retire may take seconds and must not stall the settle path.
615
+ pi.on("agent_settled", (event) => {
616
+ if (!event.aborted) return;
617
+ void cascadeRetireAll();
214
618
  });
215
- const layout = createSessionLayout();
216
- let lastModelRegistry: ExtensionContext["modelRegistry"] | undefined;
619
+ if (process.env[UTILITY_CHILD_ENV] !== "1") {
620
+ pi.registerTool({
621
+ name: "vexillatio",
622
+ label: "Vexillatio",
623
+ description: "Launch only read-only utility agents and return their collected output.",
624
+ parameters: VexillatioParams,
625
+ async execute(_id, params, signal, _onUpdate, ctx: ExtensionContext): Promise<AgentToolResult<unknown>> {
626
+ if (signal?.aborted) {
627
+ await cascadeRetireAll();
628
+ return ok("interrupted (parent turn aborted)");
629
+ }
630
+ if (!subagentsEnabled(ctx.cwd)) {
631
+ return fail("subagents are disabled (subagents.enabled: false or /legion off). Re-enable with /legion on.", ErrorCodes.DISABLED);
632
+ }
633
+ const runtime = sessionRuntime;
634
+ if (!runtime) return fail("session runtime is not initialized", ErrorCodes.INVALID_PARAMS);
635
+ runtime.bind(ctx);
636
+ const result = await launchUtilityFamily({
637
+ pi,
638
+ ctx,
639
+ runtime,
640
+ params,
641
+ supervisor,
642
+ uiProxy,
643
+ onBudgetRefused,
644
+ cwd: params.cwd ?? ctx.cwd,
645
+ sessionCwd: ctx.cwd,
646
+ signal,
647
+ isChild,
648
+ legion: legionIdentity,
649
+ eventBus: legionBus,
650
+ onLegion: (identity) => {
651
+ if (legionIdentity && legionIdentity !== identity) stopLegionRuntime();
652
+ legionIdentity = identity;
653
+ },
654
+ onUtilityChild: (retire) => pendingUtilityRetires.push(retire),
655
+ });
656
+ if (signal?.aborted) {
657
+ await cascadeRetireAll();
658
+ return ok("interrupted (parent turn aborted)");
659
+ }
660
+ return result;
661
+ },
662
+ });
663
+ }
664
+ pi.on("before_agent_start", (event, ctx) => {
665
+ if (process.env[UTILITY_CHILD_ENV] === "1") return;
666
+ if (!subagentsEnabled(ctx?.cwd ?? lastCwd)) return;
667
+ const tools = event.systemPromptOptions?.selectedTools;
668
+ if (Array.isArray(tools) && !tools.includes("subagent") && !tools.includes("vexillatio")) return;
669
+ if (ctx?.cwd) lastCwd = ctx.cwd;
670
+ const catalog = loadCatalog(lastCwd, lastCwd, undefined);
671
+ const parentContext = catalog.settings.parentContext?.trim();
672
+ return {
673
+ systemPrompt: `${event.systemPrompt}\n\n${PARENT_PLAYBOOK}\n\n${renderParentRoster(
674
+ catalog.agents,
675
+ catalog.team,
676
+ catalog.teamWarnings,
677
+ )}${parentContext ? `\n\n${parentContext}` : ""}`,
678
+ };
679
+ });
680
+ if (process.env[UTILITY_CHILD_ENV] === "1") {
681
+ pi.on("session_shutdown", () => stopLegionRuntime());
682
+ return;
683
+ }
684
+ let lastUi: ExtensionUIContext | undefined;
685
+ let statusNodeOpener: ((nodeId: string) => void) | undefined;
217
686
  let lastConfirm: ((message: string) => Promise<boolean>) | undefined;
218
- let lastCwd = process.cwd();
219
687
  // turn_start/turn_end maintain this so a flush window can wait out a
220
688
  // busy parent instead of queueing behind the whole turn (join extends
221
689
  // the window at most MAX_BUSY_EXTENSIONS times).
222
690
  let parentTurnActive = false;
691
+ const rpcEventSource = supervisor as LegionSupervisor & RpcEventSource;
223
692
  const runtime = createSessionRuntime({
224
693
  sendMessage: (message, options) => pi.sendMessage(message, options),
694
+ onStatusNodeClick: (nodeId) => statusNodeOpener?.(nodeId),
695
+ statusStrip: process.env.PI_LEGION_STRIP === "tree" ? "tree" : "railway",
696
+ eventBus: legionBus,
697
+ getTreeNodes: () => {
698
+ const identity = legionIdentity;
699
+ return identity
700
+ ? attachUsageTotals(identity.db, getSubtree(identity.db, identity.nodeId))
701
+ : [];
702
+ },
703
+ getTreeIdentity: (name) => {
704
+ const node = legionIdentity && getChildByName(legionIdentity.db, legionIdentity.nodeId, name);
705
+ return node ? {
706
+ nodeId: node.id,
707
+ parentId: node.parentId,
708
+ depth: node.depth,
709
+ supervisorName: name,
710
+ } : undefined;
711
+ },
712
+ subscribeSupervisorEvents: (name, listener, options) => rpcEventSource.onEvent?.(
713
+ name,
714
+ (event) => {
715
+ const nodeId = legionIdentity?.childNodeId(name);
716
+ if (nodeId) publishSupervisorEvent(legionBus, nodeId, event, options);
717
+ listener(event);
718
+ },
719
+ options,
720
+ ) ?? (() => {}),
225
721
  emitBusy: (active, label) => {
226
- // Sidebar annotation (research option C): herdr's own integration
227
- // reports the parent `idle` the moment its turn settles — exactly
228
- // when it is WAITING on subagents. Label the idle state so the
229
- // sidebar says `waiting` instead of reading done. Display-layer only;
230
- // the status icon stays herdr's call.
231
- parentLabeler.report(active ? label : undefined);
232
- try {
233
- pi.events.emit(
234
- "herdr:busy",
235
- active ? { active: true, label } : { active: false },
236
- );
237
- } catch {
238
- // herdr's busy overlay is optional; the TUI widget is the primary signal.
239
- }
722
+ try { pi.events.emit("pi-legion:busy", active ? { active: true, label } : { active: false }); }
723
+ catch { /* optional display integration */ }
240
724
  },
241
725
  });
726
+ sessionRuntime = runtime;
242
727
  blockedUi.set(runtime, {
243
728
  getConfirm: () => lastConfirm,
244
729
  notifyBlocked: (message) => {
@@ -252,69 +737,83 @@ export default function herdrSubagents(pi: ExtensionAPI) {
252
737
  { triggerTurn: true, deliverAs: "followUp" },
253
738
  );
254
739
  } catch {
255
- // Parent session is gone; the child pane stays open for a later steer.
740
+ // Parent session may already be gone.
256
741
  }
257
742
  },
258
743
  });
259
744
 
260
- registerProfileCommands(pi, {
261
- getModelRegistry: () => lastModelRegistry,
262
- });
263
- registerSummaryCommand(pi);
264
-
265
- // ── Master switch ────────────────────────────────────────────────────
266
- // Session-level override over `subagents.enabled` (user+project settings).
267
- // `undefined` means "follow settings" so /reload picks up file changes;
268
- // the toggle command is what writes to this variable.
269
- let sessionEnabledOverride: boolean | undefined;
270
- /** Effective enabled state: session override > merged settings. */
271
- const subagentsEnabled = (cwd: string): boolean => {
272
- if (sessionEnabledOverride !== undefined) return sessionEnabledOverride;
273
- try {
274
- return (
275
- loadSubagentSettings({
276
- userSettingsPath: path.join(getAgentDir(), "settings.json"),
277
- projectSettingsPath: path.join(cwd, ".pi", "settings.json"),
278
- }).enabled !== false
279
- );
280
- } catch (error) {
281
- console.warn(
282
- "[pi-legion] Unable to load settings while checking whether subagents are enabled; defaulting to enabled.",
283
- error,
284
- );
285
- return true;
286
- }
287
- };
288
-
289
- registerToggleCommand(pi, {
290
- sessionEnabled: () => subagentsEnabled(lastCwd),
291
- setSessionEnabled: (enabled) => {
292
- sessionEnabledOverride = enabled;
293
- },
294
- settingsEnabled: (cwd) =>
295
- loadSubagentSettings({
745
+ if (isChild && !allowNested) {
746
+ pi.on("session_shutdown", shutdownRetireAll);
747
+ return;
748
+ }
749
+ // #17: main session only. Children (PI_SUBAGENT_ROLE / PI_SUBAGENT_CHILD)
750
+ // must not count their own reads or inject this reminder. Nested children
751
+ // also have isChild set, so they skip this even though they continue below.
752
+ if (!isChild) {
753
+ registerScoutGuard(pi, {
754
+ loadSettings: (cwd) => loadSubagentSettings({
296
755
  userSettingsPath: path.join(getAgentDir(), "settings.json"),
297
756
  projectSettingsPath: path.join(cwd, ".pi", "settings.json"),
298
- }).enabled !== false,
299
- runningChildren: () =>
300
- runtime
301
- .activeJobs()
302
- .filter((job) => job.state === "working" || job.state === "blocked")
303
- .length,
304
- });
757
+ }),
758
+ });
759
+ }
760
+ // The parent transcript records every finish; the renderer keeps a
761
+ // completed child to one line so visibility does not cost a screen.
762
+ pi.registerMessageRenderer(SUBAGENT_NOTIFY_TYPE, renderSubagentNotice);
305
763
 
306
- // The human-facing roster: what the model gets injected into its system
307
- // prompt each turn, but on demand and with resolved models. The catalog
308
- // loading is the same function the tool uses, so the two listings cannot
309
- // drift apart. Re-loads on every invocation — cheap (a few stat+read calls)
310
- // and always fresh after an agent file is edited.
764
+ // User stop controls (/legion stop, idle Esc, `x` in /legion): a thin wrapper
765
+ // over the subagent tool's `stop` action — graceful retire of tracked children.
766
+ const stopChildren = async (params: { name?: string; all?: boolean }): Promise<{ ok: boolean; text: string }> => {
767
+ const result = await stopAction({
768
+ params,
769
+ runtime,
770
+ retire: async (name) => {
771
+ await runtime.get(name)?.retire?.();
772
+ },
773
+ });
774
+ const text = result.content.map((part) => (part.type === "text" ? part.text : "")).join("\n");
775
+ const details = result.details as { error?: string } | undefined;
776
+ runtime.refreshUi();
777
+ return { ok: !details?.error, text };
778
+ };
311
779
  const commandCatalogLoader = (input: {
312
780
  sessionCwd: string;
313
781
  runCwd: string;
314
782
  scope: AgentScope | undefined;
315
783
  }) => loadCatalog(input.sessionCwd, input.runCwd, input.scope);
316
- registerAgentsCommand(pi, { loadCatalog: commandCatalogLoader });
317
- registerTeamCommand(pi, { loadCatalog: commandCatalogLoader });
784
+ const liveOverlayController = registerLegionCommand(pi, {
785
+ bus: legionBus,
786
+ runtime,
787
+ supervisor: rpcEventSource,
788
+ stop: stopChildren,
789
+ commands: {
790
+ team: { loadCatalog: commandCatalogLoader },
791
+ agents: { loadCatalog: commandCatalogLoader },
792
+ toggle: {
793
+ sessionEnabled: () => subagentsEnabled(lastCwd),
794
+ setSessionEnabled: (enabled) => {
795
+ sessionEnabledOverride = enabled;
796
+ },
797
+ settingsEnabled: (cwd) =>
798
+ loadSubagentSettings({
799
+ userSettingsPath: path.join(getAgentDir(), "settings.json"),
800
+ projectSettingsPath: path.join(cwd, ".pi", "settings.json"),
801
+ }).enabled !== false,
802
+ runningChildren: () =>
803
+ runtime
804
+ .activeJobs()
805
+ .filter((job) => job.state === "working" || job.state === "blocked")
806
+ .length,
807
+ },
808
+ },
809
+ });
810
+ statusNodeOpener = createStatusNodeOpener(
811
+ pi,
812
+ liveOverlayController,
813
+ () => lastUi,
814
+ () => lastCwd,
815
+ (ui, nodeId) => void confirmAndStopNode(ui, { runtime, stop: stopChildren }, nodeId),
816
+ );
318
817
 
319
818
  pi.registerTool({
320
819
  name: "subagent",
@@ -328,34 +827,39 @@ export default function herdrSubagents(pi: ExtensionAPI) {
328
827
  async execute(
329
828
  _id,
330
829
  params,
331
- _signal,
830
+ signal,
332
831
  onUpdate,
333
832
  ctx: ExtensionContext,
334
833
  ): Promise<AgentToolResult<unknown>> {
335
834
  runtime.bind(ctx);
835
+ const action = params.action ?? "launch";
836
+ if (signal?.aborted && action !== "stop") {
837
+ await cascadeRetireAll();
838
+ return ok("interrupted (parent turn aborted)");
839
+ }
336
840
  // Master switch: refuse NEW launches with a pointer to the toggle;
337
841
  // control actions (status/collect/wait/…) must stay available so a
338
842
  // disable with live children remains observable and collectable.
339
- const action = params.action ?? "launch";
340
843
  if (!subagentsEnabled(ctx.cwd) && (action === "launch" || !action)) {
341
844
  return fail(
342
- "subagents are disabled (subagents.enabled: false or /subagents-toggle off). " +
343
- "Existing children are unaffected; re-enable with /subagents-toggle on.",
845
+ "subagents are disabled (subagents.enabled: false or /legion off). " +
846
+ "Existing children are unaffected; re-enable with /legion on.",
344
847
  ErrorCodes.DISABLED,
345
848
  );
346
849
  }
347
- const client = createHerdrClient();
348
- if (!(await client.available())) {
349
- return fail(
350
- "herdr is not available. Install it (https://herdr.dev) or check HERDR_BIN.",
351
- ErrorCodes.HERDR_UNAVAILABLE,
352
- );
353
- }
354
-
355
850
  const cwd = params.cwd ?? ctx.cwd;
356
- const store = new RunStore({ rootDir: path.join(cwd, ".pi-subagents") });
851
+ const store = runStoreFor(cwd);
357
852
  const catalog = loadCatalog(ctx.cwd, cwd, params.agentScope);
358
- const { agents, allAgents, settings, team, teamWarnings } = catalog;
853
+ const { allAgents, settings } = catalog;
854
+ const teamName = action === "launch" ? params.team : undefined;
855
+ const selectedTeam = teamName
856
+ ? applyTeam(allAgents, { ...settings, team: teamName }, { ...process.env, [TEAM_ENV]: teamName })
857
+ : { agents: catalog.agents, team: catalog.team, warnings: catalog.teamWarnings };
858
+ const agents = selectedTeam.agents;
859
+ const team = selectedTeam.team;
860
+ const teamWarnings = teamName
861
+ ? [...selectedTeam.warnings, ...catalog.utilityWarnings]
862
+ : selectedTeam.warnings;
359
863
  runtime.setJoinConfig({
360
864
  mode: settings.joinMode ?? DEFAULT_JOIN_MODE,
361
865
  flushMs: settings.joinFlushMs ?? DEFAULT_FLUSH_MS,
@@ -363,77 +867,133 @@ export default function herdrSubagents(pi: ExtensionAPI) {
363
867
  });
364
868
 
365
869
  if (action === "list") return ok(renderAgentList(agents, team, teamWarnings));
870
+ if (action === "diagnose") {
871
+ let settings: unknown;
872
+ try {
873
+ settings = JSON.parse(readFileSync(path.join(getAgentDir(), "settings.json"), "utf8"));
874
+ } catch {
875
+ settings = undefined;
876
+ }
877
+ const report = runLegionDoctor({
878
+ agentDir: getAgentDir(),
879
+ cwd,
880
+ storeRoot: store.rootDir,
881
+ expectedModels: expectedModelsFromSettings(settings),
882
+ sampleAgent: agents[0],
883
+ agents,
884
+ runtime: {
885
+ liveJobs: runtime.activeJobs().map((job) => ({
886
+ name: job.name,
887
+ state: job.state,
888
+ nodeId: job.nodeId,
889
+ })),
890
+ stopAvailable: true,
891
+ idleEscWired: true,
892
+ legionTools: ["subagent", "vexillatio"],
893
+ allowNestedEnv: process.env[ALLOW_NESTED_ENV] === "1",
894
+ },
895
+ });
896
+ return ok(report.text);
897
+ }
898
+
899
+ let activeLegion: LegionRuntimeIdentity | undefined;
900
+ try {
901
+ activeLegion = resolveLegionIdentity({ pi, supervisor, store, cwd, settings, eventBus: legionBus, cached: legionIdentity, sessionFile: ctx.sessionManager?.getSessionFile() });
902
+ if (!activeLegion && action === "launch" && !isChild) {
903
+ const plan = buildPlan(params);
904
+ if (plan.ok) activeLegion = createRootLegion({ pi, supervisor, store, cwd, task: plan.task, settings, maxDepth: effectiveMaxDepth(settings.legion?.maxDepth), eventBus: legionBus });
905
+ }
906
+ if (activeLegion) legionIdentity = activeLegion;
907
+ } catch (error) {
908
+ return fail(`legion runtime initialization failed: ${error instanceof Error ? error.message : String(error)}`, ErrorCodes.INVALID_PARAMS);
909
+ }
910
+ if (signal?.aborted && action !== "stop") {
911
+ await cascadeRetireAll();
912
+ return ok("interrupted (parent turn aborted)");
913
+ }
366
914
 
367
915
  // Control actions address an EXISTING child; launch-family actions
368
916
  // create new ones. The two share almost nothing, so they are separate.
369
917
  if (action !== "launch") {
370
- return controlAction({
918
+ const result = await controlAction({
371
919
  action,
372
920
  params,
373
- client,
921
+ signal,
922
+ supervisor,
923
+ uiProxy,
924
+ onBudgetRefused,
925
+ verifyRunner,
374
926
  store,
375
927
  cwd,
376
928
  agents,
377
929
  allAgents,
378
930
  team,
379
931
  runtime,
380
- layout,
381
932
  settings,
933
+ legion: activeLegion,
382
934
  });
935
+ if (signal?.aborted && action !== "stop") {
936
+ await cascadeRetireAll();
937
+ return ok("interrupted (parent turn aborted)");
938
+ }
939
+ return result;
383
940
  }
384
941
 
385
- return launchFamily({
942
+ const result = await launchFamily({
386
943
  params,
944
+ signal,
387
945
  ctx,
388
- client,
946
+ supervisor,
947
+ uiProxy,
948
+ onBudgetRefused,
949
+ verifyRunner,
389
950
  store,
390
951
  cwd,
391
952
  agents,
392
953
  allAgents,
393
954
  settings,
394
955
  team,
956
+ legion: activeLegion,
395
957
  runtime,
396
- layout,
397
958
  onUpdate,
398
959
  });
960
+ if (signal?.aborted) {
961
+ await cascadeRetireAll();
962
+ return ok("interrupted (parent turn aborted)");
963
+ }
964
+ return result;
399
965
  },
400
966
  });
401
967
 
402
- pi.on("before_agent_start", (event, ctx) => {
403
- if (isChild && !allowNested) return;
404
- // Master switch: when disabled, stop injecting the playbook and the
405
- // roster so the model is not coached toward a tool that will refuse.
406
- if (!subagentsEnabled(ctx?.cwd ?? lastCwd)) return;
407
- const tools = event.systemPromptOptions?.selectedTools;
408
- if (Array.isArray(tools) && !tools.includes("subagent")) return;
409
- if (ctx?.cwd) lastCwd = ctx.cwd;
410
- const catalog = loadCatalog(lastCwd, lastCwd, undefined);
411
- const parentContext = catalog.settings.parentContext?.trim();
412
- return {
413
- systemPrompt: `${event.systemPrompt}\n\n${PARENT_PLAYBOOK}\n\n${renderParentRoster(
414
- catalog.agents,
415
- catalog.team,
416
- catalog.teamWarnings,
417
- )}${parentContext ? `\n\n${parentContext}` : ""}`,
418
- };
419
- });
420
-
421
- pi.on("tool_call", (event, ctx) => {
422
- if (event.toolName !== "bash") return;
423
- // Master switch: when disabled, the user (or the model) may legitimately
424
- // drive herdr by hand; the guard only makes sense while we own dispatch.
425
- // Prefer the event's own ctx over lastCwd: it cannot go stale between
426
- // the bindUi events that maintain lastCwd.
427
- if (!subagentsEnabled(ctx?.cwd ?? lastCwd)) return;
428
- const command =
429
- typeof event.input.command === "string" ? event.input.command : "";
430
- const reason = forbiddenDispatchReason(command);
431
- if (!reason) return;
432
- return { block: true, reason: blockMessage(reason) };
968
+ let lastCtx: ExtensionContext | undefined;
969
+ let escUi: ExtensionUIContext | undefined;
970
+ let escUnsubscribe: (() => void) | undefined;
971
+ const idleEscStop = createIdleEscStop({
972
+ isIdle: () => lastCtx?.isIdle?.() ?? false,
973
+ editorEmpty: () => !(lastUi?.getEditorText?.() ?? "").trim(),
974
+ uiBusy: () => {
975
+ const tui = runtime.statusTui() as { hasOverlay?: () => boolean; getFocusedComponent?: () => unknown } | undefined;
976
+ if (!tui) return true;
977
+ if (tui.hasOverlay?.()) return true;
978
+ const focused = tui.getFocusedComponent?.() as { getText?: unknown } | null | undefined;
979
+ // Only the prompt editor may have focus: dialogs/selectors keep pi's Esc.
980
+ return !focused || typeof focused.getText !== "function" || !("onEscape" in focused);
981
+ },
982
+ liveCount: () => liveJobs(runtime.activeJobs()).length,
983
+ confirm: (title, message) => lastUi ? lastUi.confirm(title, message) : Promise.resolve(false),
984
+ stopAll: async () => {
985
+ const result = await stopChildren({ all: true });
986
+ lastUi?.notify(result.text, result.ok ? "info" : "error");
987
+ },
433
988
  });
434
-
435
989
  const bindUi = (_event: unknown, ctx: ExtensionContext): void => {
436
- lastModelRegistry = ctx.modelRegistry;
990
+ lastCtx = ctx;
991
+ if (ctx.hasUI && ctx.ui !== escUi && typeof ctx.ui?.onTerminalInput === "function") {
992
+ escUnsubscribe?.();
993
+ escUi = ctx.ui;
994
+ escUnsubscribe = ctx.ui.onTerminalInput(idleEscStop);
995
+ }
996
+ lastUi = ctx.hasUI ? ctx.ui : undefined;
437
997
  if (ctx.cwd) lastCwd = ctx.cwd;
438
998
  runtime.bind(ctx);
439
999
  lastConfirm =
@@ -442,7 +1002,30 @@ export default function herdrSubagents(pi: ExtensionAPI) {
442
1002
  : undefined;
443
1003
  };
444
1004
 
445
- pi.on("session_start", bindUi);
1005
+ uiProxy.onRequest((pending) => {
1006
+ const legionNodeId = legionIdentity?.childNodeId(pending.childName);
1007
+ if (legionNodeId) publishUIRequest(legionBus, legionNodeId, pending.request.method);
1008
+ const ui = lastUi;
1009
+ if (!ui) return;
1010
+ const request = pending.request;
1011
+ if (request.method === "confirm") return;
1012
+ if (request.method === "select") {
1013
+ void ui.select(request.title, request.options).then((value) => uiProxy.respond(pending.id, value === undefined ? { cancelled: true } : { value }));
1014
+ } else if (request.method === "input") {
1015
+ void ui.input(request.title, request.placeholder).then((value) => uiProxy.respond(pending.id, value === undefined ? { cancelled: true } : { value }));
1016
+ } else if (request.method === "editor") {
1017
+ void ui.editor(request.title, request.prefill).then((value) => uiProxy.respond(pending.id, value === undefined ? { cancelled: true } : { value }));
1018
+ } else if (request.method === "notify") ui.notify(request.message, request.notifyType);
1019
+ else if (request.method === "setStatus") ui.setStatus(request.statusKey, request.statusText);
1020
+ else if (request.method === "setWidget") ui.setWidget(request.widgetKey, request.widgetLines, { placement: request.widgetPlacement });
1021
+ else if (request.method === "setTitle") ui.setTitle(request.title);
1022
+ else if (request.method === "set_editor_text") ui.setEditorText(request.text);
1023
+ });
1024
+
1025
+ pi.on("session_start", (event, ctx) => {
1026
+ runtime.setParentIdle(ctx.isIdle?.() ?? true);
1027
+ bindUi(event, ctx);
1028
+ });
446
1029
  pi.on("session_info_changed", bindUi);
447
1030
  pi.on("turn_start", (event, ctx) => {
448
1031
  parentTurnActive = true;
@@ -452,29 +1035,32 @@ export default function herdrSubagents(pi: ExtensionAPI) {
452
1035
  parentTurnActive = false;
453
1036
  bindUi(event, ctx);
454
1037
  });
455
- pi.on("agent_start", bindUi);
456
- pi.on("agent_end", bindUi);
1038
+ pi.on("agent_start", (event, ctx) => {
1039
+ runtime.setParentIdle(false);
1040
+ bindUi(event, ctx);
1041
+ });
1042
+ pi.on("agent_end", (event, ctx) => {
1043
+ runtime.setParentIdle(true);
1044
+ bindUi(event, ctx);
1045
+ });
457
1046
  pi.on("input", bindUi);
458
1047
  pi.on("tool_result", (_event, ctx) => {
459
1048
  bindUi(_event, ctx);
460
1049
  runtime.refreshUi();
461
1050
  });
462
- pi.on("session_shutdown", () => {
463
- runtime.dispose();
464
- void parentLabeler.clear();
465
- });
1051
+ pi.on("session_shutdown", shutdownRetireAll);
466
1052
  }
467
1053
 
468
1054
  /**
469
1055
  * Load the effective agent catalog for a request.
470
1056
  *
471
- * User settings (`~/.pi/agent/settings.json`, including a loaded profile) merge
1057
+ * User settings (`~/.pi/agent/settings.json`) merge
472
1058
  * with project `.pi/settings.json`. Agent directories are resolved from the
473
1059
  * run's `cwd`. Bundled roles are included unless `subagents.disableBuiltins`
474
1060
  * opts out.
475
1061
  *
476
1062
  * Also returns the discovery directories, for listings that tell the user
477
- * WHERE the roles came from (the /subagents-agents command).
1063
+ * WHERE the roles came from (the /legion agents command).
478
1064
  */
479
1065
  export function loadCatalog(
480
1066
  sessionCwd: string,
@@ -488,6 +1074,7 @@ export function loadCatalog(
488
1074
  teamWarnings: string[];
489
1075
  projectAgentsDir: string | null;
490
1076
  builtinAgentsDir: string;
1077
+ utilityWarnings: string[];
491
1078
  } {
492
1079
  const settings = loadSubagentSettings({
493
1080
  userSettingsPath: path.join(getAgentDir(), "settings.json"),
@@ -498,6 +1085,14 @@ export function loadCatalog(
498
1085
  });
499
1086
  const overridden = applyAgentOverrides(discovery.agents, settings.agentOverrides);
500
1087
  const teamResult = applyTeam(overridden, settings);
1088
+ const utilityWarnings = [...new Map(
1089
+ [...overridden, ...teamResult.agents]
1090
+ .filter((agent) => agent.utility && agent.allowNestedSubagents)
1091
+ .map((agent) => [
1092
+ agent.name,
1093
+ `Utility agent "${agent.name}" sets allowNestedSubagents: true; utility identity takes precedence and nested dispatch is ignored.`,
1094
+ ] as const),
1095
+ ).values()];
501
1096
  const resolveDefaults = (items: AgentConfig[]): AgentConfig[] =>
502
1097
  applyDefaultOnBlocked(
503
1098
  applyDefaultModel(items, settings.defaultModel),
@@ -509,7 +1104,8 @@ export function loadCatalog(
509
1104
  agents: resolveDefaults(teamResult.agents),
510
1105
  allAgents: resolveDefaults(overridden),
511
1106
  team: teamResult.team,
512
- teamWarnings: teamResult.warnings,
1107
+ teamWarnings: [...teamResult.warnings, ...utilityWarnings],
1108
+ utilityWarnings,
513
1109
  settings,
514
1110
  projectAgentsDir: discovery.projectAgentsDir,
515
1111
  builtinAgentsDir: discovery.builtinAgentsDir ?? BUILTIN_AGENTS_DIR,
@@ -530,14 +1126,18 @@ export function loadCatalog(
530
1126
  async function controlAction(input: {
531
1127
  action: string;
532
1128
  params: SubagentParams;
533
- client: HerdrClient;
1129
+ signal?: AbortSignal;
1130
+ legion?: OrchestratorLegionContext;
1131
+ supervisor: LegionSupervisor;
1132
+ uiProxy: UIProxy;
1133
+ onBudgetRefused: NonNullable<ConstructorParameters<typeof Orchestrator>[0]["onBudgetRefused"]>;
1134
+ verifyRunner?: VerifyRunner;
534
1135
  store: RunStore;
535
1136
  cwd: string;
536
1137
  agents: AgentConfig[];
537
1138
  allAgents: AgentConfig[];
538
1139
  team: ActiveTeam;
539
1140
  runtime: SessionRuntime;
540
- layout: SessionLayout;
541
1141
  settings: ReturnType<typeof loadSubagentSettings>;
542
1142
  }): Promise<AgentToolResult<unknown>> {
543
1143
  const { action, params, store, cwd } = input;
@@ -557,6 +1157,7 @@ async function controlAction(input: {
557
1157
  }
558
1158
  return waitAction({
559
1159
  params,
1160
+ signal: input.signal,
560
1161
  runtime: input.runtime,
561
1162
  agents: input.agents,
562
1163
  allAgents: input.allAgents,
@@ -565,6 +1166,40 @@ async function controlAction(input: {
565
1166
  });
566
1167
  }
567
1168
 
1169
+ if (action === "stop") {
1170
+ return stopAction({
1171
+ params,
1172
+ runtime: input.runtime,
1173
+ retire: async (name) => {
1174
+ const job = input.runtime.get(name);
1175
+ if (job?.retire) {
1176
+ await job.retire();
1177
+ return;
1178
+ }
1179
+ // A completion watch can retire/release a target after stop snapshots
1180
+ // all names but before the serial loop reaches it. Treat that target as
1181
+ // stopped; only rebuild if its persisted record is not already retired.
1182
+ const found = findChild(input.store.listRuns(), name);
1183
+ if (!found) throw new SubagentError(`unknown child: ${name}`, ErrorCodes.NOT_FOUND);
1184
+ if (found.child.state === "retired") return;
1185
+ const orchestrator = new Orchestrator({
1186
+ supervisor: input.supervisor,
1187
+ uiProxy: input.uiProxy,
1188
+ runDir: input.store.runDir(found.runId),
1189
+ cwd: input.cwd,
1190
+ maxDepth: input.settings.legion?.maxDepth,
1191
+ childContext: input.settings.childContext,
1192
+ ...teamForChildren(input.team),
1193
+ ...(input.legion ? { legion: { ...input.legion, runId: found.runId, runStore: input.store } } : {}),
1194
+ onBudgetRefused: input.onBudgetRefused,
1195
+ });
1196
+ orchestrator.restore(found.run);
1197
+ await orchestrator.retire(name);
1198
+ await persistChild(input.store, found.runId, orchestrator, name);
1199
+ },
1200
+ });
1201
+ }
1202
+
568
1203
  if (!params.name) {
569
1204
  return fail(
570
1205
  `\`name\` is required for the "${action}" action.`,
@@ -576,14 +1211,18 @@ async function controlAction(input: {
576
1211
  if (!found) return unknownChild(params.name, store, cwd);
577
1212
 
578
1213
  const orchestrator = new Orchestrator({
579
- client: input.client,
1214
+ supervisor: input.supervisor,
1215
+ uiProxy: input.uiProxy,
580
1216
  runDir: store.runDir(found.runId),
581
1217
  cwd,
582
- layout: input.layout,
583
- workspaceId: process.env.HERDR_WORKSPACE_ID,
584
- parentPaneId: process.env.HERDR_PANE_ID,
1218
+ maxDepth: input.settings.legion?.maxDepth,
585
1219
  childContext: input.settings.childContext,
1220
+ verifyRunner: input.verifyRunner,
1221
+ worktreeBootstrap: input.settings.legion?.worktreeBootstrap,
1222
+ worktreeBootstrapTimeoutMs: input.settings.legion?.worktreeBootstrapTimeoutMs,
586
1223
  ...teamForChildren(input.team),
1224
+ ...(input.legion ? { legion: { ...input.legion, runId: found.runId, runStore: store } } : {}),
1225
+ onBudgetRefused: input.onBudgetRefused,
587
1226
  });
588
1227
  orchestrator.restore(found.run);
589
1228
 
@@ -594,9 +1233,20 @@ async function controlAction(input: {
594
1233
  orchestrator,
595
1234
  };
596
1235
 
597
- if (action === "status") return renderChild(found.child, "status");
1236
+ if (action === "status") {
1237
+ return ok(renderLiveChildStatus({
1238
+ name: found.child.name,
1239
+ state: found.child.state,
1240
+ agent: found.child.agent,
1241
+ kind: found.child.kind,
1242
+ sessionFile: found.child.sessionFile,
1243
+ execution: found.child.execution,
1244
+ acceptance: found.child.acceptance,
1245
+ alive: input.supervisor.isAlive(params.name),
1246
+ }));
1247
+ }
598
1248
  if (action === "steer") return steerChild(ctx);
599
- if (action === "collect") return collectChild(ctx);
1249
+ if (action === "collect") return collectChild(ctx, input.signal);
600
1250
  if (action === "retire") return retireChild(ctx);
601
1251
  if (action === "continue" || action === "resume") return reviveChild(ctx);
602
1252
 
@@ -609,7 +1259,9 @@ interface ChildContext {
609
1259
  params: SubagentParams;
610
1260
  /** Always set: `controlAction` rejects a request without a name. */
611
1261
  name: string;
612
- client: HerdrClient;
1262
+ supervisor: LegionSupervisor;
1263
+ uiProxy: UIProxy;
1264
+ onBudgetRefused: NonNullable<ConstructorParameters<typeof Orchestrator>[0]["onBudgetRefused"]>;
613
1265
  store: RunStore;
614
1266
  cwd: string;
615
1267
  agents: AgentConfig[];
@@ -631,14 +1283,7 @@ function unknownChild(
631
1283
  store: RunStore,
632
1284
  cwd: string,
633
1285
  ): AgentToolResult<unknown> {
634
- const known = store
635
- .listRuns()
636
- .filter((r) => {
637
- const owner = process.env.HERDR_PANE_ID?.trim();
638
- if (!owner) return true;
639
- return r.herdr.parentPaneId === owner;
640
- })
641
- .flatMap((r) => r.children.map((c) => c.name));
1286
+ const known = store.listRuns().flatMap((run) => run.children.map((child) => child.name));
642
1287
  return fail(
643
1288
  `unknown child: ${name} (no run under ${cwd}/.pi-subagents` +
644
1289
  `${known.length ? `; known here: ${known.join(", ")}` : ""}). ` +
@@ -667,12 +1312,17 @@ async function steerChild(
667
1312
  /** Wait for the child's current turn and derive its outcome from the session. */
668
1313
  async function collectChild(
669
1314
  ctx: ChildContext,
1315
+ signal?: AbortSignal,
670
1316
  ): Promise<AgentToolResult<unknown>> {
671
1317
  try {
672
- const pending = ctx.runtime.consumeCollect(ctx.name);
1318
+ if (signal?.aborted) return ok("interrupted (parent turn aborted)");
1319
+ const pending = signal
1320
+ ? ctx.runtime.consumeCollectInterruptible(ctx.name, signal)
1321
+ : ctx.runtime.consumeCollect(ctx.name);
673
1322
  if (pending) {
674
1323
  const collected = await pending;
675
- await persistChild(ctx.store, ctx.found.runId, ctx.orchestrator, ctx.name);
1324
+ if (!collected || signal?.aborted) return ok("interrupted (parent turn aborted)");
1325
+ await persistChild(ctx.store, ctx.found.runId, ctx.orchestrator, ctx.name, { preserveRetired: true });
676
1326
  return ok(renderCollect(ctx.name, collected));
677
1327
  }
678
1328
 
@@ -685,12 +1335,16 @@ async function collectChild(
685
1335
  ctx.found.child.agent,
686
1336
  );
687
1337
  const timeoutMs = agentDef?.timeoutMs ?? DEFAULTS.turnTimeoutMs;
688
- const collected = await ctx.orchestrator.collect(ctx.name, { timeoutMs });
689
- await persistChild(ctx.store, ctx.found.runId, ctx.orchestrator, ctx.name);
690
- if (
691
- shouldRecycleAfterCollect(collected.execution.status) &&
692
- !collected.blocked
693
- ) {
1338
+ const collected = await ctx.orchestrator.collect(ctx.name, {
1339
+ timeoutMs,
1340
+ ...(signal ? { signal } : {}),
1341
+ });
1342
+ if (signal?.aborted || collected.execution.reason === "aborted by parent") {
1343
+ return ok("interrupted (parent turn aborted)");
1344
+ }
1345
+ const retireAlreadyFinishedJob = shouldRecycleAfterCollect(collected.execution.status) && !collected.blocked;
1346
+ await persistChild(ctx.store, ctx.found.runId, ctx.orchestrator, ctx.name, { preserveRetired: !retireAlreadyFinishedJob });
1347
+ if (retireAlreadyFinishedJob) {
694
1348
  await ctx.orchestrator.retire(ctx.name);
695
1349
  await persistChild(ctx.store, ctx.found.runId, ctx.orchestrator, ctx.name);
696
1350
  }
@@ -701,7 +1355,7 @@ async function collectChild(
701
1355
  }
702
1356
  }
703
1357
 
704
- /** Recycle the child: snapshot the outcome, exit the agent, close the pane. */
1358
+ /** Retire the RPC child while preserving its outcome and session for resume. */
705
1359
  async function retireChild(
706
1360
  ctx: ChildContext,
707
1361
  ): Promise<AgentToolResult<unknown>> {
@@ -718,7 +1372,7 @@ async function retireChild(
718
1372
  await persistChild(ctx.store, ctx.found.runId, ctx.orchestrator, ctx.name);
719
1373
  return ok(
720
1374
  `Retired ${ctx.name} (execution=${child.execution?.status ?? "unknown"}). ` +
721
- `Pane closed; session kept for resume: ${child.sessionFile}`,
1375
+ `RPC process stopped; session kept for resume: ${child.sessionFile}`,
722
1376
  );
723
1377
  } catch (error) {
724
1378
  return fail(`retire failed: ${String(error)}`, ErrorCodes.RETIRE_FAILED);
@@ -729,12 +1383,12 @@ async function retireChild(
729
1383
  * Continue or resume a child.
730
1384
  *
731
1385
  * A live agent is prompted in place; an exited one is relaunched from its
732
- * persisted session so its context survives the pane being gone (F12).
1386
+ * persisted session so its context survives the process being gone (F12).
733
1387
  */
734
1388
  async function reviveChild(
735
1389
  ctx: ChildContext,
736
1390
  ): Promise<AgentToolResult<unknown>> {
737
- const { action, params, client, name, found, orchestrator } = ctx;
1391
+ const { action, params, supervisor, name, found, orchestrator } = ctx;
738
1392
 
739
1393
  if (!params.message) {
740
1394
  return fail(
@@ -756,23 +1410,11 @@ async function reviveChild(
756
1410
  }
757
1411
 
758
1412
  try {
759
- const agentState = await client.agentGet(name);
760
- if (agentState.ok) {
1413
+ if (supervisor.isAlive(name)) {
761
1414
  await orchestrator.steer(name, params.message);
762
1415
  followChild(ctx, { watch: true });
763
1416
  return ok(
764
- `${action === "resume" ? "Resumed" : "Continued"} ${name} (live agent prompted).`,
765
- );
766
- }
767
- // Only a DEFINITIVE not-found means the agent exited and must be
768
- // relaunched. Any other failure (a wedged or timed-out herdr CLI) says
769
- // nothing about the child — relaunching now would collide with the live
770
- // agent holding the name (F16) or spawn a duplicate.
771
- if (agentState.error.code !== ErrorCodes.NOT_FOUND) {
772
- return fail(
773
- `${action} failed: cannot determine whether ${name} is alive ` +
774
- `(herdr: ${agentState.error.message}). Retry shortly.`,
775
- agentState.error.code,
1417
+ `${action === "resume" ? "Resumed" : "Continued"} ${name} (live RPC child steered).`,
776
1418
  );
777
1419
  }
778
1420
 
@@ -784,8 +1426,7 @@ async function reviveChild(
784
1426
  });
785
1427
  followChild(ctx, { watch: true });
786
1428
  return ok(
787
- `Resumed ${handle.name} from its session (pane ${handle.paneId}). ` +
788
- `Context preserved from ${handle.sessionFile}`,
1429
+ `Resumed ${handle.name} from its session. Context preserved from ${handle.sessionFile}`,
789
1430
  );
790
1431
  } catch (error) {
791
1432
  return fail(`${action} failed: ${String(error)}`, ErrorCodes.START_FAILED);
@@ -800,19 +1441,22 @@ async function reviveChild(
800
1441
  async function launchFamily(input: {
801
1442
  params: SubagentParams;
802
1443
  ctx: ExtensionContext;
803
- client: HerdrClient;
1444
+ supervisor: LegionSupervisor;
1445
+ uiProxy: UIProxy;
1446
+ onBudgetRefused: NonNullable<ConstructorParameters<typeof Orchestrator>[0]["onBudgetRefused"]>;
1447
+ verifyRunner?: VerifyRunner;
804
1448
  store: RunStore;
805
1449
  cwd: string;
806
1450
  agents: AgentConfig[];
807
1451
  allAgents: AgentConfig[];
808
1452
  settings: ReturnType<typeof resolveSubagentSettings>;
809
1453
  team: ActiveTeam;
1454
+ legion?: LegionRuntimeIdentity;
810
1455
  runtime: SessionRuntime;
811
- layout: SessionLayout;
1456
+ signal?: AbortSignal;
812
1457
  onUpdate?: AgentToolUpdateCallback;
813
1458
  }): Promise<AgentToolResult<unknown>> {
814
- const { params, ctx, store, cwd, agents, settings, runtime, layout, onUpdate } =
815
- input;
1459
+ const { params, ctx, store, cwd, agents, settings, runtime, onUpdate } = input;
816
1460
 
817
1461
  const plan = buildPlan(params);
818
1462
  if (!plan.ok) return fail(plan.message, ErrorCodes.INVALID_PARAMS);
@@ -821,40 +1465,65 @@ async function launchFamily(input: {
821
1465
  content: [
822
1466
  {
823
1467
  type: "text",
824
- text: `playbook: ${plan.steps.map((s) => s.agent).join(", ")} → type-tab → pane → start → watch`,
1468
+ text: `playbook: ${plan.steps.map((s) => s.agent).join(", ")} → RPC launch → watch`,
825
1469
  },
826
1470
  ],
827
1471
  details: {},
828
1472
  });
829
1473
 
830
- const run = store.createRun({
831
- task: plan.task,
832
- cwd,
833
- herdr: {
834
- ...(process.env.HERDR_WORKSPACE_ID
835
- ? { workspaceId: process.env.HERDR_WORKSPACE_ID }
836
- : {}),
837
- ...(process.env.HERDR_PANE_ID
838
- ? { parentPaneId: process.env.HERDR_PANE_ID }
839
- : {}),
840
- },
841
- });
1474
+ const maxDepth = effectiveMaxDepth(settings.legion?.maxDepth);
1475
+ const legion = input.legion;
1476
+ let run = legion ? store.readRun(legion.runId) : null;
1477
+ if (!run) {
1478
+ run = store.createRun({
1479
+ task: plan.task,
1480
+ cwd,
1481
+ maxDepth,
1482
+ ...(legion ? { path: parseNestedPathEnv(process.env.PI_SUBAGENT_PARENT_PATH), nodeId: legion.nodeId, herdr: { supervisor: "rpc" as const, node_id: legion.nodeId } } : { herdr: { supervisor: "rpc" as const } }),
1483
+ });
1484
+ if (legion && legion.nodeId !== "root" && legion.parentRunId) {
1485
+ const node = getNode(legion.db, legion.nodeId);
1486
+ if (node?.sessionFile) {
1487
+ const parentRun = store.readRun(legion.parentRunId);
1488
+ if (parentRun && !parentRun.children.some((child) => child.nodeId === node.id)) {
1489
+ await store.addChild(legion.parentRunId, {
1490
+ name: node.name,
1491
+ nodeId: node.id,
1492
+ sessionFile: node.sessionFile,
1493
+ ownerToken: node.id,
1494
+ state: "working",
1495
+ spawnedAt: run.createdAt,
1496
+ agent: node.role,
1497
+ kind: node.kind as AgentConfig["kind"],
1498
+ ...(node.model ? { model: node.model } : {}),
1499
+ ...(node.worktreePath ? { worktreePath: node.worktreePath } : {}),
1500
+ });
1501
+ }
1502
+ }
1503
+ updateLegionRunId(legion.db, legion.nodeId, run.runId);
1504
+ }
1505
+ }
842
1506
  const orchestrator = new Orchestrator({
843
- client: input.client,
844
- runDir: store.runDir(run.runId),
1507
+ supervisor: input.supervisor,
1508
+ uiProxy: input.uiProxy,
1509
+ runDir: legion?.runDir ?? store.runDir(run.runId),
845
1510
  cwd,
846
- layout,
847
- // Enforce the session spawn budget so a runaway fan-out cannot exhaust
848
- // the machine (ErrorCodes.BUDGET_EXCEEDED).
1511
+ maxDepth: settings.legion?.maxDepth,
849
1512
  maxSpawns: settings.maxSubagentSpawnsPerSession ?? null,
850
- workspaceId: process.env.HERDR_WORKSPACE_ID,
851
- parentPaneId: process.env.HERDR_PANE_ID,
852
1513
  childContext: settings.childContext,
1514
+ verifyRunner: input.verifyRunner,
1515
+ worktreeBootstrap: settings.legion?.worktreeBootstrap,
1516
+ worktreeBootstrapTimeoutMs: settings.legion?.worktreeBootstrapTimeoutMs,
853
1517
  ...teamForChildren(input.team),
1518
+ ...(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 } } : {}),
1519
+ onBudgetRefused: input.onBudgetRefused,
854
1520
  });
1521
+ orchestrator.restore(run);
855
1522
 
856
1523
  const session: LaunchSession = {
857
1524
  params,
1525
+ signal: input.signal,
1526
+ teamOverride: params.team,
858
1527
  store,
859
1528
  agents,
860
1529
  allAgents: input.allAgents,
@@ -872,13 +1541,27 @@ async function launchFamily(input: {
872
1541
  timeoutByName: new Map(),
873
1542
  };
874
1543
 
1544
+ const interruptedResult = async (): Promise<AgentToolResult<unknown> | undefined> => {
1545
+ if (!input.signal?.aborted) return undefined;
1546
+ for (const name of session.handles) await abandonLaunchedChild(session, name);
1547
+ session.handles.length = 0;
1548
+ return {
1549
+ content: [{ type: "text", text: session.results.join("\n") || "interrupted (parent turn aborted)" }],
1550
+ details: { runId: run.runId, handles: [], async: params.async ?? true },
1551
+ };
1552
+ };
1553
+
875
1554
  // Every planned child is independent and launches concurrently.
876
1555
  await Promise.all(plan.steps.map((step) => launchStep(session, step)));
1556
+ const interruptedLaunch = await interruptedResult();
1557
+ if (interruptedLaunch) return interruptedLaunch;
877
1558
 
878
1559
  // Persist children first so a crash during the wait still leaves a record.
879
1560
  for (const name of session.handles) {
880
1561
  await persistChild(store, run.runId, orchestrator, name);
881
1562
  }
1563
+ const interruptedPersistence = await interruptedResult();
1564
+ if (interruptedPersistence) return interruptedPersistence;
882
1565
 
883
1566
  // Collect inline unless the caller asked for async (the default).
884
1567
  // Async: the session runtime watches each child and wakes the parent
@@ -888,11 +1571,16 @@ async function launchFamily(input: {
888
1571
  followLaunched(session, name, { watch: isAsync });
889
1572
  }
890
1573
  if (!isAsync) {
891
- await collectInline(session);
1574
+ await collectInline(session, input.signal);
892
1575
  for (const name of session.handles) {
893
1576
  const child = orchestrator
894
1577
  .childrenSnapshot()
895
1578
  .find((c) => c.name === name);
1579
+ if (input.signal?.aborted && (!child?.execution || !shouldRecycleAfterCollect(child.execution.status))) {
1580
+ followLaunched(session, name, { watch: true });
1581
+ await persistChild(store, run.runId, orchestrator, name);
1582
+ continue;
1583
+ }
896
1584
  if (
897
1585
  child?.execution &&
898
1586
  shouldRecycleAfterCollect(child.execution.status)
@@ -904,19 +1592,9 @@ async function launchFamily(input: {
904
1592
  }
905
1593
  }
906
1594
 
907
- // Record the type-tab the children joined so a later process can see it.
908
- if (orchestrator.tabId) {
909
- await store.updateRun(run.runId, (r) => {
910
- r.herdr = {
911
- ...r.herdr,
912
- tabId: orchestrator.tabId ?? undefined,
913
- };
914
- });
915
- }
916
-
917
1595
  if (isAsync && session.handles.length > 0) {
918
1596
  session.results.push(
919
- "↳ async: a completion message is queued when each child finishes. If this session is idle it wakes immediately; if it is still working the notice waits until the current turn ends. Running children show next to the input. Do not tell children to message the parent; do not poll just to wait.",
1597
+ "↳ async: a completion message is queued when each RPC child finishes. It wakes this session when idle; otherwise it is delivered after the current turn. Use wait or collect for results. Do not tell children to message the parent.",
920
1598
  );
921
1599
  }
922
1600
 
@@ -929,6 +1607,8 @@ async function launchFamily(input: {
929
1607
  /** Accumulated state for one launch-family request. */
930
1608
  interface LaunchSession {
931
1609
  params: SubagentParams;
1610
+ signal?: AbortSignal;
1611
+ teamOverride?: string;
932
1612
  store: RunStore;
933
1613
  agents: AgentConfig[];
934
1614
  allAgents: AgentConfig[];
@@ -950,8 +1630,9 @@ interface LaunchSession {
950
1630
  */
951
1631
  async function launchStep(
952
1632
  session: LaunchSession,
953
- step: { agent: string; task: string; model?: string; preset?: string; worktree?: boolean },
1633
+ step: { agent: string; task: string; model?: string; preset?: string; worktree?: boolean; verify?: string[] },
954
1634
  ): Promise<void> {
1635
+ if (session.signal?.aborted) return;
955
1636
  const agent = findAgent(session.agents, step.agent);
956
1637
  if (!agent) {
957
1638
  session.results.push(
@@ -981,6 +1662,34 @@ async function launchStep(
981
1662
  const effective = model.agent;
982
1663
  const resolved = model.resolved;
983
1664
 
1665
+ // `verify:` on the launch call becomes a criterion with an explicit
1666
+ // `command`, so `applyVerification` runs it in the child worktree at
1667
+ // collect time — the extension verifies the child's verdict instead
1668
+ // of trusting pasted test output. Blank entries are dropped; an empty
1669
+ // result is a no-op. Never mutated onto the shared agent object: the
1670
+ // catalog would hand the same reference to every later launch.
1671
+ const verifyCommands = (step.verify ?? session.params.verify ?? [])
1672
+ .map((command) => command.trim())
1673
+ .filter(Boolean);
1674
+ const launchedAgent = verifyCommands.length
1675
+ ? {
1676
+ ...effective,
1677
+ acceptance: {
1678
+ ...(effective.acceptance ?? { level: "attested" as const }),
1679
+ criteria: [
1680
+ ...(effective.acceptance?.criteria ?? []),
1681
+ {
1682
+ id: "task-verify",
1683
+ must: "Launcher-supplied verify commands pass (run by the extension, not the child)",
1684
+ evidence: ["verification-output"],
1685
+ severity: "required" as const,
1686
+ command: verifyCommands.join(" && "),
1687
+ },
1688
+ ],
1689
+ },
1690
+ }
1691
+ : effective;
1692
+
984
1693
  const violation = checkModelScope(
985
1694
  resolved.model,
986
1695
  session.settings.modelScope,
@@ -991,31 +1700,39 @@ async function launchStep(
991
1700
  return;
992
1701
  }
993
1702
 
994
- // Let `launch()` allocate: it consults the GLOBAL herdr name namespace.
995
- // Pre-allocating here from local state alone would bypass that check and
996
- // collide with another session's agent.
1703
+ // Let `launch()` allocate from the supervisor-local registry; the index
1704
+ // must not race separate subagent tool calls.
997
1705
  const handle = await session.orchestrator.launch({
998
- agent: effective,
1706
+ agent: launchedAgent,
999
1707
  task: step.task,
1708
+ ...(session.teamOverride ? { team: session.teamOverride } : {}),
1000
1709
  ...(resolved.model ? { model: resolved.model } : {}),
1001
1710
  // The origin decides whether an unacceptable model is refused or
1002
1711
  // dropped, and only this layer can tell them apart (a parent model
1003
1712
  // arrives as the same `dispatch` source as a per-run override).
1004
1713
  modelOrigin: model.modelOrigin,
1005
- ...(model.placement ? { placement: model.placement } : {}),
1006
1714
  worktree: resolveLaunchWorktree({
1007
1715
  roleDefault: effective.worktree,
1008
1716
  launch: session.params.worktree,
1009
1717
  step: step.worktree,
1010
1718
  }),
1011
1719
  });
1012
- await session.store.addChild(session.runId, handle.child);
1720
+ const recorded = session.store.findChild(session.runId, handle.name);
1721
+ if (recorded?.nodeId && recorded.nodeId === handle.child.nodeId) {
1722
+ await session.store.updateChild(session.runId, handle.name, (child) => Object.assign(child, handle.child));
1723
+ } else {
1724
+ await session.store.addChild(session.runId, handle.child);
1725
+ }
1013
1726
  session.handles.push(handle.name);
1014
1727
  if (effective.timeoutMs !== undefined) {
1015
1728
  session.timeoutByName.set(handle.name, effective.timeoutMs);
1016
1729
  }
1730
+ if (session.signal?.aborted) {
1731
+ await abandonLaunchedChild(session, handle.name);
1732
+ return;
1733
+ }
1017
1734
  session.results.push(
1018
- `▶ ${handle.name} (${effective.name}) pane=${handle.paneId}`,
1735
+ `▶ ${handle.name} (${effective.name}) supervisor=rpc`,
1019
1736
  );
1020
1737
  // An inherited model the target CLI cannot express is dropped on purpose
1021
1738
  // (the parent's pi model means nothing to cursor). Say so, or the user
@@ -1031,7 +1748,7 @@ async function launchStep(
1031
1748
  content: [
1032
1749
  {
1033
1750
  type: "text",
1034
- text: `playbook: ${handle.name} started in ${handle.paneId}`,
1751
+ text: `playbook: ${handle.name} started via RPC`,
1035
1752
  },
1036
1753
  ],
1037
1754
  details: {},
@@ -1068,7 +1785,7 @@ export function unknownAgentLine(
1068
1785
  ): string {
1069
1786
  const available = agents.map((a) => a.name).join(", ") || "none";
1070
1787
  if (teamName) {
1071
- return `✗ unknown agent "${requested}" in team "${teamName}". Available: ${available}. Switch with /subagents-team use ${teamName} (or "default").`;
1788
+ return `✗ unknown agent "${requested}" in team "${teamName}". Available: ${available}. Switch with /legion team use ${teamName} (or "default").`;
1072
1789
  }
1073
1790
  return `✗ unknown agent "${requested}". Available: ${available}`;
1074
1791
  }
@@ -1093,7 +1810,7 @@ export function unknownAgentLineForCatalog(
1093
1810
  }
1094
1811
 
1095
1812
  /**
1096
- * Resolve the model and placement one step will launch with.
1813
+ * Resolve the model one step will launch with.
1097
1814
  *
1098
1815
  * The precedence logic itself lives in `resolveStepModel`
1099
1816
  * (`src/agents/step-model.ts`), which is pure and unit-tested; this wrapper
@@ -1107,7 +1824,6 @@ function resolveStep(
1107
1824
  step: { model?: string; preset?: string },
1108
1825
  ): {
1109
1826
  resolved: ReturnType<typeof resolveModel>;
1110
- placement?: Placement;
1111
1827
  agent: AgentConfig;
1112
1828
  modelOrigin: ModelOrigin;
1113
1829
  } {
@@ -1121,19 +1837,27 @@ function resolveStep(
1121
1837
  });
1122
1838
  return {
1123
1839
  resolved: result.resolved,
1124
- ...(params.placement ? { placement: params.placement as Placement } : {}),
1125
1840
  agent: result.agent,
1126
1841
  modelOrigin: result.modelOrigin,
1127
1842
  };
1128
1843
  }
1129
1844
 
1130
1845
  /** Wait for each launched child's turn, appending its outcome. */
1131
- async function collectInline(session: LaunchSession): Promise<void> {
1846
+ async function collectInline(session: LaunchSession, signal?: AbortSignal): Promise<void> {
1132
1847
  for (const name of session.handles) {
1848
+ if (signal?.aborted) {
1849
+ session.results.push("interrupted (parent turn aborted)");
1850
+ return;
1851
+ }
1133
1852
  try {
1134
1853
  const collected = await session.orchestrator.collect(name, {
1135
1854
  timeoutMs: session.timeoutByName.get(name) ?? DEFAULTS.turnTimeoutMs,
1855
+ ...(signal ? { signal } : {}),
1136
1856
  });
1857
+ if (signal?.aborted || collected.execution.reason === "aborted by parent") {
1858
+ session.results.push("interrupted (parent turn aborted)");
1859
+ return;
1860
+ }
1137
1861
  session.results.push(renderCollect(name, collected));
1138
1862
  } catch (error) {
1139
1863
  session.results.push(`✗ ${name}: collect failed: ${String(error)}`);
@@ -1159,6 +1883,334 @@ type Plan =
1159
1883
  }
1160
1884
  | { ok: false; message: string };
1161
1885
 
1886
+ type UtilityTaskItem = Static<typeof TaskItem>;
1887
+ type VexillatioParams = Static<typeof VexillatioParams>;
1888
+
1889
+ type UtilityPlan =
1890
+ | { ok: true; steps: UtilityTaskItem[] }
1891
+ | { ok: false; message: string };
1892
+
1893
+ function buildUtilityPlan(params: VexillatioParams): UtilityPlan {
1894
+ if (!params.task.trim()) return { ok: false, message: "`task` must not be blank." };
1895
+ const steps: UtilityTaskItem[] = params.tasks?.length
1896
+ ? params.tasks
1897
+ : [{ agent: params.agent?.trim() || "scout", task: params.task }];
1898
+ const unsupportedVerify = steps.findIndex((item) => item.verify !== undefined);
1899
+ if (unsupportedVerify >= 0) {
1900
+ return {
1901
+ ok: false,
1902
+ message: `tasks[] step ${unsupportedVerify + 1} cannot include \`verify\`; vexillatio does not run verification commands.`,
1903
+ };
1904
+ }
1905
+ const invalid = steps.findIndex((item) => !item.agent.trim() || !item.task.trim());
1906
+ if (invalid >= 0) {
1907
+ return { ok: false, message: `tasks[] step ${invalid + 1} must include non-blank \`agent\` and \`task\`.` };
1908
+ }
1909
+ return { ok: true, steps };
1910
+ }
1911
+
1912
+ async function launchUtilityFamily(input: {
1913
+ pi: ExtensionAPI;
1914
+ ctx: ExtensionContext;
1915
+ params: VexillatioParams;
1916
+ runtime: SessionRuntime;
1917
+ signal?: AbortSignal;
1918
+ supervisor: LegionSupervisor;
1919
+ uiProxy: UIProxy;
1920
+ onBudgetRefused: NonNullable<ConstructorParameters<typeof Orchestrator>[0]["onBudgetRefused"]>;
1921
+ cwd: string;
1922
+ sessionCwd: string;
1923
+ isChild: boolean;
1924
+ legion?: LegionRuntimeIdentity;
1925
+ eventBus: LegionEventBus;
1926
+ onLegion: (identity: LegionRuntimeIdentity) => void;
1927
+ onUtilityChild: (retire: () => Promise<void>) => void;
1928
+ }): Promise<AgentToolResult<unknown>> {
1929
+ const plan = buildUtilityPlan(input.params);
1930
+ if (!plan.ok) return fail(plan.message, ErrorCodes.INVALID_PARAMS);
1931
+
1932
+ const catalog = loadCatalog(input.sessionCwd, input.cwd, undefined);
1933
+ const prepared: Array<{
1934
+ step: UtilityTaskItem;
1935
+ agent: AgentConfig;
1936
+ resolved: ReturnType<typeof resolveStepModel>["resolved"];
1937
+ modelOrigin: ModelOrigin;
1938
+ }> = [];
1939
+ for (const step of plan.steps) {
1940
+ const selectedAgent = findAgent(catalog.agents, step.agent);
1941
+ const fallbackAgent = findAgent(catalog.allAgents, step.agent);
1942
+ const teamMembers = catalog.settings.teams?.[catalog.team.name]?.members ?? [];
1943
+ const explicitlyMentionedByTeam = Boolean(
1944
+ !selectedAgent &&
1945
+ fallbackAgent &&
1946
+ catalog.team.name !== DEFAULT_TEAM &&
1947
+ teamMembers.some((member) => {
1948
+ const reference = typeof member === "string" ? member.trim() : member.agent.trim();
1949
+ if (reference === "*") return false;
1950
+ return findAgent(catalog.allAgents, reference)?.name === fallbackAgent.name;
1951
+ }),
1952
+ );
1953
+ if (explicitlyMentionedByTeam) {
1954
+ return fail(
1955
+ unknownAgentLineForCatalog(
1956
+ catalog.agents,
1957
+ catalog.allAgents,
1958
+ step.agent,
1959
+ catalog.team,
1960
+ catalog.settings,
1961
+ ),
1962
+ ErrorCodes.INVALID_PARAMS,
1963
+ );
1964
+ }
1965
+ const agent = selectedAgent ?? fallbackAgent;
1966
+ if (!agent?.utility) {
1967
+ return fail(
1968
+ `not a utility agent: ${step.agent} (mark it with 'utility: true')`,
1969
+ ErrorCodes.INVALID_PARAMS,
1970
+ );
1971
+ }
1972
+ if (agent.disabled) {
1973
+ return fail(`utility agent is disabled: ${agent.name}`, ErrorCodes.DISABLED);
1974
+ }
1975
+ try {
1976
+ const resolvedStep = resolveStepModel({
1977
+ agent,
1978
+ step: {
1979
+ ...(step.model !== undefined ? { model: step.model } : {}),
1980
+ ...(step.preset !== undefined ? { preset: step.preset } : {}),
1981
+ },
1982
+ params: { ...(input.params.model !== undefined ? { model: input.params.model } : {}) },
1983
+ ...(input.ctx.model ? { dispatchModel: `${input.ctx.model.provider}/${input.ctx.model.id}` } : {}),
1984
+ settings: catalog.settings,
1985
+ });
1986
+ const violation = checkModelScope(
1987
+ resolvedStep.resolved.model,
1988
+ catalog.settings.modelScope,
1989
+ resolvedStep.modelOrigin,
1990
+ );
1991
+ if (violation?.severity === "error") {
1992
+ return fail(violation.message, ErrorCodes.INVALID_PARAMS);
1993
+ }
1994
+ prepared.push({
1995
+ step,
1996
+ agent: resolvedStep.agent,
1997
+ resolved: resolvedStep.resolved,
1998
+ modelOrigin: resolvedStep.modelOrigin,
1999
+ });
2000
+ } catch (error) {
2001
+ return fail(`✗ ${step.agent}: ${error instanceof Error ? error.message : String(error)}`, ErrorCodes.INVALID_PARAMS);
2002
+ }
2003
+ }
2004
+
2005
+ const firstPrepared = prepared[0];
2006
+ const runTask = prepared.length === 1 && firstPrepared
2007
+ ? firstPrepared.step.task
2008
+ : `${prepared.length} parallel utility tasks`;
2009
+ const rootStore = runStoreFor(input.cwd);
2010
+ let legion = resolveLegionIdentity({
2011
+ pi: input.pi,
2012
+ supervisor: input.supervisor,
2013
+ store: rootStore,
2014
+ cwd: input.cwd,
2015
+ settings: catalog.settings,
2016
+ eventBus: input.eventBus,
2017
+ cached: input.legion,
2018
+ sessionFile: input.ctx.sessionManager?.getSessionFile(),
2019
+ });
2020
+ if (!legion && !input.isChild) {
2021
+ legion = createRootLegion({
2022
+ pi: input.pi,
2023
+ supervisor: input.supervisor,
2024
+ store: rootStore,
2025
+ cwd: input.cwd,
2026
+ task: `vexillatio: ${runTask}`,
2027
+ settings: catalog.settings,
2028
+ maxDepth: effectiveMaxDepth(catalog.settings.legion?.maxDepth),
2029
+ eventBus: input.eventBus,
2030
+ });
2031
+ }
2032
+ if (legion) input.onLegion(legion);
2033
+
2034
+ const store = legion?.runStore ?? rootStore;
2035
+ const run = legion
2036
+ ? store.readRun(legion.runId)
2037
+ : store.createRun({
2038
+ task: runTask,
2039
+ cwd: input.cwd,
2040
+ maxDepth: effectiveMaxDepth(catalog.settings.legion?.maxDepth),
2041
+ path: parseNestedPathEnv(process.env.PI_SUBAGENT_PARENT_PATH),
2042
+ herdr: { supervisor: "rpc" },
2043
+ });
2044
+ if (!run) return fail("vexillatio parent run is not available", ErrorCodes.NOT_FOUND);
2045
+ const runId = run.runId;
2046
+ const timeoutMs = input.params.timeoutMs ?? 600_000;
2047
+ // §2.6: continue numbering from this parent's existing children only —
2048
+ // do NOT scan store.listRuns() (that made names climb across sessions, #42).
2049
+ const existingNames: string[] = legion
2050
+ ? getChildren(legion.db, legion.nodeId).map((child) => child.name)
2051
+ : run.children.map((child) => child.name);
2052
+ const allocated = allocateSiblingNames(
2053
+ prepared.map((item) => item.agent.name),
2054
+ existingNames,
2055
+ (name) => input.supervisor.isAlive(name),
2056
+ );
2057
+ const names = new Map<UtilityTaskItem, string>();
2058
+ for (let i = 0; i < prepared.length; i++) names.set(prepared[i]!.step, allocated[i]!);
2059
+
2060
+ const results: string[] = [];
2061
+ const stillRunning = new Set<string>();
2062
+ const launches = await Promise.all(prepared.map(async (item) => {
2063
+ const cwd = item.step.cwd ? path.resolve(input.cwd, item.step.cwd) : input.cwd;
2064
+ const orchestrator = new Orchestrator({
2065
+ supervisor: input.supervisor,
2066
+ uiProxy: input.uiProxy,
2067
+ runDir: legion?.runDir ?? store.runDir(runId),
2068
+ cwd,
2069
+ maxDepth: catalog.settings.legion?.maxDepth,
2070
+ maxSpawns: catalog.settings.maxSubagentSpawnsPerSession ?? null,
2071
+ childContext: catalog.settings.childContext,
2072
+ team: catalog.team,
2073
+ onBudgetRefused: input.onBudgetRefused,
2074
+ ...(legion ? {
2075
+ legion: {
2076
+ db: legion.db,
2077
+ dbPath: legion.dbPath,
2078
+ nodeId: legion.nodeId,
2079
+ runId,
2080
+ parentNodeId: legion.parentNodeId,
2081
+ parentRunId: legion.parentRunId ?? undefined,
2082
+ runStore: store,
2083
+ settings: legion.settings,
2084
+ eventBus: legion.eventBus,
2085
+ },
2086
+ } : {}),
2087
+ });
2088
+ orchestrator.restore(run);
2089
+ try {
2090
+ const handle = await orchestrator.launch({
2091
+ agent: item.agent,
2092
+ task: item.step.task,
2093
+ name: names.get(item.step),
2094
+ ...(item.resolved.model ? { model: item.resolved.model } : {}),
2095
+ modelOrigin: item.modelOrigin,
2096
+ ...(item.step.worktree !== undefined ? { worktree: item.step.worktree } : {}),
2097
+ utilityDispatch: true,
2098
+ stripLegionIdentity: true,
2099
+ });
2100
+ await persistChild(store, runId, orchestrator, handle.name);
2101
+ let retirement: Promise<void> | undefined;
2102
+ const retire = (): Promise<void> => {
2103
+ if (!retirement) {
2104
+ const current = (async () => {
2105
+ await orchestrator.retire(handle.name);
2106
+ await persistChild(store, runId, orchestrator, handle.name);
2107
+ })();
2108
+ retirement = current;
2109
+ void current.catch(() => {
2110
+ if (retirement === current) retirement = undefined;
2111
+ });
2112
+ }
2113
+ return retirement;
2114
+ };
2115
+ input.onUtilityChild(async () => {
2116
+ try {
2117
+ await retire();
2118
+ } catch {
2119
+ // Shutdown cleanup is best-effort; the supervisor owns final process state.
2120
+ }
2121
+ });
2122
+ return {
2123
+ item,
2124
+ value: {
2125
+ name: handle.name,
2126
+ agent: item.agent.name,
2127
+ sessionFile: handle.sessionFile,
2128
+ orchestrator,
2129
+ retire,
2130
+ },
2131
+ };
2132
+ } catch (error) {
2133
+ return {
2134
+ item,
2135
+ error: error instanceof Error ? error.message : String(error),
2136
+ };
2137
+ }
2138
+ }));
2139
+ const handles: Array<{
2140
+ name: string;
2141
+ agent: string;
2142
+ sessionFile: string;
2143
+ orchestrator: Orchestrator;
2144
+ retire: () => Promise<void>;
2145
+ }> = [];
2146
+ for (const launch of launches) {
2147
+ if ("error" in launch) results.push(`✗ ${launch.item.agent.name}: ${launch.error}`);
2148
+ else handles.push(launch.value);
2149
+ }
2150
+
2151
+ const followUtility = (handle: (typeof handles)[number], watch: boolean): void => {
2152
+ followJob(input.runtime, {
2153
+ name: handle.name,
2154
+ runId,
2155
+ agent: handle.agent,
2156
+ sessionFile: handle.sessionFile,
2157
+ timeoutMs,
2158
+ orchestrator: handle.orchestrator,
2159
+ store,
2160
+ watch,
2161
+ retire: handle.retire,
2162
+ });
2163
+ };
2164
+ for (const handle of handles) followUtility(handle, false);
2165
+
2166
+ const collected = await Promise.all(handles.map(async (handle) => {
2167
+ try {
2168
+ const result = await handle.orchestrator.collect(handle.name, {
2169
+ timeoutMs,
2170
+ ...(input.signal ? { signal: input.signal } : {}),
2171
+ });
2172
+ await persistChild(store, runId, handle.orchestrator, handle.name);
2173
+ if (result.blocked || !shouldRecycleAfterCollect(result.execution.status)) {
2174
+ followUtility(handle, true);
2175
+ stillRunning.add(handle.name);
2176
+ } else {
2177
+ await handle.retire();
2178
+ input.runtime.release(handle.name);
2179
+ }
2180
+ const lines = [
2181
+ `── ${handle.name} ──`,
2182
+ `execution: ${result.execution.status}${result.execution.reason ? ` (${result.execution.reason})` : ""}`,
2183
+ result.output || "(no output)",
2184
+ ];
2185
+ if (result.outputFile) lines.push(`outputFile: ${result.outputFile}`);
2186
+ lines.push(`sessionFile: ${handle.sessionFile}`);
2187
+ return lines.join("\n");
2188
+ } catch (error) {
2189
+ if (input.supervisor.isAlive(handle.name)) {
2190
+ followUtility(handle, true);
2191
+ stillRunning.add(handle.name);
2192
+ } else {
2193
+ await handle.retire().catch(() => {});
2194
+ input.runtime.release(handle.name);
2195
+ }
2196
+ return `✗ ${handle.name}: collect failed: ${error instanceof Error ? error.message : String(error)}\nsessionFile: ${handle.sessionFile}`;
2197
+ }
2198
+ }));
2199
+ if (input.signal?.aborted) return ok("interrupted (parent turn aborted)");
2200
+ results.push(...collected);
2201
+ return {
2202
+ content: [{ type: "text", text: results.join("\n\n") || "No utility agents were launched." }],
2203
+ details: {
2204
+ runId,
2205
+ handles: handles.map(({ name, agent, sessionFile }) => ({ name, agent, sessionFile })),
2206
+ async: false,
2207
+ ...(stillRunning.size > 0
2208
+ ? { stillRunning: handles.filter(({ name }) => stillRunning.has(name)).map(({ name }) => name) }
2209
+ : {}),
2210
+ },
2211
+ };
2212
+ }
2213
+
1162
2214
  export function buildPlan(params: {
1163
2215
  agent?: string;
1164
2216
  task?: string;
@@ -1169,8 +2221,10 @@ export function buildPlan(params: {
1169
2221
  model?: string;
1170
2222
  preset?: string;
1171
2223
  worktree?: boolean;
2224
+ verify?: string[];
1172
2225
  }>;
1173
2226
  worktree?: boolean;
2227
+ verify?: string[];
1174
2228
  }): Plan {
1175
2229
  const hasSingle = isPresent(params.agent) && isPresent(params.task);
1176
2230
  const hasTasks = Boolean(params.tasks?.length);
@@ -1219,6 +2273,7 @@ interface PlanStep {
1219
2273
  model?: string;
1220
2274
  preset?: string;
1221
2275
  worktree?: boolean;
2276
+ verify?: string[];
1222
2277
  }
1223
2278
 
1224
2279
  /**
@@ -1264,13 +2319,14 @@ function followJob(
1264
2319
  orchestrator: Orchestrator;
1265
2320
  store: RunStore;
1266
2321
  watch: boolean;
2322
+ retire?: () => Promise<void>;
2323
+ treeIdentity?: { nodeId: string; parentId: string | null; depth: number };
1267
2324
  },
1268
2325
  ): void {
1269
2326
  const child = input.orchestrator
1270
2327
  .childrenSnapshot()
1271
2328
  .find((entry) => entry.name === input.name);
1272
2329
  const existing = runtime.get(input.name);
1273
- const nonPi = child?.kind && child.kind !== "pi";
1274
2330
  runtime.track({
1275
2331
  name: input.name,
1276
2332
  runId: input.runId,
@@ -1281,14 +2337,24 @@ function followJob(
1281
2337
  ...(child?.model ? { model: child.model } : {}),
1282
2338
  ...(child?.thinking !== undefined ? { thinking: child.thinking } : {}),
1283
2339
  ...(child?.worktreeBranch ? { worktreeBranch: child.worktreeBranch } : {}),
1284
- ...(nonPi
1285
- ? { probe: () => input.orchestrator.probeProgress(input.name) }
1286
- : {}),
1287
- collect: () =>
1288
- input.orchestrator.collect(input.name, { timeoutMs: input.timeoutMs }),
2340
+ ...(input.treeIdentity ? input.treeIdentity : child?.nodeId ? {
2341
+ nodeId: child.nodeId,
2342
+ parentId: child.nodeId.includes(".") ? child.nodeId.slice(0, child.nodeId.lastIndexOf(".")) : null,
2343
+ depth: child.nodeId.split(".").length - 1,
2344
+ } : {}),
2345
+ collect: (opts) =>
2346
+ input.orchestrator.collect(input.name, {
2347
+ timeoutMs: input.timeoutMs,
2348
+ ...(opts?.signal ? { signal: opts.signal } : {}),
2349
+ }),
2350
+ isAlive: () => input.orchestrator.isChildAlive(input.name),
1289
2351
  persist: async () =>
1290
2352
  persistChild(input.store, input.runId, input.orchestrator, input.name),
1291
2353
  retire: async () => {
2354
+ if (input.retire) {
2355
+ await input.retire();
2356
+ return;
2357
+ }
1292
2358
  await input.orchestrator.retire(input.name);
1293
2359
  await persistChild(
1294
2360
  input.store,
@@ -1337,6 +2403,13 @@ function followChild(ctx: ChildContext, opts: { watch: boolean }): void {
1337
2403
  orchestrator: ctx.orchestrator,
1338
2404
  store: ctx.store,
1339
2405
  watch: opts.watch,
2406
+ ...(ctx.found.child.nodeId ? {
2407
+ treeIdentity: {
2408
+ nodeId: ctx.found.child.nodeId,
2409
+ parentId: ctx.found.child.nodeId.includes(".") ? ctx.found.child.nodeId.slice(0, ctx.found.child.nodeId.lastIndexOf(".")) : null,
2410
+ depth: ctx.found.child.nodeId.split(".").length - 1,
2411
+ },
2412
+ } : {}),
1340
2413
  });
1341
2414
  }
1342
2415
 
@@ -1357,6 +2430,13 @@ function followLaunched(
1357
2430
  orchestrator: session.orchestrator,
1358
2431
  store: session.store,
1359
2432
  watch: opts.watch,
2433
+ ...(child?.nodeId ? {
2434
+ treeIdentity: {
2435
+ nodeId: child.nodeId,
2436
+ parentId: child.nodeId.includes(".") ? child.nodeId.slice(0, child.nodeId.lastIndexOf(".")) : null,
2437
+ depth: child.nodeId.split(".").length - 1,
2438
+ },
2439
+ } : {}),
1360
2440
  });
1361
2441
  }
1362
2442
 
@@ -1372,13 +2452,44 @@ async function persistChild(
1372
2452
  runId: string,
1373
2453
  orchestrator: Orchestrator,
1374
2454
  name: string,
2455
+ opts: { preserveRetired?: boolean } = {},
1375
2456
  ): Promise<void> {
1376
2457
  const child = orchestrator.childrenSnapshot().find((c) => c.name === name);
1377
2458
  if (!child) return;
1378
2459
  try {
1379
- await store.updateChild(runId, name, (c) => Object.assign(c, child));
2460
+ const recorded = store.findChild(runId, name);
2461
+ if (!recorded) {
2462
+ await store.addChild(runId, child);
2463
+ return;
2464
+ }
2465
+ if (recorded.nodeId !== child.nodeId) return;
2466
+ await store.updateChild(runId, name, (current) => {
2467
+ if (opts.preserveRetired && current.state === "retired" && child.state !== "retired") return;
2468
+ Object.assign(current, child);
2469
+ });
1380
2470
  } catch {
1381
- // Not persisted yet (or already removed) — the caller's own addChild wins.
2471
+ // Persistence is best-effort when a competing lifecycle callback wins the write.
2472
+ }
2473
+ }
2474
+
2475
+ async function abandonLaunchedChild(session: LaunchSession, name: string): Promise<void> {
2476
+ const child = session.orchestrator.childrenSnapshot().find((entry) => entry.name === name);
2477
+ if (!child) return;
2478
+ if (child.state === "retired") {
2479
+ session.runtime.release(name);
2480
+ return;
2481
+ }
2482
+ child.execution = { status: "failed", reason: "launch abandoned because parent turn aborted" };
2483
+ child.acceptance = { status: "rejected", level: "none", reason: "aborted by parent" };
2484
+ try {
2485
+ await session.orchestrator.retire(name);
2486
+ await persistChild(session.store, session.runId, session.orchestrator, name);
2487
+ session.runtime.release(name);
2488
+ session.results.push(`✗ ${name}: abandoned because parent turn aborted (child retired)`);
2489
+ } catch (error) {
2490
+ await persistChild(session.store, session.runId, session.orchestrator, name);
2491
+ followLaunched(session, name, { watch: true });
2492
+ session.results.push(`✗ ${name}: abandon retire failed: ${String(error)}; completion remains watched`);
1382
2493
  }
1383
2494
  }
1384
2495
 
@@ -1421,6 +2532,7 @@ export function resolveWaitTargets(
1421
2532
  export function renderWait(results: WaitResult[]): string {
1422
2533
  let done = 0;
1423
2534
  let running = 0;
2535
+ let interrupted = 0;
1424
2536
  const parts: string[] = [];
1425
2537
  for (const result of results) {
1426
2538
  if (result.snapshot) {
@@ -1429,23 +2541,83 @@ export function renderWait(results: WaitResult[]): string {
1429
2541
  } else if (result.stillRunning) {
1430
2542
  running += 1;
1431
2543
  parts.push(`── ${result.name} ──\nstill running`);
2544
+ } else if (result.interrupted) {
2545
+ interrupted += 1;
2546
+ parts.push(`── ${result.name} ──\ninterrupted (parent turn aborted)`);
1432
2547
  } else {
1433
2548
  parts.push(`── ${result.name} ──\nnot tracked (no live job, no finished cache)`);
1434
2549
  }
1435
2550
  }
1436
2551
  parts.push(`${done} done, ${running} still running`);
2552
+ if (interrupted > 0) parts.push(`${interrupted} interrupted`);
1437
2553
  return parts.join("\n");
1438
2554
  }
1439
2555
 
2556
+ /** Gracefully retire a named child or every child tracked by this session. */
2557
+ export async function stopAction(input: {
2558
+ params: { name?: string; all?: boolean };
2559
+ runtime: SessionRuntime;
2560
+ retire: (name: string) => Promise<void>;
2561
+ }): Promise<AgentToolResult<unknown>> {
2562
+ const { params, runtime, retire } = input;
2563
+ if (params.name && params.all) {
2564
+ return fail("`name` and `all` are mutually exclusive for stop.", ErrorCodes.INVALID_PARAMS);
2565
+ }
2566
+ if (!params.name && !params.all) {
2567
+ return fail("`name` or `all` is required for stop", ErrorCodes.INVALID_PARAMS);
2568
+ }
2569
+ const jobs = runtime.activeJobs();
2570
+ const active = jobs.filter((job) => job.state !== "retired");
2571
+ let targets: string[];
2572
+ if (params.all) {
2573
+ targets = active.map((job) => job.name);
2574
+ } else {
2575
+ const target = jobs.find((job) => job.name === params.name);
2576
+ if (!target) {
2577
+ return fail(`unknown child: ${params.name} (not tracked by this session).`, ErrorCodes.NOT_FOUND);
2578
+ }
2579
+ targets = target.state === "retired" ? [] : [target.name];
2580
+ }
2581
+ if (targets.length === 0) return ok("No non-retired children to stop.");
2582
+
2583
+ // Stop every watcher before any retire starts: a later target must not finish
2584
+ // its own watch (and notify/release) while an earlier target is being retired.
2585
+ for (const name of targets) runtime.cancelWatch(name);
2586
+
2587
+ const stopped: string[] = [];
2588
+ const failures: string[] = [];
2589
+ for (const name of targets) {
2590
+ try {
2591
+ // The runtime job may have been released after the target snapshot was
2592
+ // taken. The retire adapter can then resolve its persisted run record.
2593
+ await retire(name);
2594
+ runtime.release(name);
2595
+ stopped.push(name);
2596
+ } catch (error) {
2597
+ // If the graceful-retire chain fails, restore the completion watcher so a
2598
+ // still-live child is not orphaned by the explicit stop attempt.
2599
+ if (runtime.get(name)) runtime.rewatch(name);
2600
+ failures.push(`${name}: ${String(error)}`);
2601
+ }
2602
+ }
2603
+ const summary = stopped.length
2604
+ ? `Stopped ${stopped.length} child${stopped.length === 1 ? "" : "ren"}: ${stopped.join(", ")}.`
2605
+ : "No children were stopped.";
2606
+ return failures.length
2607
+ ? fail(`${summary}\nFailed to stop: ${failures.join("; ")}`, ErrorCodes.RETIRE_FAILED)
2608
+ : ok(summary);
2609
+ }
2610
+
1440
2611
  export async function waitAction(input: {
1441
2612
  params: SubagentParams;
2613
+ signal?: AbortSignal;
1442
2614
  runtime: SessionRuntime;
1443
2615
  agents: AgentConfig[];
1444
2616
  allAgents?: AgentConfig[];
1445
2617
  store: RunStore;
1446
2618
  cwd: string;
1447
2619
  }): Promise<AgentToolResult<unknown>> {
1448
- const { params, runtime, agents, allAgents = agents, store, cwd } = input;
2620
+ const { params, signal, runtime, agents, allAgents = agents, store, cwd } = input;
1449
2621
  const jobs = runtime.activeJobs();
1450
2622
  let targets = resolveWaitTargets(
1451
2623
  { name: params.name, all: params.all },
@@ -1480,7 +2652,7 @@ export async function waitAction(input: {
1480
2652
  );
1481
2653
  }),
1482
2654
  );
1483
- const results = await runtime.wait(targets.names, { timeoutMs });
2655
+ const results = await runtime.wait(targets.names, { timeoutMs, ...(signal ? { signal } : {}) });
1484
2656
  return ok(renderWait(results));
1485
2657
  }
1486
2658
 
@@ -1559,9 +2731,7 @@ function findChild(
1559
2731
  run: ReturnType<RunStore["listRuns"]>[number];
1560
2732
  child: NonNullable<ReturnType<RunStore["findChild"]>>;
1561
2733
  } | null {
1562
- const picked = pickChildByName(runs, name, {
1563
- parentPaneId: process.env.HERDR_PANE_ID,
1564
- });
2734
+ const picked = pickChildByName(runs, name);
1565
2735
  if (!picked) return null;
1566
2736
  return { runId: picked.run.runId, run: picked.run, child: picked.child };
1567
2737
  }
@@ -1572,7 +2742,7 @@ function renderChild(
1572
2742
  ): AgentToolResult<unknown> {
1573
2743
  const lines = [
1574
2744
  `${child.name} — state=${child.state} agent=${child.agent ?? "?"} kind=${child.kind ?? "?"}`,
1575
- `pane=${child.paneId ?? "(recycled)"}`,
2745
+ "supervisor=rpc",
1576
2746
  `session=${child.sessionFile}`,
1577
2747
  ];
1578
2748
  if (child.execution) {
@@ -1603,6 +2773,7 @@ function renderCollect(
1603
2773
  acceptance: {
1604
2774
  status: string;
1605
2775
  level?: string;
2776
+ reason?: string;
1606
2777
  pendingCriteria?: Array<{
1607
2778
  id: string;
1608
2779
  must: string;
@@ -1617,9 +2788,14 @@ function renderCollect(
1617
2788
  `execution: ${collected.execution.status}${collected.execution.reason ? ` (${collected.execution.reason})` : ""}`,
1618
2789
  `acceptance: ${collected.acceptance.status}${collected.acceptance.level ? ` (${collected.acceptance.level})` : ""}`,
1619
2790
  ];
2791
+ // The mechanical verify outcome (which command failed / which commands had
2792
+ // to pass) is the parent's primary trust anchor — surface it inline.
2793
+ if (collected.acceptance.reason && /^(verified by|verification failed)/.test(collected.acceptance.reason)) {
2794
+ lines.push(collected.acceptance.reason);
2795
+ }
1620
2796
  if (collected.blocked) {
1621
2797
  lines.push(
1622
- "blocked: waiting for a tool approval — the pane is still open. Approve in the parent confirm, or steer the child.",
2798
+ "blocked: waiting for child UI approval. Answer the pending request in the parent UI, or steer the child.",
1623
2799
  );
1624
2800
  }
1625
2801