pi-legion 3.1.0 → 5.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 (85) hide show
  1. package/CHANGELOG.md +382 -2
  2. package/CONTRIBUTING.md +4 -17
  3. package/README.md +127 -66
  4. package/agents/centurion.md +20 -16
  5. package/agents/scout.md +3 -1
  6. package/agents/worker.md +6 -8
  7. package/docs/contracts/declarative-phase-machine-v5.md +128 -0
  8. package/docs/contracts/display-liveness-v1.md +373 -0
  9. package/docs/contracts/drop-non-pi-kinds-v4.md +198 -0
  10. package/docs/contracts/esc-abort-v1.md +4 -4
  11. package/docs/contracts/esc-no-retire-v3.md +2 -2
  12. package/docs/contracts/launch-admission-queue-v1.md +839 -0
  13. package/docs/contracts/status-panel-v1.md +174 -0
  14. package/docs/contracts/subagent-renewal-v1.md +260 -0
  15. package/docs/headless-child-contract.md +6 -3
  16. package/docs/inprocess-supervisor.md +106 -27
  17. package/docs/legion-v2-contract.md +27 -2
  18. package/docs/performance-2026-10-19.md +199 -0
  19. package/docs/spawn-mode-visibility-v1.md +4 -1
  20. package/docs/vexillatio-contract.md +3 -1
  21. package/index.ts +1155 -120
  22. package/package.json +5 -3
  23. package/skills/pi-legion/SKILL.md +2 -3
  24. package/src/agents/agents.ts +310 -46
  25. package/src/agents/overrides.ts +3 -2
  26. package/src/agents/presets.ts +14 -40
  27. package/src/agents/settings.ts +32 -95
  28. package/src/agents/step-model.ts +7 -22
  29. package/src/api.ts +1 -35
  30. package/src/extension/child-guard.ts +25 -1
  31. package/src/extension/event-bus.ts +80 -8
  32. package/src/extension/legion-commands.ts +41 -17
  33. package/src/extension/legion-doctor.ts +1 -1
  34. package/src/extension/legion-stop.ts +39 -13
  35. package/src/extension/legion-tools.ts +87 -25
  36. package/src/extension/liveness.ts +200 -0
  37. package/src/extension/notify.ts +136 -4
  38. package/src/extension/playbook.ts +4 -4
  39. package/src/extension/recycle.ts +4 -0
  40. package/src/extension/runtime.ts +435 -16
  41. package/src/extension/slash.ts +0 -21
  42. package/src/extension/summary.ts +14 -5
  43. package/src/extension/timing.ts +8 -4
  44. package/src/legion/db.ts +165 -1
  45. package/src/legion/events.ts +81 -16
  46. package/src/legion/nodes.ts +13 -2
  47. package/src/legion/phase-conditions.ts +164 -0
  48. package/src/legion/phase-machine.ts +403 -0
  49. package/src/legion/rebuild.ts +7 -1
  50. package/src/legion/state-machine.ts +219 -115
  51. package/src/legion/usage.ts +58 -4
  52. package/src/runs/args.ts +32 -18
  53. package/src/runs/extension-inheritance.ts +220 -0
  54. package/src/runs/orchestrator.ts +1292 -96
  55. package/src/runs/progress-signal.ts +137 -0
  56. package/src/runs/store-lookup.ts +270 -0
  57. package/src/runs/worktree.ts +190 -21
  58. package/src/shared/ignored-kind.ts +57 -0
  59. package/src/shared/progress.ts +11 -0
  60. package/src/shared/session.ts +199 -19
  61. package/src/shared/types.ts +138 -69
  62. package/src/supervisor/admission-queue.ts +516 -0
  63. package/src/supervisor/child-context.ts +19 -0
  64. package/src/supervisor/inprocess-supervisor.ts +137 -38
  65. package/src/supervisor/proc-memory.ts +67 -0
  66. package/src/supervisor/routing-supervisor.ts +60 -2
  67. package/src/supervisor/rpc-supervisor.ts +2 -2
  68. package/src/tui/session-messages-file.ts +100 -0
  69. package/src/tui/status.ts +91 -18
  70. package/src/tui/tree-view.ts +84 -3
  71. package/src/tui/viz/layout.ts +54 -3
  72. package/src/tui/viz/model.ts +67 -14
  73. package/src/tui/viz/palette.ts +11 -3
  74. package/src/tui/viz/panels.ts +222 -8
  75. package/src/tui/viz/view.ts +110 -23
  76. package/agents/advisor.md +0 -110
  77. package/agents/designer.md +0 -87
  78. package/agents/planner.md +0 -53
  79. package/agents/prototype.md +0 -72
  80. package/agents/reviewer.md +0 -113
  81. package/src/backends/legacy/client.ts +0 -638
  82. package/src/backends/legacy/cursor-chat.ts +0 -348
  83. package/src/backends/legacy/runner.ts +0 -101
  84. package/src/backends/legacy/types.ts +0 -138
  85. package/src/runs/kind.ts +0 -260
package/index.ts CHANGED
@@ -21,7 +21,8 @@ import type {
21
21
  import { Type, type Static } from "typebox";
22
22
  import { RpcSupervisor } from "./src/supervisor/rpc-supervisor.ts";
23
23
  import { InProcessSupervisor } from "./src/supervisor/inprocess-supervisor.ts";
24
- import { RoutingSupervisor } from "./src/supervisor/routing-supervisor.ts";
24
+ import { runAtAbsoluteDepth } from "./src/supervisor/inprocess-depth.ts";
25
+ import { notifyInProcessSlotReleased, RoutingSupervisor } from "./src/supervisor/routing-supervisor.ts";
25
26
  import type { LegionSupervisor } from "./src/supervisor/types.ts";
26
27
  import { InMemoryUIProxy, type UIProxy } from "./src/supervisor/ui-proxy.ts";
27
28
  import { discoverAgents, findAgent, formatAgentRoster, BUILTIN_AGENTS_DIR } from "./src/agents/agents.ts";
@@ -48,19 +49,26 @@ import { resolveTeamContext } from "./src/agents/context.ts";
48
49
  import { createSessionRuntime, renderLiveChildStatus, type SessionRuntime, shouldRecycleAfterCollect, type WaitResult } from "./src/extension/runtime.ts";
49
50
  import { createDbEventSource, createLegionEventBus, type DbEventSource, type LegionEventBus } from "./src/extension/event-bus.ts";
50
51
  import { LegionPostman, registerLegionTools, type LegionMailCommand } from "./src/extension/legion-tools.ts";
52
+ import type { PhaseMachine } from "./src/legion/phase-machine.ts";
51
53
  import { LEGION_DB_ENV, LEGION_NODE_ID_ENV } from "./src/runs/args.ts";
52
54
  import type { VerifyRunner } from "./src/runs/acceptance.ts";
53
55
  import { legionDbPath, openLegionDb, type DatabaseSync } from "./src/legion/db.ts";
54
- import { appendEvent } from "./src/legion/events.ts";
56
+ import { appendEvent, maxEventId } from "./src/legion/events.ts";
55
57
  import { attachUsageTotals, upsertUsage } from "./src/legion/usage.ts";
56
58
  import { getChildByName, getChildren, getNode, getSubtree, insertNode, type LegionNode, updateNode } from "./src/legion/nodes.ts";
57
59
  import { DEFAULT_LEGION_SETTINGS, type LegionSettings } from "./src/shared/types.ts";
58
60
  import { parseNestedPathEnv } from "./src/shared/nested-path.ts";
59
- import { allocateSiblingNames, isValidAgentName, makeName } from "./src/shared/name.ts";
60
- import { deriveOutcome, isLastTurnComplete, parseSessionFile } from "./src/shared/session.ts";
61
+ import { allocateSiblingNames, isValidAgentName } from "./src/shared/name.ts";
62
+ import {
63
+ deriveOutcome,
64
+ isLastTurnComplete,
65
+ NO_ASSISTANT_ABORT_REASON,
66
+ parseSessionFile,
67
+ } from "./src/shared/session.ts";
61
68
  import { restoreHostNodeOptions } from "./src/supervisor/child-memory.ts";
62
69
  import { currentChildContext } from "./src/supervisor/child-context.ts";
63
70
  import { setVerifyQueueListener } from "./src/runs/verify-queue.ts";
71
+ import { setLaunchQueueListener } from "./src/supervisor/admission-queue.ts";
64
72
  import { applyEventRetention, checkpoint, formatHygieneReport, hygieneEnabled, runStartupHygiene } from "./src/runs/hygiene.ts";
65
73
  import {
66
74
  createStatusNodeOpener,
@@ -80,11 +88,14 @@ import {
80
88
  } from "./src/extension/blocked.ts";
81
89
  import { formatAlreadyRecycled } from "./src/extension/recycle.ts";
82
90
  import {
91
+ completionStatusOf,
92
+ type RenewalProposal,
83
93
  SUBAGENT_NOTIFY_TYPE,
84
94
  } from "./src/extension/notify.ts";
85
95
  import { renderSubagentNotice } from "./src/extension/notice-renderer.ts";
86
96
  import { renderSpawnResult } from "./src/extension/spawn-render.ts";
87
97
  import { PARENT_PLAYBOOK, TOOL_DESCRIPTION } from "./src/extension/playbook.ts";
98
+ import { CHILD_ROLE_ENV } from "./src/extension/child-guard.ts";
88
99
  import { configureSessionErrors, recordSessionError } from "./src/extension/session-errors.ts";
89
100
  import { getAgentDir } from "./src/agents/paths.ts";
90
101
  import { expectedModelsFromSettings, parseDoctorSections, runLegionDoctor } from "./src/extension/legion-doctor.ts";
@@ -95,19 +106,32 @@ import {
95
106
  type OrchestratorLegionContext,
96
107
  } from "./src/runs/orchestrator.ts";
97
108
  import { RunStore, pickChildByName } from "./src/runs/store.ts";
109
+ import {
110
+ candidateStoreRoots,
111
+ resolveChildAcrossStores,
112
+ unknownChildMessage,
113
+ type CandidateStore,
114
+ } from "./src/runs/store-lookup.ts";
98
115
  import { RUN_OWNER_FILE, ownerState } from "./src/runs/owner.ts";
116
+ import { measureProgress, readSessionLines } from "./src/runs/progress-signal.ts";
99
117
  import { resolveLaunchWorktree } from "./src/runs/worktree.ts";
100
118
  import {
101
119
  type AgentConfig,
102
120
  type AgentScope,
121
+ type ChildRecord,
103
122
  DEFAULTS,
123
+ DEFAULT_MAX_RENEWALS,
104
124
  DEFAULT_ON_BLOCKED,
125
+ DEFAULT_RENEWABLE,
105
126
  ErrorCodes,
127
+ type Execution,
106
128
  type ModelOrigin,
107
129
  type RetireOptions,
108
130
  type SpawnMode,
109
131
  SubagentError,
110
132
  type SubagentsSettings,
133
+ unknownChildError,
134
+ type Handle,
111
135
  } from "./src/shared/types.ts";
112
136
 
113
137
  // ---------------------------------------------------------------------------
@@ -117,7 +141,12 @@ import {
117
141
  const TaskItem = Type.Object({
118
142
  agent: Type.String({ description: "Name of the agent to invoke" }),
119
143
  task: Type.String({ description: "Task to delegate" }),
120
- cwd: Type.Optional(Type.String()),
144
+ cwd: Type.Optional(
145
+ Type.String({
146
+ description:
147
+ "Directory the child records live under (`<cwd>/.pi-subagents`), used by launch and by every control action. Defaults to the session cwd. A name miss searches the neighbouring stores too, so this is rarely needed.",
148
+ }),
149
+ ),
121
150
  model: Type.Optional(Type.String()),
122
151
  preset: Type.Optional(
123
152
  Type.String({ description: "Named kind+model+thinking preset" }),
@@ -173,7 +202,12 @@ const SubagentParams = Type.Object({
173
202
  preset: Type.Optional(
174
203
  Type.String({ description: "Named kind+model+thinking preset" }),
175
204
  ),
176
- cwd: Type.Optional(Type.String()),
205
+ cwd: Type.Optional(
206
+ Type.String({
207
+ description:
208
+ "Directory the child records live under (`<cwd>/.pi-subagents`) for launch and every control action. Defaults to the session cwd. A name miss also searches neighbouring stores, so setting this is rarely needed.",
209
+ }),
210
+ ),
177
211
  worktree: Type.Optional(
178
212
  Type.Boolean({
179
213
  description:
@@ -234,10 +268,25 @@ const SubagentParams = Type.Object({
234
268
  type SubagentParams = Static<typeof SubagentParams>;
235
269
 
236
270
  const VexillatioParams = Type.Object({
237
- task: Type.String({ description: "Recon task: one clear question list" }),
271
+ // Optional on purpose: `tasks[]` is a complete request on its own, exactly like
272
+ // `subagent({ tasks })`. Leaving this required made pi's own argument check
273
+ // reject `{ tasks: [...] }` before `execute` ran, so every `tasks[]`-only call
274
+ // failed with `task: must have required properties task` and had to be retried
275
+ // with a dummy `task` label (models copy the `subagent` shape).
276
+ task: Type.Optional(
277
+ Type.String({
278
+ description:
279
+ "Recon task: one clear question list. Optional when `tasks[]` is provided (then it only labels the parent run).",
280
+ }),
281
+ ),
238
282
  agent: Type.Optional(Type.String({ description: "Utility agent name (default: scout)" })),
239
283
  tasks: Type.Optional(Type.Array(TaskItem, { description: "Parallel recon launches" })),
240
- cwd: Type.Optional(Type.String()),
284
+ cwd: Type.Optional(
285
+ Type.String({
286
+ description:
287
+ "Directory the child records live under (`<cwd>/.pi-subagents`) for launch and every control action. Defaults to the session cwd. A name miss also searches neighbouring stores, so setting this is rarely needed.",
288
+ }),
289
+ ),
241
290
  model: Type.Optional(Type.String()),
242
291
  timeoutMs: Type.Optional(Type.Number({ description: "Per-child wait in ms, default 600000" })),
243
292
  });
@@ -309,8 +358,159 @@ function currentProcessSpawnMode(): SpawnMode {
309
358
  return currentChildContext()?.spawnMode ?? "rpc";
310
359
  }
311
360
 
361
+ /**
362
+ * The frozen interruption head line (`test/unit/teams.test.ts` matches it, and
363
+ * `esc-no-retire-v3` records why it leads every interrupted result).
364
+ */
365
+ const INTERRUPTED_HEAD = "interrupted (parent turn ended; children still running)";
366
+
312
367
  function notice(pi: ExtensionAPI, text: string): void {
313
- pi.sendMessage({ customType: SUBAGENT_NOTIFY_TYPE, content: text, display: true }, { triggerTurn: true, deliverAs: "followUp" });
368
+ // #273: `sendMessage` is a host ACTION and pi throws
369
+ // "Extension runtime not initialized. Action methods cannot be called during
370
+ // extension loading." if it is reached before the runtime is up. An error
371
+ // handler that is more fatal than the error it reports turns "a resume
372
+ // failed" into "the whole agent process died", which is what happened on the
373
+ // centurion recovery path. Degrade to stderr instead; the caller still gets
374
+ // the fact, and nothing crashes.
375
+ try {
376
+ pi.sendMessage({ customType: SUBAGENT_NOTIFY_TYPE, content: text, display: true }, { triggerTurn: true, deliverAs: "followUp" });
377
+ } catch (error) {
378
+ const reason = error instanceof Error ? error.message : String(error);
379
+ console.error(`[pi-legion] notice not delivered (${reason}): ${text}`);
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Launch-admission-queue bounds from settings (issue #266, contract D6-a/D10).
385
+ *
386
+ * Frozen key names are `subagents.inprocess.maxQueuedLaunches` /
387
+ * `subagents.inprocess.launchQueueTimeoutMs`. An absent key is *omitted* rather
388
+ * than defaulted here: the single source of the defaults is
389
+ * `src/supervisor/admission-queue.ts`, and passing a literal 32/900000 from the
390
+ * adapter would create a second copy that can drift from the docs and sentinel.
391
+ */
392
+ function launchQueueBounds(settings: {
393
+ inprocess?: { maxQueuedLaunches?: number; launchQueueTimeoutMs?: number };
394
+ }): { maxPending?: number; timeoutMs?: number } {
395
+ const bounds: { maxPending?: number; timeoutMs?: number } = {};
396
+ if (settings.inprocess?.maxQueuedLaunches !== undefined) bounds.maxPending = settings.inprocess.maxQueuedLaunches;
397
+ if (settings.inprocess?.launchQueueTimeoutMs !== undefined) bounds.timeoutMs = settings.inprocess.launchQueueTimeoutMs;
398
+ return bounds;
399
+ }
400
+
401
+ /**
402
+ * D9/§13.4: the one user-visible line per queued / admitted / expired / stopped
403
+ * launch.
404
+ *
405
+ * Deliberately `triggerTurn: false` (unlike {@link notice}): admission is a
406
+ * mid-turn fact about work the caller already asked for, so waking the parent
407
+ * with a fresh turn for every queue transition would re-enter the agent for a
408
+ * purely cosmetic line. Anything the caller must act on still arrives through
409
+ * the tool result and, for `async:false`, the inline `↳ waited Ns` line.
410
+ */
411
+ function launchQueueNoticeSink(pi: ExtensionAPI): (line: string) => void {
412
+ return (line: string) => {
413
+ try {
414
+ pi.sendMessage(
415
+ { customType: SUBAGENT_NOTIFY_TYPE, content: line, display: true },
416
+ { triggerTurn: false },
417
+ );
418
+ } catch {
419
+ // Cosmetic: a gone parent session must never break admission.
420
+ }
421
+ };
422
+ }
423
+
424
+ /**
425
+ * D9-1: the launch line's queue suffix, or `""` for a launch that spawned now.
426
+ *
427
+ * `▶ scout-7 (scout) supervisor=inprocess queued(3)` — the position is what the
428
+ * operator needs to know the launch is waiting rather than broken. Only the
429
+ * trailing segment is added: the line's published prefix and `details.spawns`
430
+ * (a renderer input) keep their existing shapes.
431
+ */
432
+ function launchLineQueueSuffix(handle: Pick<Handle, "queued" | "queuePosition">): string {
433
+ if (handle.queued !== true) return "";
434
+ return handle.queuePosition === undefined ? " queued" : ` queued(${handle.queuePosition})`;
435
+ }
436
+
437
+ /**
438
+ * D8/A20: the honest wait line for an `async:false` child that queued.
439
+ *
440
+ * Read from the admitted `ChildRecord.queueWaitedMs` — never re-derived from a
441
+ * clock here, or the number would describe the adapter's own latency instead of
442
+ * the queue's. `""` when the child never waited.
443
+ */
444
+ function queuedWaitLine(child: { name: string; queueWaitedMs?: number } | undefined): string {
445
+ if (!child || child.queueWaitedMs === undefined || child.queueWaitedMs <= 0) return "";
446
+ return `↳ ${child.name} waited ${Math.round(child.queueWaitedMs / 1000)}s in the launch queue`;
447
+ }
448
+
449
+ /**
450
+ * D6/D7: report a queue that an abort cleared out.
451
+ *
452
+ * "Empty queue and zero spawns" is only half the contract; the caller must also
453
+ * learn that N launches it asked for never ran. One line, names in `seq` order.
454
+ */
455
+ function launchQueueFlushLine(flushed: readonly string[]): string {
456
+ if (flushed.length === 0) return "";
457
+ return `${flushed.length} queued launch(es) dropped: ${flushed.join(", ")}`;
458
+ }
459
+
460
+ /**
461
+ * #238: create and start the DB event source that feeds the legion bus on attach.
462
+ *
463
+ * This is the production attach path and it is exported so a unit test can drive
464
+ * it directly (same seam style as `buildPlan`/`resolveWaitTargets`): the seeding
465
+ * behaviour is a property of THIS call, not of `createDbEventSource`, and a test
466
+ * that only exercised the factory would keep passing if this seed were removed.
467
+ *
468
+ * Seeding at `MAX(id)` matters because `createDbEventSource`'s own default is 0,
469
+ * i.e. "replay the whole log". On a ledger with history that re-delivered every
470
+ * stored event through `onWidgetEvent` — one full `snapshotNodes()` plus a
471
+ * repaint each, ~2.5s of blocked UI at 120 nodes / 1000 events — to reach a
472
+ * state the DB already describes.
473
+ *
474
+ * Rows committed in the window between this seed query and the first poll are
475
+ * not lost: `poll()` only skips when `lastDataVersion` matches, that variable
476
+ * starts `undefined` (so the first poll always reads), and the cursor is only
477
+ * ever advanced from ids actually returned by `SELECT ... WHERE id > cursor`.
478
+ * A row committed after the seed is therefore either picked up by the first
479
+ * poll or dirties `data_version` and is picked up on the next tick. The seed is
480
+ * not atomic with `start()`, but it does not need to be.
481
+ *
482
+ * Note the drain loop's early `return`: it exits BEFORE `lastDataVersion` is
483
+ * assigned (that assignment is after the loop), so an interrupted drain leaves
484
+ * the version unlatched. That errs safe — the next poll re-reads from the
485
+ * cursor instead of skipping — but it is a re-read, not a "set then return".
486
+ */
487
+ export function attachLegionRuntime(input: {
488
+ db: DatabaseSync;
489
+ eventBus: LegionEventBus;
490
+ }): DbEventSource {
491
+ const dbEventSource = createDbEventSource(input.db, { cursor: maxEventId(input.db) });
492
+ dbEventSource.start(input.eventBus);
493
+ return dbEventSource;
494
+ }
495
+
496
+ /**
497
+ * The phase machine for a legion node, from its role card (#298).
498
+ *
499
+ * Reads `phases:` off the agent definition for the node's role. Absent means the
500
+ * built-in machine, so an undeclared team is unaffected. A card that declares an
501
+ * invalid machine never reaches here: `parseAgentDocument` rejects it at load.
502
+ */
503
+ function phaseMachineForRole(role: string | null | undefined, cwd: string, settings: SubagentsSettings): PhaseMachine | undefined {
504
+ if (!role) return undefined;
505
+ try {
506
+ const discovery = discoverAgents(cwd, "user", { includeBuiltin: settings.disableBuiltins !== true });
507
+ const overridden = applyAgentOverrides(discovery.agents, settings.agentOverrides);
508
+ return findAgent(overridden, role)?.phases;
509
+ } catch {
510
+ // Discovery is best-effort here: a broken project dir must not stop a team
511
+ // from attaching, and the built-in machine still works.
512
+ return undefined;
513
+ }
314
514
  }
315
515
 
316
516
  function createLegionRuntime(input: {
@@ -326,11 +526,12 @@ function createLegionRuntime(input: {
326
526
  settings: LegionSettings;
327
527
  eventBus: LegionEventBus;
328
528
  registerTools: boolean;
529
+ /** #298: the team root's declared phase machine, when its card supplies one. */
530
+ machine?: PhaseMachine;
329
531
  }): LegionRuntimeIdentity {
330
532
  const self = getNode(input.db, input.nodeId);
331
533
  const eventBus = input.eventBus;
332
- const dbEventSource = createDbEventSource(input.db);
333
- dbEventSource.start(eventBus);
534
+ const dbEventSource = attachLegionRuntime(input);
334
535
  const postman = new LegionPostman({
335
536
  db: input.db,
336
537
  nodeId: input.nodeId,
@@ -339,6 +540,7 @@ function createLegionRuntime(input: {
339
540
  eventBus,
340
541
  sendRootMessage: (text: string) => notice(input.pi, text),
341
542
  sendSelfMessage: (text: string) => notice(input.pi, text),
543
+ ...(input.machine ? { machine: input.machine } : {}),
342
544
  resumeChild: (node: LegionNode, mail: LegionMailCommand) => resumeLegionChild({
343
545
  pi: input.pi, supervisor: input.supervisor, db: input.db, dbPath: input.dbPath,
344
546
  eventBus, settings: input.settings, cwd: input.cwd, storeRootDir: input.storeRootDir,
@@ -363,7 +565,7 @@ function createLegionRuntime(input: {
363
565
  postman,
364
566
  childNodeId: (name) => getChildByName(input.db, input.nodeId, name)?.id,
365
567
  };
366
- 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) });
568
+ 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), ...(input.machine ? { machine: input.machine } : {}) });
367
569
  postman.start();
368
570
  return identity;
369
571
  }
@@ -421,8 +623,31 @@ function resolveLegionIdentity(input: {
421
623
  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 } });
422
624
  }
423
625
  if (node.runId !== nodeRun.runId) updateLegionRunId(db, nodeId, nodeRun.runId);
424
- 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 });
425
- if (node.role === "centurion") void resumeLegionTeam({ identity, input });
626
+ const machine = phaseMachineForRole(node.role, input.cwd, input.settings);
627
+ 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, ...(machine ? { machine } : {}) });
628
+ // #273: `createLegionRuntime` runs during extension LOADING, where host
629
+ // actions (sendMessage, spawn, ui) are not available yet. Defer the team
630
+ // recovery to the next tick so a failure inside it cannot crash the load
631
+ // path, and so any notice it emits has a live runtime to go to.
632
+ if (node.role === "centurion") {
633
+ // #275: run the recovery at depth 0 explicitly. `queueMicrotask`
634
+ // INHERITS the AsyncLocalStorage depth (verified: `als.getStore()`
635
+ // inside a queued microtask still reads the parent's value), so a
636
+ // recovery queued from a nested context would start already deep and
637
+ // report `nesting limit reached (depth N, max 4)` for a two-level
638
+ // chain. `runAtAbsoluteDepth(0, …)` is the mechanism this codebase
639
+ // already uses for exactly that reason (see inprocess-depth.ts
640
+ // "REV2-1"): the recovery runs at the TOP of the tree, not under
641
+ // whatever context happened to enqueue it.
642
+ queueMicrotask(() => {
643
+ void runAtAbsoluteDepth(0, async () => {
644
+ await resumeLegionTeam({ identity, input });
645
+ }).catch((error: unknown) => {
646
+ const reason = error instanceof Error ? error.message : String(error);
647
+ console.error(`[pi-legion] team recovery for ${identity.nodeId} failed: ${reason}`);
648
+ });
649
+ });
650
+ }
426
651
  return identity;
427
652
  }
428
653
  if (input.cached?.nodeId === "root" && path.resolve(input.cached.storeRootDir) === path.resolve(input.store.rootDir)) return input.cached;
@@ -508,12 +733,57 @@ async function resumeLegionChild(input: { pi: ExtensionAPI; supervisor: LegionSu
508
733
  const childRun = store.readRun(input.node.runId);
509
734
  if (!childRun) throw new Error(`child run.json not found: ${input.node.runId}`);
510
735
  const parentOfSupervisor = parent.parentId ? getNode(input.db, parent.parentId) : null;
511
- const orchestrator = new Orchestrator({ supervisor: input.supervisor, runDir: store.runDir(parentRun.runId), cwd, maxDepth: catalog.settings.legion?.maxDepth, maxSpawns: catalog.settings.maxSubagentSpawnsPerSession ?? null, team: resumedTeam.team, childContext: resolveTeamContext("child", catalog.settings, resumedTeam.team), parentPath: parentRun.path, ...rpcApproveFrom(catalog.settings), legion: { db: input.db, dbPath: input.dbPath, nodeId: parent.id, parentNodeId: parent.parentId, parentRunId: parentOfSupervisor?.runId ?? null, runId: parentRun.runId, runStore: store, settings: input.settings, eventBus: input.eventBus } });
736
+ const orchestrator = new Orchestrator({ supervisor: input.supervisor, runDir: store.runDir(parentRun.runId), cwd, maxDepth: catalog.settings.legion?.maxDepth, maxSpawns: catalog.settings.maxSubagentSpawnsPerSession ?? null, team: resumedTeam.team, childContext: resolveTeamContext("child", catalog.settings, resumedTeam.team), parentPath: parentRun.path, ...rpcApproveFrom(catalog.settings), launchQueue: launchQueueBounds(catalog.settings), onLaunchQueueNotice: launchQueueNoticeSink(input.pi), 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 } });
512
737
  orchestrator.restore(parentRun);
513
738
  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) });
514
739
  await store.updateChild(parentRun.runId, saved.name, (child) => Object.assign(child, handle.child));
515
740
  }
516
741
 
742
+ /**
743
+ * The live legion runtime for THIS process, held at module scope rather than in
744
+ * the `piLegion` closure (#283).
745
+ *
746
+ * `pi` re-runs the extension factory for every `DefaultResourceLoader.reload()`,
747
+ * and an in-process child session creates its own loader
748
+ * (`inprocess-supervisor.ts` builds one per `createAgentSession`). So a single
749
+ * process can enter `piLegion` many times. A closure-local `let` reset to
750
+ * `undefined` on each entry defeated the cache inside `resolveLegionIdentity`,
751
+ * which then opened a fresh `legion.db` — and never closed it. Measured on a
752
+ * centurion dispatching one in-process scout: one extra open per entry, ~2.5k
753
+ * leaked descriptors per 20 s, RSS climbing to 6 GB until V8 aborted with
754
+ * `JavaScript heap out of memory`.
755
+ */
756
+ /**
757
+ * Runtime handles for THIS process, keyed by ledger path (#283).
758
+ *
759
+ * Two independent facts force a map rather than a single slot:
760
+ *
761
+ * 1. `pi` re-runs the extension factory for every `DefaultResourceLoader.reload()`,
762
+ * and an in-process child session builds its OWN loader per
763
+ * `createAgentSession` — so one process enters `piLegion` many times. A
764
+ * closure-local binding reset on every entry defeated the `cached`
765
+ * short-circuit in `resolveLegionIdentity`, which then opened a fresh
766
+ * `legion.db` per entry and never closed it: measured on a centurion
767
+ * dispatching one in-process scout, ~2.5k leaked descriptors per 20 s and RSS
768
+ * climbing to 6 GB until V8 aborted with `JavaScript heap out of memory`.
769
+ * 2. Those entries do NOT share one ledger. A parent and its in-process child
770
+ * legitimately own different `legion.db` files, and each registers its own
771
+ * `session_shutdown`. With a single slot the child's shutdown closed the
772
+ * PARENT's database, surfacing later as `database is not open` from whichever
773
+ * pump or admission probe ran next.
774
+ *
775
+ * Keying by (resolved path, nodeId) keeps the reuse (2) while giving each owner
776
+ * its own lifecycle (1).
777
+ *
778
+ * The path alone is NOT enough: a parent and its in-process child share ONE
779
+ * ledger file (the child inherits `dbPath` from the orchestrator's launch
780
+ * context) and differ only by the node row they own. Keying by path made them
781
+ * collide, so the child's shutdown closed the parent's handle and a sibling's
782
+ * next spawn failed with `database is not open` (#285 follow-up).
783
+ */
784
+ const legionIdentityKey = (dbPath: string, nodeId: string): string => `${dbPath}\u0000${nodeId}`;
785
+ const legionIdentities = new Map<string, LegionRuntimeIdentity>();
786
+
517
787
  export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps = {}) {
518
788
  // #197 D8: a session is a child if either transport says so. RPC children
519
789
  // are separate processes marked in env; in-process children share the
@@ -537,6 +807,25 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
537
807
  else lastUi?.setStatus("legion-verify", undefined);
538
808
  } catch { /* no UI */ }
539
809
  });
810
+ // #266 D9-3: the launch admission queue's own pill. Deliberately a separate
811
+ // listener from verify's — different phases, and verify's is poll-driven.
812
+ // The contract's baseline is `legion-queue: N queued`; the `· 8/8 slots busy`
813
+ // tail needs `slotsInUse`/`slots`, which the pure queue does not know (the
814
+ // limit lives in the supervisor), so that dimension is omitted until the
815
+ // event carries it — the contract allows the omission explicitly.
816
+ setLaunchQueueListener((event) => {
817
+ try {
818
+ if (event.phase === "queued" || event.phase === "admitted") {
819
+ const suffix = event.slotsInUse !== undefined && event.slots !== undefined
820
+ ? ` · ${event.slotsInUse}/${event.slots} slots busy`
821
+ : "";
822
+ lastUi?.setStatus("legion-queue", `legion-queue: ${event.queued} queued${suffix}`);
823
+ if (event.queued === 0) lastUi?.setStatus("legion-queue", undefined);
824
+ } else if (event.phase === "overflow" || event.phase === "expired" || event.phase === "flushed") {
825
+ lastUi?.setStatus("legion-queue", undefined);
826
+ }
827
+ } catch { /* no UI */ }
828
+ });
540
829
  }
541
830
  const onSpawnRefused = (message: string) => {
542
831
  try { lastUi?.notify(`pi-legion: ${message}`, "error"); } catch { /* no UI (child / print mode) */ }
@@ -546,8 +835,20 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
546
835
  let lastCwd = process.cwd();
547
836
  const supervisor = deps.supervisor ?? new RoutingSupervisor({
548
837
  rpc: new RpcSupervisor({ onSpawnRefused }),
838
+ // The in-process supervisor is constructed here (not by RoutingSupervisor) so
839
+ // it can read live settings per spawn. That means `onSlotReleased` must be
840
+ // wired EXPLICITLY: RoutingSupervisor only adds it when it builds the
841
+ // backend itself (`options.inprocess ?? new InProcessSupervisor({...})`).
842
+ // Passing a pre-built instance used to drop the hook silently, so a freed
843
+ // slot never woke a launch-queue pump in production — admission then waited
844
+ // for the fallback recheck instead of firing on the release (#266 D2-d-2).
549
845
  inprocess: new InProcessSupervisor({
550
846
  onSpawnRefused,
847
+ // The event payload is not needed here: every Orchestrator on this run dir
848
+ // re-probes its own items, so a bare wake-up edge is the whole contract.
849
+ onSlotReleased: () => {
850
+ notifyInProcessSlotReleased();
851
+ },
551
852
  getLimits: () => {
552
853
  try {
553
854
  return loadSubagentSettings({
@@ -562,7 +863,23 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
562
863
  onSpawnRefused,
563
864
  });
564
865
  const verifyRunner = deps.verifyRunner;
565
- const legionBus = createLegionEventBus();
866
+ // #238: the resident status widget defers its repaint to a microtask, which puts
867
+ // a paint failure outside the per-subscriber try/catch in `createLegionEventBus`.
868
+ // The runtime funnels that failure through `reportError` → `onHandlerError`, so
869
+ // a handler must actually be configured or the containment is silent. A widget
870
+ // repaint that throws (a stale UI context, a SQLITE_IOERR on the tree read) must
871
+ // never take the host down, and must leave a trace.
872
+ const legionBus = createLegionEventBus({
873
+ onHandlerError: (error, event, handler) => {
874
+ const detail = error instanceof Error ? error.message : String(error);
875
+ // `event === null` means a deferred failure with no event to attribute;
876
+ // `handler` is then an origin tag rather than a subscriber function.
877
+ const where = event
878
+ ? `${event.type} node=${event.nodeId}`
879
+ : typeof handler === "string" ? handler : "deferred";
880
+ console.error(`[pi-legion] legion event handler failed (${where}): ${detail}`);
881
+ },
882
+ });
566
883
  const rpcUIProxy = (supervisor as LegionSupervisor & { uiProxy?: UIProxy }).uiProxy;
567
884
  const uiProxy = deps.uiProxy ?? rpcUIProxy ?? new InMemoryUIProxy();
568
885
  const onBudgetRefused = deps.onBudgetRefused ?? ((event) => {
@@ -584,16 +901,50 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
584
901
  return true;
585
902
  }
586
903
  };
587
- let legionIdentity: LegionRuntimeIdentity | undefined;
588
- const stopLegionRuntime = (): void => {
589
- if (!legionIdentity) return;
590
- legionIdentity.postman.stop();
591
- legionIdentity.dbEventSource.stop();
592
- // #40: bound the ledger and fold the WAL back before closing.
593
- if (hygieneEnabled() && !isChild) applyEventRetention(legionIdentity.db);
594
- else checkpoint(legionIdentity.db);
595
- legionIdentity.db.close();
596
- legionIdentity = undefined;
904
+ /**
905
+ * Close one ledger handle (by resolved path) or every handle this process
906
+ * opened. Shutdown handlers pass their own path so a child session teardown
907
+ * cannot close the parent's database (#283).
908
+ */
909
+ const stopLegionRuntime = (key?: string): void => {
910
+ const targets = key === undefined
911
+ ? [...legionIdentities.entries()]
912
+ : (legionIdentities.has(key) ? [[key, legionIdentities.get(key)!] as const] : []);
913
+ for (const [key, identity] of targets) {
914
+ legionIdentities.delete(key);
915
+ identity.postman.stop();
916
+ identity.dbEventSource.stop();
917
+ // #40: bound the ledger and fold the WAL back before closing.
918
+ if (hygieneEnabled() && !isChild) applyEventRetention(identity.db);
919
+ else checkpoint(identity.db);
920
+ identity.db.close();
921
+ }
922
+ };
923
+ /** The handle this extension entry owns, if any (see `legionIdentities`). */
924
+ let ownLegionKey: string | undefined;
925
+ const ownLegionIdentity = (): LegionRuntimeIdentity | undefined =>
926
+ ownLegionKey === undefined ? undefined : legionIdentities.get(ownLegionKey);
927
+ /**
928
+ * Register a resolved identity and make it this entry's own (#283/#285).
929
+ *
930
+ * One rule for every call site: the map holds at most one live handle per
931
+ * ledger path, and an existing handle is only closed when THIS entry is
932
+ * replacing the path it already owned. Reassigning across paths never closes
933
+ * anything — a sibling entry (parent vs in-process child) may still be using
934
+ * the handle, and closing it surfaced as `database is not open` from that
935
+ * sibling's next spawn.
936
+ */
937
+ const adoptLegionIdentity = (identity: LegionRuntimeIdentity): void => {
938
+ const key = legionIdentityKey(identity.dbPath, identity.nodeId);
939
+ const existing = legionIdentities.get(key);
940
+ // Only close when this entry is replacing the key it ALREADY owns: that
941
+ // handle is provably ours. A handle owned by a sibling (same ledger file,
942
+ // different node) is not ours to close.
943
+ if (existing && existing !== identity && ownLegionKey === key) {
944
+ stopLegionRuntime(key);
945
+ }
946
+ if (existing !== identity) legionIdentities.set(key, identity);
947
+ ownLegionKey = key;
597
948
  };
598
949
  const initializeLegionRuntime = (ctx: Pick<ExtensionContext, "cwd"> & { sessionFile?: string; legionNode?: { dbPath: string; nodeId: string } }): void => {
599
950
  // The injected node's dbPath is authoritative: an in-process child shares the
@@ -603,9 +954,27 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
603
954
  userSettingsPath: path.join(getAgentDir(), "settings.json"),
604
955
  projectSettingsPath: path.join(ctx.cwd, ".pi", "settings.json"),
605
956
  });
606
- const next = resolveLegionIdentity({ pi, supervisor, store, cwd: ctx.cwd, settings, eventBus: legionBus, cached: legionIdentity, sessionFile: ctx.sessionFile, legionNode: ctx.legionNode });
607
- if (legionIdentity && next !== legionIdentity) stopLegionRuntime();
608
- legionIdentity = next;
957
+ // Resolve the ledger path BEFORE asking for a cache entry: a fresh factory
958
+ // entry starts with no owned path, but the same database may already have a
959
+ // live handle from an earlier entry in this process (#283).
960
+ const targetPath = ctx.legionNode?.dbPath ?? process.env[LEGION_DB_ENV];
961
+ const targetNode = ctx.legionNode?.nodeId ?? process.env[LEGION_NODE_ID_ENV];
962
+ const cached = targetPath !== undefined && targetNode !== undefined && path.isAbsolute(targetPath)
963
+ ? legionIdentities.get(legionIdentityKey(path.resolve(targetPath), targetNode))
964
+ : ownLegionIdentity();
965
+ const next = resolveLegionIdentity({ pi, supervisor, store, cwd: ctx.cwd, settings, eventBus: legionBus, cached, sessionFile: ctx.sessionFile, legionNode: ctx.legionNode });
966
+ if (next === undefined) return;
967
+ // Index by ledger path: a re-entry for the SAME database reuses the cached
968
+ // handle, a different database (parent vs its in-process child) gets its
969
+ // own slot.
970
+ //
971
+ // Never close `cached` here just because this entry resolved elsewhere.
972
+ // `resolveLegionIdentity` returns a DIFFERENT object for the same path when
973
+ // the node/session changed, and that earlier handle may still be in use by
974
+ // the entry that opened it — closing it produced `database is not open`
975
+ // from a sibling's spawn (#285 follow-up). Only the entry that owns a path
976
+ // closes it, via its own session_shutdown.
977
+ adoptLegionIdentity(next);
609
978
  };
610
979
  if (isChild) {
611
980
  registerChildGuard(pi);
@@ -659,7 +1028,7 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
659
1028
  }
660
1029
  const { storeRoot, notify } = pendingHygiene;
661
1030
  try {
662
- const report = runStartupHygiene({ storeRoot, protectRunIds: legionIdentity ? [legionIdentity.rootRunId] : [] });
1031
+ const report = runStartupHygiene({ storeRoot, protectRunIds: ownLegionIdentity() ? [ownLegionIdentity()!.rootRunId] : [] });
663
1032
  const text = formatHygieneReport(report);
664
1033
  if (text) notify?.(text);
665
1034
  } catch (error) {
@@ -703,7 +1072,9 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
703
1072
  ...children.map((job) => job.retire?.(shutdownRetire).catch(() => {})),
704
1073
  ...pendingUtilityRetires.map((retire) => retire(shutdownRetire).catch(() => {})),
705
1074
  ]);
706
- stopLegionRuntime();
1075
+ // Scope to the ledger THIS entry opened: a parent and its in-process child
1076
+ // both register a shutdown handler, and each must close only its own (#283).
1077
+ stopLegionRuntime(ownLegionKey);
707
1078
  };
708
1079
  if (process.env[UTILITY_CHILD_ENV] !== "1") {
709
1080
  pi.registerTool({
@@ -736,11 +1107,10 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
736
1107
  sessionCwd: ctx.cwd,
737
1108
  signal,
738
1109
  isChild,
739
- legion: legionIdentity,
1110
+ legion: ownLegionIdentity(),
740
1111
  eventBus: legionBus,
741
1112
  onLegion: (identity) => {
742
- if (legionIdentity && legionIdentity !== identity) stopLegionRuntime();
743
- legionIdentity = identity;
1113
+ adoptLegionIdentity(identity);
744
1114
  },
745
1115
  onUtilityChild: (retire) => pendingUtilityRetires.push(retire),
746
1116
  });
@@ -764,16 +1134,25 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
764
1134
  catalog.settings,
765
1135
  catalog.team,
766
1136
  );
1137
+ // A centurion running as a NESTED child is still a "parent" for templating
1138
+ // purposes — it dispatches its own squad — but it must not be offered itself:
1139
+ // the runtime refuses a non-root centurion dispatch (a nested centurion is
1140
+ // a second command chain the root cannot collect), so listing it invites a
1141
+ // call that can only fail. Leaving it out keeps the roster truthful.
1142
+ const selfRole = process.env[CHILD_ROLE_ENV];
1143
+ const rosterAgents = selfRole
1144
+ ? catalog.agents.filter((agent) => agent.name !== selfRole)
1145
+ : catalog.agents;
767
1146
  return {
768
1147
  systemPrompt: `${event.systemPrompt}\n\n${PARENT_PLAYBOOK}\n\n${renderParentRoster(
769
- catalog.agents,
1148
+ rosterAgents,
770
1149
  catalog.team,
771
1150
  catalog.teamWarnings,
772
1151
  )}${parentContext ? `\n\n${parentContext}` : ""}`,
773
1152
  };
774
1153
  });
775
1154
  if (process.env[UTILITY_CHILD_ENV] === "1") {
776
- pi.on("session_shutdown", () => stopLegionRuntime());
1155
+ pi.on("session_shutdown", () => stopLegionRuntime(ownLegionKey));
777
1156
  return;
778
1157
  }
779
1158
  let lastUi: ExtensionUIContext | undefined;
@@ -793,13 +1172,37 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
793
1172
  statusStrip: process.env.PI_LEGION_STRIP === "tree" ? "tree" : "railway",
794
1173
  eventBus: legionBus,
795
1174
  getTreeNodes: () => {
796
- const identity = legionIdentity;
1175
+ const identity = ownLegionIdentity();
797
1176
  return identity
798
1177
  ? attachUsageTotals(identity.db, getSubtree(identity.db, identity.nodeId))
799
1178
  : [];
800
1179
  },
1180
+ // #298: let the tree render a team's declared workflow. Cached per role
1181
+ // because agent discovery touches the filesystem and the tree re-renders on
1182
+ // every event.
1183
+ getPhaseMachine: (() => {
1184
+ const cache = new Map<string, PhaseMachine | undefined>();
1185
+ return (role) => {
1186
+ if (!role) return undefined;
1187
+ if (cache.has(role)) return cache.get(role);
1188
+ let machine: PhaseMachine | undefined;
1189
+ try {
1190
+ // `loadCatalog` already applies agentOverrides and team selection, so
1191
+ // this is the same definition the launch path resolves.
1192
+ const catalog = loadCatalog(lastCwd, lastCwd, undefined);
1193
+ machine = findAgent(catalog.agents, role)?.phases;
1194
+ } catch {
1195
+ // Discovery must never break rendering: a malformed project dir would
1196
+ // otherwise blank the tree.
1197
+ machine = undefined;
1198
+ }
1199
+ cache.set(role, machine);
1200
+ return machine;
1201
+ };
1202
+ })(),
801
1203
  getTreeIdentity: (name) => {
802
- const node = legionIdentity && getChildByName(legionIdentity.db, legionIdentity.nodeId, name);
1204
+ const own = ownLegionIdentity();
1205
+ const node = own && getChildByName(own.db, own.nodeId, name);
803
1206
  return node ? {
804
1207
  nodeId: node.id,
805
1208
  parentId: node.parentId,
@@ -807,10 +1210,37 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
807
1210
  supervisorName: name,
808
1211
  } : undefined;
809
1212
  },
1213
+ /**
1214
+ * subagent-renewal-v1 D3: the one renewal proposal the completion notice
1215
+ * may carry.
1216
+ *
1217
+ * Read from the persisted ChildRecord so a proposal survives process
1218
+ * boundaries (each tool call is a fresh process). Every "do not offer"
1219
+ * rule — D1 completed, D7 still running, D5 role opt-out, D8 no baseline —
1220
+ * lives inside `renewalProposalFor`, so returning its undefined is the
1221
+ * whole decision.
1222
+ */
1223
+ getRenewalProposal: (name, execution) => {
1224
+ // Each tool call is a fresh process, so the child record comes off disk
1225
+ // rather than from any in-memory launch state.
1226
+ const cwd = lastCwd;
1227
+ if (!cwd) return undefined;
1228
+ const located = findChild(runStoreFor(cwd).listRuns(), name);
1229
+ if (!located) return undefined;
1230
+ const catalog = loadCatalog(cwd, cwd, undefined);
1231
+ const def = findControlAgent(catalog.agents, catalog.allAgents, located.child.agent);
1232
+ return renewalProposalFor({
1233
+ agent: def,
1234
+ child: located.child,
1235
+ sessionFile: located.child.sessionFile,
1236
+ execution,
1237
+ name,
1238
+ });
1239
+ },
810
1240
  subscribeSupervisorEvents: (name, listener, options) => rpcEventSource.onEvent?.(
811
1241
  name,
812
1242
  (event) => {
813
- const nodeId = legionIdentity?.childNodeId(name);
1243
+ const nodeId = ownLegionIdentity()?.childNodeId(name);
814
1244
  if (nodeId) publishSupervisorEvent(legionBus, nodeId, event, options);
815
1245
  listener(event);
816
1246
  },
@@ -925,9 +1355,17 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
925
1355
  stop: stopChildren,
926
1356
  childStatus: (name, cwd) => {
927
1357
  try {
928
- const found = findChild(runStoreFor(cwd).listRuns(), name);
1358
+ const store = runStoreFor(cwd);
1359
+ const runs = store.listRuns();
1360
+ const found = findChild(runs, name);
929
1361
  if (!found) return undefined;
930
1362
  const child = found.child;
1363
+ const alive = supervisor.isAlive(child.name);
1364
+ // Run-dir ownership verdict (#209-3), the same one `subagent status`
1365
+ // uses: `alive` is this process's in-memory registry and never holds a
1366
+ // child launched elsewhere, so a foreign record needs the owner marker.
1367
+ // Undefined while alive — liveness already wins there.
1368
+ const owner = ownerVerdictFor(store, runs, child.name, alive);
931
1369
  return {
932
1370
  name: child.name,
933
1371
  state: child.state,
@@ -938,7 +1376,11 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
938
1376
  ...(child.acceptance
939
1377
  ? { acceptance: { status: child.acceptance.status, level: child.acceptance.level } }
940
1378
  : {}),
941
- alive: supervisor.isAlive(child.name),
1379
+ // run.json records the arbitrated backend; a legacy record has none,
1380
+ // and `renderLiveChildStatus` renders that as `unknown` — never `rpc`.
1381
+ ...(child.spawnMode ? { spawnMode: child.spawnMode } : {}),
1382
+ alive,
1383
+ ...(owner ? { owner } : {}),
942
1384
  };
943
1385
  } catch {
944
1386
  return undefined;
@@ -1070,12 +1512,12 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
1070
1512
 
1071
1513
  let activeLegion: LegionRuntimeIdentity | undefined;
1072
1514
  try {
1073
- activeLegion = resolveLegionIdentity({ pi, supervisor, store, cwd, settings, eventBus: legionBus, cached: legionIdentity, sessionFile: ctx.sessionManager?.getSessionFile() });
1515
+ activeLegion = resolveLegionIdentity({ pi, supervisor, store, cwd, settings, eventBus: legionBus, cached: ownLegionIdentity(), sessionFile: ctx.sessionManager?.getSessionFile() });
1074
1516
  if (!activeLegion && action === "launch" && !isChild) {
1075
1517
  const plan = buildPlan(params);
1076
1518
  if (plan.ok) activeLegion = createRootLegion({ pi, supervisor, store, cwd, task: plan.task, settings, maxDepth: effectiveMaxDepth(settings.legion?.maxDepth), eventBus: legionBus });
1077
1519
  }
1078
- if (activeLegion) legionIdentity = activeLegion;
1520
+ if (activeLegion) adoptLegionIdentity(activeLegion);
1079
1521
  } catch (error) {
1080
1522
  return fail(`legion runtime initialization failed: ${error instanceof Error ? error.message : String(error)}`, ErrorCodes.INVALID_PARAMS);
1081
1523
  }
@@ -1096,6 +1538,9 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
1096
1538
  verifyRunner,
1097
1539
  store,
1098
1540
  cwd,
1541
+ // #270: the session cwd is a second store worth searching when the
1542
+ // call passes an explicit `cwd` that differs from it.
1543
+ ...(ctx.cwd && ctx.cwd !== cwd ? { callerCwd: ctx.cwd } : {}),
1099
1544
  agents,
1100
1545
  allAgents,
1101
1546
  team,
@@ -1113,6 +1558,7 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
1113
1558
  params,
1114
1559
  signal,
1115
1560
  ctx,
1561
+ pi,
1116
1562
  supervisor,
1117
1563
  uiProxy,
1118
1564
  onBudgetRefused,
@@ -1139,7 +1585,16 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
1139
1585
  details !== null && typeof details === "object" && !Array.isArray(details)
1140
1586
  ? (details as Record<string, unknown>)
1141
1587
  : undefined;
1142
- return ok("interrupted (parent turn ended; children still running)", carried);
1588
+ // #266 D7: the inner interrupted result also carries the launch-queue
1589
+ // flush line (`N queued launch(es) dropped: …`), which a bare head line
1590
+ // would silently erase — the caller would think its launches still ran.
1591
+ // An inner result that already reports the interruption is therefore
1592
+ // kept as-is; only an otherwise-successful result is replaced.
1593
+ const inner = (result.content ?? [])
1594
+ .map((part) => (part.type === "text" ? part.text : ""))
1595
+ .join("\n");
1596
+ if (inner.includes(INTERRUPTED_HEAD)) return ok(inner, carried);
1597
+ return ok(INTERRUPTED_HEAD, carried);
1143
1598
  }
1144
1599
  return result;
1145
1600
  },
@@ -1156,7 +1611,7 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
1156
1611
  };
1157
1612
 
1158
1613
  uiProxy.onRequest((pending) => {
1159
- const legionNodeId = legionIdentity?.childNodeId(pending.childName);
1614
+ const legionNodeId = ownLegionIdentity()?.childNodeId(pending.childName);
1160
1615
  if (legionNodeId) publishUIRequest(legionBus, legionNodeId, pending.request.method);
1161
1616
  const ui = lastUi;
1162
1617
  if (!ui) return;
@@ -1317,6 +1772,11 @@ async function controlAction(input: {
1317
1772
  verifyRunner?: VerifyRunner;
1318
1773
  store: RunStore;
1319
1774
  cwd: string;
1775
+ /**
1776
+ * #270: the session's own cwd, when the call also passes an explicit `cwd`.
1777
+ * Child records may live in either store, so both are searched on a miss.
1778
+ */
1779
+ callerCwd?: string;
1320
1780
  agents: AgentConfig[];
1321
1781
  allAgents: AgentConfig[];
1322
1782
  team: ActiveTeam;
@@ -1367,7 +1827,7 @@ async function controlAction(input: {
1367
1827
  // all names but before the serial loop reaches it. Treat that target as
1368
1828
  // stopped; only rebuild if its persisted record is not already retired.
1369
1829
  const found = findChild(input.store.listRuns(), name);
1370
- if (!found) throw new SubagentError(`unknown child: ${name}`, ErrorCodes.NOT_FOUND);
1830
+ if (!found) throw unknownChildError(name);
1371
1831
  if (found.child.state === "retired") return;
1372
1832
  const orchestrator = new Orchestrator({
1373
1833
  supervisor: input.supervisor,
@@ -1398,20 +1858,42 @@ async function controlAction(input: {
1398
1858
  }
1399
1859
  if (!params.name) {
1400
1860
  const runs = store.listRuns();
1401
- const live = listLiveChildrenForStatus(runs);
1861
+ // Liveness facts are read once per name and reused by both the filter and
1862
+ // the block, so `status` cannot classify a child one way and render it
1863
+ // another (display-liveness D6 exit 1).
1864
+ const factsByName = new Map<string, StatusLivenessFacts & { pid?: number }>();
1865
+ const factsFor = (child: StatusChildRecord): StatusLivenessFacts & { pid?: number } => {
1866
+ const cached = factsByName.get(child.name);
1867
+ if (cached) return cached;
1868
+ const alive = input.supervisor.isAlive(child.name);
1869
+ const owner = ownerVerdictFor(store, runs, child.name, alive);
1870
+ // A pid only exists for a child this process actually tracks; a
1871
+ // record from another session has none, and none is invented.
1872
+ const pid = input.runtime.get(child.name)?.pid;
1873
+ const facts = { alive, ...(owner ? { owner } : {}), ...(pid ? { pid } : {}) };
1874
+ factsByName.set(child.name, facts);
1875
+ return facts;
1876
+ };
1877
+ const classified = classifyChildrenForStatus(runs, factsFor);
1402
1878
  return ok(
1403
1879
  renderStatusOverview(
1404
- live.map((child) => {
1405
- const alive = input.supervisor.isAlive(child.name);
1406
- const owner = ownerVerdictFor(store, runs, child.name, alive);
1407
- return { child, alive, ...(owner ? { owner } : {}) };
1880
+ classified.map(({ child, live }) => {
1881
+ const { pid, ...facts } = factsFor(child);
1882
+ return { child, ...facts, ...(pid ? { pid } : {}), live };
1408
1883
  }),
1409
1884
  ),
1410
1885
  );
1411
1886
  }
1412
1887
  const runs = store.listRuns();
1413
- const found = findChild(runs, params.name);
1414
- if (!found) return unknownChild(params.name, store, cwd);
1888
+ // #270: a miss in this store is not a miss overall — a worktree child's
1889
+ // record lives under its own store, which this one cannot enumerate.
1890
+ const found = findChild(runs, params.name) ?? findChildAcrossStores({
1891
+ name: params.name,
1892
+ cwd,
1893
+ callerCwd: input.callerCwd,
1894
+ ownRoot: store.rootDir,
1895
+ });
1896
+ if (!found) return unknownChild(params.name, store, cwd, input.callerCwd);
1415
1897
  const alive = input.supervisor.isAlive(params.name);
1416
1898
  const owner = ownerVerdictFor(store, runs, params.name, alive);
1417
1899
  return ok(renderLiveChildStatus({
@@ -1435,8 +1917,15 @@ async function controlAction(input: {
1435
1917
  );
1436
1918
  }
1437
1919
 
1438
- const found = findChild(store.listRuns(), params.name);
1439
- if (!found) return unknownChild(params.name, store, cwd);
1920
+ const found =
1921
+ findChild(store.listRuns(), params.name) ??
1922
+ findChildAcrossStores({
1923
+ name: params.name,
1924
+ cwd,
1925
+ callerCwd: input.callerCwd,
1926
+ ownRoot: store.rootDir,
1927
+ });
1928
+ if (!found) return unknownChild(params.name, store, cwd, input.callerCwd);
1440
1929
 
1441
1930
  const orchestrator = new Orchestrator({
1442
1931
  supervisor: input.supervisor,
@@ -1499,13 +1988,29 @@ function unknownChild(
1499
1988
  name: string,
1500
1989
  store: RunStore,
1501
1990
  cwd: string,
1991
+ callerCwd?: string,
1502
1992
  ): AgentToolResult<unknown> {
1503
- const known = store.listRuns().flatMap((run) => run.children.map((child) => child.name));
1993
+ // #270: search the stores that can plausibly hold the record before giving up.
1994
+ // A worktree child's store lives under `<runDir>/worktrees/<name>/.pi-subagents`,
1995
+ // which the root store can never enumerate, so a single-store lookup reports a
1996
+ // miss for a child that plainly exists.
1997
+ // The store already searched comes first, then every other candidate
1998
+ // (caller cwd, worktree stores). `resolveChildAcrossStores` re-searches the
1999
+ // own store too — that is deliberate: it returns the `searched` list the
2000
+ // message needs, and a store with no runs simply drops out of it.
2001
+ const { searched } = resolveChildAcrossStores({
2002
+ name,
2003
+ cwd,
2004
+ callerCwd,
2005
+ roots: [
2006
+ { rootDir: path.resolve(store.rootDir), reason: "cwd" as const },
2007
+ ...candidateStoreRoots({ cwd, callerCwd }).filter(
2008
+ (candidate) => candidate.rootDir !== path.resolve(store.rootDir),
2009
+ ),
2010
+ ],
2011
+ });
1504
2012
  return fail(
1505
- `unknown child: ${name} (no run under ${cwd}/.pi-subagents` +
1506
- `${known.length ? `; known here: ${known.join(", ")}` : ""}). ` +
1507
- `Child records are scoped to the \`cwd\` they were launched from — ` +
1508
- `pass the same \`cwd\` you used for \`launch\`.`,
2013
+ unknownChildMessage({ name, cwd, searched: searched as CandidateStore[] }),
1509
2014
  ErrorCodes.NOT_FOUND,
1510
2015
  );
1511
2016
  }
@@ -1596,6 +2101,171 @@ async function retireChild(
1596
2101
  }
1597
2102
  }
1598
2103
 
2104
+ /**
2105
+ * D8: a fresh progress baseline, taken from the session file as it is NOW.
2106
+ *
2107
+ * A child that has just been spawned (or relaunched from its session) may have
2108
+ * an empty or not-yet-created session file; `lines: 0` is then the honest
2109
+ * answer and everything written afterwards counts as progress. Never throws —
2110
+ * an unreadable file is "nothing written yet", not a launch failure.
2111
+ */
2112
+ export function renewalBaselineFor(
2113
+ sessionFile: string,
2114
+ at: number = Date.now(),
2115
+ readLines: (file: string) => string[] | undefined = readSessionLines,
2116
+ ): { at: number; lines: number } {
2117
+ return { at, lines: readLines(sessionFile)?.length ?? 0 };
2118
+ }
2119
+
2120
+ /**
2121
+ * D5-a: was this child **cut off by its turn budget** rather than finishing?
2122
+ *
2123
+ * `continue`/`resume` works on every terminal child, but only a budget casualty
2124
+ * should spend a renewal (D6) — otherwise an ordinary retry after a normal
2125
+ * failure quietly eats the budget, and the budget stops meaning anything.
2126
+ *
2127
+ * Judgement order is frozen by D5-a and follows `src/shared/session.ts`'s
2128
+ * measured semantics (F29), most reliable first:
2129
+ *
2130
+ * 1. `stopReason === "toolUse"` — the final assistant row was still waiting on
2131
+ * a tool when the process went away: the classic budget-exhaustion shape.
2132
+ * 2. No assistant message for the last prompt — F29: a hard kill writes no
2133
+ * assistant row at all, so "missing reply" IS the abort signal.
2134
+ * 3. `reason` mentions `terminated` — the host's own label, the weakest of the
2135
+ * three because wording is not a contract.
2136
+ *
2137
+ * A clean `stop` / `length` turn (the user simply asked for another round)
2138
+ * matches none of them and does **not** spend a renewal.
2139
+ *
2140
+ * `shouldRecycleAfterCollect` is deliberately NOT used: it only separates
2141
+ * terminal from non-terminal, which is a different question.
2142
+ */
2143
+ export function wasBudgetCut(execution: Execution | undefined): boolean {
2144
+ if (!execution) return false;
2145
+ // 1. Terminated while a tool call was outstanding.
2146
+ if (execution.stopReason === "toolUse") return true;
2147
+ // 2. F29 hard kill: the turn has no assistant row at all.
2148
+ if (
2149
+ execution.reason === NO_ASSISTANT_ABORT_REASON &&
2150
+ execution.stopReason !== "stop" &&
2151
+ execution.stopReason !== "length"
2152
+ ) {
2153
+ return true;
2154
+ }
2155
+ // 3. Host wording, last resort.
2156
+ return execution.reason?.toLowerCase().includes("terminated") ?? false;
2157
+ }
2158
+
2159
+ /**
2160
+ * D3: the renewal proposal to attach to a **real termination** notice, or
2161
+ * `undefined` when the contract says not to offer one.
2162
+ *
2163
+ * Extracted as a pure function (not inlined at the notify call site) because
2164
+ * the whole decision is testable without launching a child: "was the budget
2165
+ * cut + is the role renewable + has it a baseline" is arithmetic, not I/O.
2166
+ *
2167
+ * The four exits, all locked by tests:
2168
+ * - **D1** a `completed` child is never a renewal candidate: it produced a
2169
+ * verdict, so offering `continue` would relaunch finished work.
2170
+ * - **D7/A3** a `running` snapshot is a `wait`/`collect` deadline on a **live**
2171
+ * child (`inprocess-supervisor` merely resolved `{settled:false}`); the child
2172
+ * never lost a budget, so renewing it would spend a fresh one for nothing.
2173
+ * - **D5/A4** `renewable: false` → no proposal at all, so the notice keeps the
2174
+ * exact pre-W3 bytes (no renewal lines).
2175
+ * - **A2** no measurable progress is still **reported**: the caller passes the
2176
+ * measurement through and `notify.ts` prints "no measurable progress". The
2177
+ * proposal is withheld only for the three reasons above, never for "we could
2178
+ * not tell".
2179
+ *
2180
+ * **A5/D6**: `used >= max` still gets a proposal — the renderer turns it into
2181
+ * `renewals exhausted (used/max) — split the task instead of renewing`. That is
2182
+ * a deliberate hand-off to the human, not silence.
2183
+ *
2184
+ * `maxRenewals: 0` (D6) is "renewal disabled": `renewable` is the role-level
2185
+ * switch, `0` the budget-level one, and both must agree before an offer is made.
2186
+ */
2187
+ export function renewalProposalFor(input: {
2188
+ agent: Pick<AgentConfig, "renewable" | "maxRenewals"> | undefined;
2189
+ child: Pick<ChildRecord, "renewalBaseline" | "renewalsUsed">;
2190
+ /** Session file the progress measurement reads; the resume credential (D8). */
2191
+ sessionFile: string;
2192
+ execution: { status: string; reason?: string };
2193
+ name: string;
2194
+ /** Injectable for tests; defaults to W1's measurer over the real session file. */
2195
+ measure?: (sessionFile: string, baseline: { at: number; lines: number }) => {
2196
+ toolResults: number;
2197
+ assistantMessages: number;
2198
+ filesChanged?: number;
2199
+ };
2200
+ }): RenewalProposal | undefined {
2201
+ const status = completionStatusOf(
2202
+ input.execution.status,
2203
+ // Acceptance is not part of this decision: D1 keys on the completion
2204
+ // status alone, and `unknown` + a parsed verdict is handled by the
2205
+ // caller's own status, not re-derived here.
2206
+ undefined,
2207
+ );
2208
+ // D1: a finished child is not a renewal candidate.
2209
+ if (status === "completed") return undefined;
2210
+ // D7: `running` is a live child behind a `wait`/`collect` deadline.
2211
+ if (status === "running") return undefined;
2212
+ // D5: the role forbids renewal (e.g. scout: a timed-out search was mis-scoped).
2213
+ if ((input.agent?.renewable ?? DEFAULT_RENEWABLE) === false) return undefined;
2214
+ const max = input.agent?.maxRenewals ?? DEFAULT_MAX_RENEWALS;
2215
+ // D6: `0` disables renewal outright; offering `0/0` would be a lie.
2216
+ if (max <= 0) return undefined;
2217
+ // D8: no baseline means nothing to measure against (#266-era records).
2218
+ const baseline = input.child.renewalBaseline;
2219
+ if (!baseline) return undefined;
2220
+
2221
+ const used = input.child.renewalsUsed ?? 0;
2222
+ // D2 puts the worktree probe last, so the measurement is session-file only.
2223
+ const measured = (input.measure ?? defaultRenewalMeasure)(
2224
+ input.sessionFile,
2225
+ baseline,
2226
+ );
2227
+ return {
2228
+ progress: {
2229
+ toolResults: measured.toolResults,
2230
+ assistantMessages: measured.assistantMessages,
2231
+ ...(measured.filesChanged !== undefined
2232
+ ? { filesChanged: measured.filesChanged }
2233
+ : {}),
2234
+ },
2235
+ used,
2236
+ max,
2237
+ // D4/A8: renewal IS `continue`; the parent gets a finished command.
2238
+ command: `subagent({ action: "continue", name: "${input.name}", message: "<what to continue>" })`,
2239
+ };
2240
+ }
2241
+
2242
+ /** Session-file half of {@link renewalProposalFor}; no worktree probe (D2 puts it last). */
2243
+ function defaultRenewalMeasure(
2244
+ sessionFile: string,
2245
+ baseline: { at: number; lines: number },
2246
+ ): { toolResults: number; assistantMessages: number; filesChanged?: number } {
2247
+ return measureProgress({ sessionFile, baseline });
2248
+ }
2249
+
2250
+ /**
2251
+ * D6/D8: charge one renewal to a child, D5-a's way.
2252
+ *
2253
+ * Split out from `reviveChild` so the arithmetic is testable in isolation: the
2254
+ * caller has already decided (via {@link wasBudgetCut}) whether this continue
2255
+ * was a budget casualty, and only then does the counter move.
2256
+ *
2257
+ * Absent `renewalsUsed` means "never renewed" (#266-era record), so it reads as
2258
+ * 0 rather than throwing. The baseline is the caller's business: it must come
2259
+ * from the **relaunched** session file, which only that layer holds.
2260
+ */
2261
+ export function chargeRenewal(
2262
+ child: Pick<ChildRecord, "renewalsUsed">,
2263
+ ): number {
2264
+ const used = (child.renewalsUsed ?? 0) + 1;
2265
+ child.renewalsUsed = used;
2266
+ return used;
2267
+ }
2268
+
1599
2269
  /**
1600
2270
  * Continue or resume a child.
1601
2271
  *
@@ -1641,6 +2311,32 @@ async function reviveChild(
1641
2311
  name,
1642
2312
  ...(found.child.model ? { model: found.child.model } : {}),
1643
2313
  });
2314
+ // D6/D8/D5-a: spend one renewal, but only if this child was actually cut
2315
+ // off by its turn budget.
2316
+ //
2317
+ // `continue` is legal on every terminal child (D4), yet an ordinary retry
2318
+ // after a normal `stop` must not eat the budget — otherwise two harmless
2319
+ // retries would silently exhaust the allowance that exists for genuine
2320
+ // budget casualties. `wasBudgetCut` answers that from the session shape
2321
+ // (F29/D5-a), not from `shouldRecycleAfterCollect` (terminal vs not, a
2322
+ // different question).
2323
+ //
2324
+ // The baseline is reset in both branches. A non-budget continue still
2325
+ // moved the child's own "since" line forward: keeping the old baseline
2326
+ // would count this whole extra turn as progress at the next termination
2327
+ // and overstate how much the child achieved under the current budget.
2328
+ const resumedChild =
2329
+ orchestrator.childrenSnapshot().find((entry) => entry.name === handle.name) ??
2330
+ found.child;
2331
+ // Read the verdict from `found.child`, NOT the orchestrator's record: `launch`
2332
+ // clears `previous.execution` before returning (it is a relaunch), and
2333
+ // `found` is a freshly parsed copy from `store.listRuns()`, so the pre-resume
2334
+ // outcome is still intact there. Reading the orchestrator's object here
2335
+ // would see `undefined` on every single resume and never charge anything.
2336
+ const budgetCut = wasBudgetCut(found.child.execution);
2337
+ resumedChild.renewalBaseline = renewalBaselineFor(handle.sessionFile);
2338
+ if (budgetCut) chargeRenewal(resumedChild);
2339
+ await persistChild(ctx.store, ctx.found.runId, ctx.orchestrator, handle.name);
1644
2340
  followChild(ctx, { watch: true });
1645
2341
  return ok(
1646
2342
  `Resumed ${handle.name} from its session. Context preserved from ${handle.sessionFile}`,
@@ -1658,6 +2354,7 @@ async function reviveChild(
1658
2354
  async function launchFamily(input: {
1659
2355
  params: SubagentParams;
1660
2356
  ctx: ExtensionContext;
2357
+ pi: ExtensionAPI;
1661
2358
  supervisor: LegionSupervisor;
1662
2359
  uiProxy: UIProxy;
1663
2360
  onBudgetRefused: NonNullable<ConstructorParameters<typeof Orchestrator>[0]["onBudgetRefused"]>;
@@ -1735,6 +2432,10 @@ async function launchFamily(input: {
1735
2432
  worktreeBootstrapTimeoutMs: settings.legion?.worktreeBootstrapTimeoutMs,
1736
2433
  ...teamForChildren(input.team),
1737
2434
  ...rpcApproveFrom(settings),
2435
+ // #266 D6-a/D10: queue bounds + the D9 notice channel. The command chain is
2436
+ // the bug's main path (a 12-step `tasks[]` fanout against `maxChildrenPerNode`).
2437
+ launchQueue: launchQueueBounds(settings),
2438
+ onLaunchQueueNotice: launchQueueNoticeSink(input.pi),
1738
2439
  ...(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 } } : {}),
1739
2440
  onBudgetRefused: input.onBudgetRefused,
1740
2441
  });
@@ -1764,6 +2465,7 @@ async function launchFamily(input: {
1764
2465
  // abort (interrupted results run after that), so it is kept per name
1765
2466
  // here — same precedent as `timeoutByName`.
1766
2467
  spawnByName: new Map(),
2468
+ pidByName: new Map(),
1767
2469
  ...(nameGate.name ? { launchName: nameGate.name } : {}),
1768
2470
  };
1769
2471
 
@@ -1775,10 +2477,28 @@ async function launchFamily(input: {
1775
2477
 
1776
2478
  const interruptedResult = async (): Promise<AgentToolResult<unknown> | undefined> => {
1777
2479
  if (!input.signal?.aborted) return undefined;
2480
+ // #266 D7/A5: a parent turn end empties the launch queue and spawns nothing.
2481
+ // Deliberately only HERE (and in the vexillatio interrupt): Esc/wait
2482
+ // interruptions end the parent's *wait*, not its turn, and must leave
2483
+ // children — queued ones included — running (`esc-no-retire-v3`).
2484
+ const flushed = orchestrator.flushLaunchQueue("parent turn ended");
1778
2485
  for (const name of session.handles) await abandonLaunchedChild(session, name);
1779
2486
  session.handles.length = 0;
2487
+ const flushLine = launchQueueFlushLine(flushed);
1780
2488
  return {
1781
- content: [{ type: "text", text: session.results.join("\n") || "interrupted (parent turn ended; children still running)" }],
2489
+ content: [{
2490
+ type: "text",
2491
+ text: [
2492
+ // The head line LEADS (same frozen shape as the vexillatio interrupt):
2493
+ // the outer abort net keeps an inner result that already reports the
2494
+ // interruption, and only a leading head makes that detection reliable
2495
+ // — otherwise each `parent turn ended; child still running (watched)`
2496
+ // line reads like a standalone notice and the flush line is lost.
2497
+ INTERRUPTED_HEAD,
2498
+ session.results.join("\n"),
2499
+ flushLine,
2500
+ ].filter(Boolean).join("\n"),
2501
+ }],
1782
2502
  details: { runId: run.runId, handles: [], async: params.async ?? true, ...spawnDetails() },
1783
2503
  };
1784
2504
  };
@@ -1858,6 +2578,13 @@ interface LaunchSession {
1858
2578
  timeoutByName: Map<string, number>;
1859
2579
  /** Arbitrated backend per resolved handle; survives an abort clearing `handles`. */
1860
2580
  spawnByName: Map<string, { name: string; agent: string; spawnMode: SpawnMode }>;
2581
+ /**
2582
+ * Own process of an `rpc` child, keyed by name — same reason `timeoutByName`
2583
+ * exists: `Handle.pid` is not persisted, so a re-follow from `ChildRecord`
2584
+ * cannot recover it. Kept out of `spawnByName` so `details.spawns` (a
2585
+ * renderer input) keeps its published shape.
2586
+ */
2587
+ pidByName: Map<string, number>;
1861
2588
  /** Explicit child name for this single-step launch; omit to auto-allocate. */
1862
2589
  launchName?: string;
1863
2590
  }
@@ -1952,9 +2679,8 @@ async function launchStep(
1952
2679
  // `--no-extensions` the child would resolve it there and die with
1953
2680
  // "No API key found". The parent registry knows the live provider.
1954
2681
  ...(resolved.model ? { modelProvider: providerForModel(session.modelRegistry, resolved.model) } : {}),
1955
- // The origin decides whether an unacceptable model is refused or
1956
- // dropped, and only this layer can tell them apart (a parent model
1957
- // arrives as the same `dispatch` source as a per-run override).
2682
+ // The origin distinguishes a deliberate per-run model from the parent's
2683
+ // model falling through; the launch line reports which one won.
1958
2684
  modelOrigin: model.modelOrigin,
1959
2685
  worktree: resolveLaunchWorktree({
1960
2686
  roleDefault: effective.worktree,
@@ -1976,6 +2702,7 @@ async function launchStep(
1976
2702
  agent: effective.name,
1977
2703
  spawnMode: handle.spawnMode,
1978
2704
  });
2705
+ if (handle.pid) session.pidByName.set(handle.name, handle.pid);
1979
2706
  if (effective.timeoutMs !== undefined) {
1980
2707
  session.timeoutByName.set(handle.name, effective.timeoutMs);
1981
2708
  }
@@ -1984,18 +2711,8 @@ async function launchStep(
1984
2711
  return;
1985
2712
  }
1986
2713
  session.results.push(
1987
- `▶ ${handle.name} (${effective.name}) supervisor=${handle.spawnMode}`,
2714
+ `▶ ${handle.name} (${effective.name}) supervisor=${handle.spawnMode}${launchLineQueueSuffix(handle)}`,
1988
2715
  );
1989
- // An inherited model the target CLI cannot express is dropped on purpose
1990
- // (the parent's pi model means nothing to cursor). Say so, or the user
1991
- // reasonably believes the child is running the model they see on the
1992
- // parent — the same silent surprise this whole guard exists to remove.
1993
- if (handle.child.modelDropped?.length) {
1994
- session.results.push(
1995
- ` ⚠ ${effective.name} runs on ${effective.kind}'s own default: ` +
1996
- `${handle.child.modelDropped.join(", ")} does not apply to that CLI.`,
1997
- );
1998
- }
1999
2716
  session.onUpdate?.({
2000
2717
  content: [
2001
2718
  {
@@ -2094,23 +2811,100 @@ function resolveStep(
2094
2811
  };
2095
2812
  }
2096
2813
 
2814
+ /**
2815
+ * Collect one child, tolerating the transient gap an admission creates.
2816
+ *
2817
+ * `pumpAdmissions` deletes the `queued` placeholder before replaying the real
2818
+ * launch, so between those two steps the name exists in neither the placeholder
2819
+ * map nor the freshly spawned record. `collect()` resolves names through
2820
+ * `requireChild`, so a collect that lands in that window throws
2821
+ * `unknown child: <name>` — a race, not a real failure, and one only a queued
2822
+ * child can hit. The window is a microtask-or-two long (the replay is awaited),
2823
+ * so a short bounded retry is enough; anything genuinely gone still surfaces.
2824
+ *
2825
+ * Only used by the sequential `async:false` path. `async:true` never collects
2826
+ * here — its watcher goes through the same `collect` but is re-armed, so a retry
2827
+ * loop there would fight the watcher instead of helping it.
2828
+ */
2829
+ async function collectThroughAdmission(
2830
+ session: LaunchSession,
2831
+ name: string,
2832
+ signal?: AbortSignal,
2833
+ ): Promise<Awaited<ReturnType<Orchestrator["collect"]>>> {
2834
+ const deadline = Date.now() + ADMISSION_SETTLE_TIMEOUT_MS;
2835
+ for (;;) {
2836
+ try {
2837
+ return await session.orchestrator.collect(name, {
2838
+ timeoutMs: session.timeoutByName.get(name) ?? DEFAULTS.turnTimeoutMs,
2839
+ ...(signal ? { signal } : {}),
2840
+ });
2841
+ } catch (error) {
2842
+ const transient = error instanceof SubagentError && error.code === ErrorCodes.NOT_FOUND;
2843
+ if (!transient || Date.now() >= deadline || signal?.aborted) throw error;
2844
+ await new Promise((resolve) => setTimeout(resolve, ADMISSION_SETTLE_POLL_MS));
2845
+ }
2846
+ }
2847
+ }
2848
+
2849
+ /**
2850
+ * How long a collect may retry across the admission gap before reporting it.
2851
+ *
2852
+ * Deliberately much shorter than a turn timeout: the gap is two `await`s wide,
2853
+ * so anything still missing after this long is not racing — it is gone.
2854
+ */
2855
+ const ADMISSION_SETTLE_TIMEOUT_MS = 5_000;
2856
+ const ADMISSION_SETTLE_POLL_MS = 25;
2857
+
2097
2858
  /** Wait for each launched child's turn, appending its outcome. */
2098
2859
  async function collectInline(session: LaunchSession, signal?: AbortSignal): Promise<void> {
2099
- for (const name of session.handles) {
2860
+ // #266 D8: collect children that already hold a slot FIRST.
2861
+ //
2862
+ // A queued handle resolves synchronously (`launch()` returns it before any
2863
+ // spawn), so it lands in `handles` BEFORE the admitted siblings. Collecting in
2864
+ // raw launch order would therefore block on the queued child first — waiting
2865
+ // for a slot that only those very siblings can release once THEY are
2866
+ // collected — i.e. a deadlock, not a wait. Launch order is preserved within
2867
+ // each group, so a run with no queue keeps its exact previous order.
2868
+ const queuedHere = new Set(
2869
+ session.orchestrator
2870
+ .childrenSnapshot()
2871
+ .filter((child) => child.state === "queued")
2872
+ .map((child) => child.name),
2873
+ );
2874
+ const ordered = [
2875
+ ...session.handles.filter((name) => !queuedHere.has(name)),
2876
+ ...session.handles.filter((name) => queuedHere.has(name)),
2877
+ ];
2878
+ for (const name of ordered) {
2100
2879
  if (signal?.aborted) {
2101
2880
  session.results.push("interrupted (parent turn ended; children still running)");
2102
2881
  return;
2103
2882
  }
2104
2883
  try {
2105
- const collected = await session.orchestrator.collect(name, {
2106
- timeoutMs: session.timeoutByName.get(name) ?? DEFAULTS.turnTimeoutMs,
2107
- ...(signal ? { signal } : {}),
2108
- });
2884
+ const collected = await collectThroughAdmission(session, name, signal);
2109
2885
  if (signal?.aborted || collected.execution.reason === "aborted by parent") {
2110
2886
  session.results.push("interrupted (parent turn ended; children still running)");
2111
2887
  return;
2112
2888
  }
2113
2889
  session.results.push(renderCollect(name, collected));
2890
+ // #266 D8/A20: `async:false` must report the real wait, not just look slow.
2891
+ // Read AFTER collect: `queueWaitedMs` is written at admission, so an item
2892
+ // that is still waiting when the collect deadline fires has no line yet.
2893
+ const waitedLine = queuedWaitLine(
2894
+ session.orchestrator.childrenSnapshot().find((child) => child.name === name),
2895
+ );
2896
+ if (waitedLine) session.results.push(waitedLine);
2897
+ // A finished child must release its slot BEFORE the next collect starts,
2898
+ // or a queue can never drain: `collectInline` is sequential, and the
2899
+ // caller's retire loop runs after every collect (so with `maxChildrenPerNode:
2900
+ // 1` the second child would wait for a slot the first still holds).
2901
+ // Gated on a non-empty queue so the un-queued fast path is unchanged.
2902
+ if (
2903
+ session.orchestrator.launchQueueEntries().length > 0 &&
2904
+ shouldRecycleAfterCollect(collected.execution.status)
2905
+ ) {
2906
+ await session.orchestrator.retire(name);
2907
+ }
2114
2908
  } catch (error) {
2115
2909
  session.results.push(`✗ ${name}: collect failed: ${String(error)}`);
2116
2910
  }
@@ -2143,10 +2937,16 @@ type UtilityPlan =
2143
2937
  | { ok: false; message: string };
2144
2938
 
2145
2939
  function buildUtilityPlan(params: VexillatioParams): UtilityPlan {
2146
- if (!params.task.trim()) return { ok: false, message: "`task` must not be blank." };
2147
- const steps: UtilityTaskItem[] = params.tasks?.length
2148
- ? params.tasks
2149
- : [{ agent: params.agent?.trim() || "scout", task: params.task }];
2940
+ const singleTask = params.task ?? "";
2941
+ const parallel = params.tasks?.length ? params.tasks : undefined;
2942
+ // `tasks[]` wins and `task` degrades to a parent-run label, so both shapes
2943
+ // accepted by the schema reach a launch: `{ task }`, `{ tasks }`, `{ task, tasks }`.
2944
+ if (!parallel && !singleTask.trim()) {
2945
+ return { ok: false, message: "Provide one of: `task` (single recon) or `tasks[]` (parallel recon)." };
2946
+ }
2947
+ const steps: UtilityTaskItem[] = parallel
2948
+ ? parallel
2949
+ : [{ agent: params.agent?.trim() || "scout", task: singleTask }];
2150
2950
  const unsupportedVerify = steps.findIndex((item) => item.verify !== undefined);
2151
2951
  if (unsupportedVerify >= 0) {
2152
2952
  return {
@@ -2323,6 +3123,10 @@ async function launchUtilityFamily(input: {
2323
3123
  childContext: resolveTeamContext("child", catalog.settings, catalog.team),
2324
3124
  team: catalog.team,
2325
3125
  ...rpcApproveFrom(catalog.settings),
3126
+ // #266: the parallel-recon path shares the same bounds and notice channel
3127
+ // as the command chain — one scheduler for every dispatch family (D2).
3128
+ launchQueue: launchQueueBounds(catalog.settings),
3129
+ onLaunchQueueNotice: launchQueueNoticeSink(input.pi),
2326
3130
  onBudgetRefused: input.onBudgetRefused,
2327
3131
  ...(legion ? {
2328
3132
  legion: {
@@ -2381,6 +3185,7 @@ async function launchUtilityFamily(input: {
2381
3185
  agent: item.agent.name,
2382
3186
  sessionFile: handle.sessionFile,
2383
3187
  spawnMode: handle.spawnMode,
3188
+ ...(handle.pid ? { pid: handle.pid } : {}),
2384
3189
  orchestrator,
2385
3190
  retire,
2386
3191
  },
@@ -2397,6 +3202,7 @@ async function launchUtilityFamily(input: {
2397
3202
  agent: string;
2398
3203
  sessionFile: string;
2399
3204
  spawnMode: SpawnMode;
3205
+ pid?: number;
2400
3206
  orchestrator: Orchestrator;
2401
3207
  retire: (opts?: RetireOptions) => Promise<void>;
2402
3208
  }> = [];
@@ -2414,6 +3220,7 @@ async function launchUtilityFamily(input: {
2414
3220
  timeoutMs,
2415
3221
  // Arbitrated at launch; never re-derived here (#209).
2416
3222
  spawnMode: handle.spawnMode,
3223
+ ...(handle.pid ? { pid: handle.pid } : {}),
2417
3224
  orchestrator: handle.orchestrator,
2418
3225
  store,
2419
3226
  watch,
@@ -2445,6 +3252,17 @@ async function launchUtilityFamily(input: {
2445
3252
  lines.push(`sessionFile: ${handle.sessionFile}`);
2446
3253
  return lines.join("\n");
2447
3254
  } catch (error) {
3255
+ // #266: a queued item is never "failed" — it never started. Treating it as
3256
+ // still running keeps it out of the failure list and lets the retry/watch
3257
+ // path own it, instead of retiring a launch the caller only asked to wait on.
3258
+ const queuedNow = handle.orchestrator
3259
+ .childrenSnapshot()
3260
+ .find((entry) => entry.name === handle.name)?.state === "queued";
3261
+ if (queuedNow) {
3262
+ followUtility(handle, true);
3263
+ stillRunning.add(handle.name);
3264
+ return `── ${handle.name} ──\nexecution: queued (waiting for a free slot)\nsessionFile: ${handle.sessionFile}`;
3265
+ }
2448
3266
  if (input.supervisor.isAlive(handle.name)) {
2449
3267
  followUtility(handle, true);
2450
3268
  stillRunning.add(handle.name);
@@ -2455,13 +3273,27 @@ async function launchUtilityFamily(input: {
2455
3273
  return `✗ ${handle.name}: collect failed: ${error instanceof Error ? error.message : String(error)}\nsessionFile: ${handle.sessionFile}`;
2456
3274
  }
2457
3275
  }));
2458
- if (input.signal?.aborted) return interruptedUtilityResult({
2459
- runId,
2460
- handles,
2461
- stillRunning,
2462
- results,
2463
- collected,
2464
- });
3276
+ if (input.signal?.aborted) {
3277
+ // #266 D7/A5: same rule as the command chain — a parent turn end empties
3278
+ // every dispatch family's queue and spawns nothing. Each utility item owns
3279
+ // its own Orchestrator, so each one flushes its own view of the shared queue.
3280
+ const flushed: string[] = [];
3281
+ for (const handle of handles) {
3282
+ const child = handle.orchestrator.childrenSnapshot().find((entry) => entry.name === handle.name);
3283
+ // D9: a queued item produced no result, so it must never be reported as
3284
+ // one. It is either dropped by the flush below (reported by name) or, if
3285
+ // it was already admitted, genuinely still running.
3286
+ if (child?.state === "queued") flushed.push(...handle.orchestrator.flushLaunchQueue("parent turn ended"));
3287
+ }
3288
+ return interruptedUtilityResult({
3289
+ runId,
3290
+ handles,
3291
+ stillRunning,
3292
+ results,
3293
+ collected,
3294
+ flushed,
3295
+ });
3296
+ }
2465
3297
  results.push(...collected);
2466
3298
  return {
2467
3299
  content: [{ type: "text", text: results.join("\n\n") || "No utility agents were launched." }],
@@ -2493,9 +3325,18 @@ function interruptedUtilityResult(input: {
2493
3325
  stillRunning: Set<string>;
2494
3326
  results: string[];
2495
3327
  collected: string[];
3328
+ /** #266 D7: names the abort cleared out of the launch queue (seq order). */
3329
+ flushed?: readonly string[];
2496
3330
  }): AgentToolResult<unknown> {
2497
3331
  const head = "interrupted (parent turn ended; children still running)";
2498
- const segments = [...input.results, ...input.collected].filter((segment) => segment.length > 0);
3332
+ // #266 D7: a dropped queued launch is the reason the rest of the result is
3333
+ // short, so it is reported even when nothing was collected at all. Trailing,
3334
+ // like the command chain's line: it reads as a summary of what was cleared.
3335
+ const segments = [
3336
+ ...input.results,
3337
+ ...input.collected,
3338
+ launchQueueFlushLine(input.flushed ?? []),
3339
+ ].filter((segment) => segment.length > 0);
2499
3340
  const stillRunning = input.handles
2500
3341
  .filter(({ name }) => input.stillRunning.has(name))
2501
3342
  .map(({ name }) => name);
@@ -2663,6 +3504,13 @@ function followJob(
2663
3504
  treeIdentity?: { nodeId: string; parentId: string | null; depth: number };
2664
3505
  /** Arbitrated backend from `Handle.spawnMode`; omitted when unknown (#209). */
2665
3506
  spawnMode?: SpawnMode;
3507
+ /**
3508
+ * Own process of an `rpc` child (`Handle.pid`), for the memory probe.
3509
+ * Omitted when the caller has no handle at hand — a rebuilt orchestrator
3510
+ * only knows `ChildRecord`, and a pid is not persisted, so it must not be
3511
+ * guessed or looked up elsewhere.
3512
+ */
3513
+ pid?: number;
2666
3514
  },
2667
3515
  ): void {
2668
3516
  const child = input.orchestrator
@@ -2683,6 +3531,7 @@ function followJob(
2683
3531
  ...(child?.thinking !== undefined ? { thinking: child.thinking } : {}),
2684
3532
  ...(child?.worktreeBranch ? { worktreeBranch: child.worktreeBranch } : {}),
2685
3533
  ...(spawnMode ? { spawnMode } : {}),
3534
+ ...(input.pid ? { pid: input.pid } : {}),
2686
3535
  ...(input.treeIdentity ? input.treeIdentity : child?.nodeId ? {
2687
3536
  nodeId: child.nodeId,
2688
3537
  parentId: child.nodeId.includes(".") ? child.nodeId.slice(0, child.nodeId.lastIndexOf(".")) : null,
@@ -2760,16 +3609,21 @@ function followChild(ctx: ChildContext, opts: { watch: boolean }): void {
2760
3609
  }
2761
3610
 
2762
3611
  /**
2763
- * The arbitrated backend recorded for one handled child of this request.
3612
+ * The arbitrated backend recorded for one handled child of this request, plus
3613
+ * the OS process of that child when the launch reported one.
2764
3614
  *
2765
3615
  * Spreads to nothing when unknown, so the caller never has to invent a value.
2766
3616
  */
2767
3617
  function spawnModeOf(
2768
3618
  session: LaunchSession,
2769
3619
  name: string,
2770
- ): { spawnMode?: SpawnMode } {
3620
+ ): { spawnMode?: SpawnMode; pid?: number } {
2771
3621
  const recorded = session.spawnByName.get(name);
2772
- return recorded ? { spawnMode: recorded.spawnMode } : {};
3622
+ const pid = session.pidByName.get(name);
3623
+ return {
3624
+ ...(recorded ? { spawnMode: recorded.spawnMode } : {}),
3625
+ ...(pid ? { pid } : {}),
3626
+ };
2773
3627
  }
2774
3628
 
2775
3629
  function followLaunched(
@@ -2801,6 +3655,39 @@ function followLaunched(
2801
3655
  });
2802
3656
  }
2803
3657
 
3658
+ /**
3659
+ * Persist the orchestrator's current view of one child.
3660
+ *
3661
+ * RunStore is async while Orchestrator's `onChildUpdate` is synchronous, so
3662
+ * persistence is done explicitly here rather than from inside the callback
3663
+ * (which would leave floating promises).
3664
+ */
3665
+ /**
3666
+ * D8: give a child its spawn-time progress baseline exactly once.
3667
+ *
3668
+ * Called before every persist, so whichever launch path created the record
3669
+ * seeds it at (as near as makes no difference) spawn time: `persistChild` runs
3670
+ * immediately after `launch()` on every path — fresh launch, `tasks[]` fan-out,
3671
+ * utility dispatch — and the session file is empty-ish at that moment by
3672
+ * definition (`lines: 0` is then the honest answer, see {@link renewalBaselineFor}).
3673
+ *
3674
+ * The guard is "absent", never "stale":
3675
+ * - a record written before this field existed seeds on its next write, which
3676
+ * is the first moment anything is known about it;
3677
+ * - a renewed child keeps the baseline `reviveChild` handed it, so D8's
3678
+ * "count only what happened since the renewal" cannot be undone here.
3679
+ */
3680
+ export function seedRenewalState(
3681
+ child: Pick<ChildRecord, "sessionFile" | "renewalBaseline" | "renewalsUsed">,
3682
+ now: number = Date.now(),
3683
+ readLines: (file: string) => string[] | undefined = readSessionLines,
3684
+ ): void {
3685
+ if (!child.renewalBaseline) {
3686
+ child.renewalBaseline = renewalBaselineFor(child.sessionFile, now, readLines);
3687
+ }
3688
+ if (child.renewalsUsed === undefined) child.renewalsUsed = 0;
3689
+ }
3690
+
2804
3691
  /**
2805
3692
  * Persist the orchestrator's current view of one child.
2806
3693
  *
@@ -2817,6 +3704,10 @@ async function persistChild(
2817
3704
  ): Promise<void> {
2818
3705
  const child = orchestrator.childrenSnapshot().find((c) => c.name === name);
2819
3706
  if (!child) return;
3707
+ // D8: every persist path is a launch path on its first call, so this is where
3708
+ // a fresh child gets its baseline (see `seedRenewalState` for why it is safe
3709
+ // to run unconditionally on later persists too).
3710
+ seedRenewalState(child);
2820
3711
  try {
2821
3712
  const recorded = store.findChild(runId, name);
2822
3713
  if (!recorded) {
@@ -2840,6 +3731,10 @@ async function abandonLaunchedChild(session: LaunchSession, name: string): Promi
2840
3731
  session.runtime.release(name);
2841
3732
  return;
2842
3733
  }
3734
+ // #266: a launch that never started cannot be "still running". Reporting it as
3735
+ // watched would contradict the `N queued launch(es) dropped` line and tell the
3736
+ // caller to wait for a child that will never produce a turn.
3737
+ if (child.state === "queued") return;
2843
3738
  // Esc / parent-turn abort must not retire: child keeps running; follow+watch
2844
3739
  // so settle still notifies the parent (esc-no-retire-v3).
2845
3740
  followLaunched(session, name, { watch: true });
@@ -2851,40 +3746,132 @@ async function abandonLaunchedChild(session: LaunchSession, name: string): Promi
2851
3746
  // ---------------------------------------------------------------------------
2852
3747
 
2853
3748
  /**
2854
- * Live (non-retired / non-exited) children under this cwd for status overview.
3749
+ * Child-record states that are terminal, so a record carrying one is never live
3750
+ * (display-liveness D6, exit 1). `failed`/`settled` live on the execution side
3751
+ * rather than in `TaskState`, but a run.json written by another version (or a
3752
+ * hand-edited record) can carry them and they are terminal either way, so the
3753
+ * set is keyed on strings instead of the narrow state union.
3754
+ */
3755
+ const TERMINAL_STATUS_CHILD_STATES = new Set<string>([
3756
+ "retired",
3757
+ "exited",
3758
+ "failed",
3759
+ "settled",
3760
+ ]);
3761
+
3762
+ /** The persisted child record shape, as resolved through the run store. */
3763
+ type StatusChildRecord = NonNullable<ReturnType<RunStore["findChild"]>>;
3764
+
3765
+ /**
3766
+ * Liveness facts for one child, supplied by the caller.
2855
3767
  *
2856
- * Names can be reused after retire; prefer the newest live record per name
2857
- * (same preference as {@link pickChildByName}).
3768
+ * `alive` is this process's in-memory supervisor answer (it never holds a child
3769
+ * launched by another session); `owner` is the run-dir ownership verdict that
3770
+ * covers that hole (#209-3). See {@link ownerVerdictFor}.
2858
3771
  */
2859
- export function listLiveChildrenForStatus(
3772
+ export interface StatusLivenessFacts {
3773
+ alive: boolean;
3774
+ /** `undefined` while `alive` — in-memory liveness already decided (#209-3). */
3775
+ owner?: "live" | "dead" | "unknown";
3776
+ }
3777
+
3778
+ /** One run record after the status filter ran (display-liveness D6 exit 1). */
3779
+ export interface StatusChildClassification {
3780
+ child: StatusChildRecord;
3781
+ /**
3782
+ * Counted in `Status · N live children`. `false` = listed but NOT counted:
3783
+ * the record's run-dir owner pid is gone (#209-3 / A3).
3784
+ */
3785
+ live: boolean;
3786
+ }
3787
+
3788
+ /**
3789
+ * Split the persisted child records under this cwd into `live` and `dead`.
3790
+ *
3791
+ * A record is dead when its owner pid is gone and this process is not running
3792
+ * it — not merely when its `state` says so. `owner === "unknown"` counts as
3793
+ * live: filtering unknown out would hide a child that may well be starting, and
3794
+ * D2 forbids collapsing unknown into dead. Its block still reports
3795
+ * `owner: unknown` + `state=working`, so it is never *displayed* as confirmed
3796
+ * live — it is only not hidden.
3797
+ *
3798
+ * Names can be reused after retire, so the newest record per name wins, except
3799
+ * that a dead record never shadows an older record of the same name that is
3800
+ * still alive (same preference as {@link pickChildByName}).
3801
+ *
3802
+ * Without `factsFor` the filter falls back to the record state alone; that keeps
3803
+ * this callable from a context that has no store (and keeps the pre-liveness
3804
+ * signature working), but the `status` exit must pass facts.
3805
+ */
3806
+ export function classifyChildrenForStatus(
2860
3807
  runs: ReturnType<RunStore["listRuns"]>,
2861
- ): NonNullable<ReturnType<RunStore["findChild"]>>[] {
3808
+ factsFor?: (child: StatusChildRecord) => StatusLivenessFacts,
3809
+ ): StatusChildClassification[] {
2862
3810
  const ranked = [...runs].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
2863
- const seen = new Set<string>();
2864
- const out: NonNullable<ReturnType<RunStore["findChild"]>>[] = [];
3811
+ const candidates: StatusChildClassification[] = [];
2865
3812
  for (const run of ranked) {
2866
3813
  for (const child of run.children) {
2867
- if (child.state === "retired" || child.state === "exited") continue;
2868
- if (seen.has(child.name)) continue;
2869
- seen.add(child.name);
2870
- out.push(child);
3814
+ if (TERMINAL_STATUS_CHILD_STATES.has(child.state)) continue;
3815
+ const facts = factsFor?.(child);
3816
+ // Dead = the run dir's owner pid is gone AND this process is not running
3817
+ // it. `owner === "unknown"` (no marker, or no pid in it) is never dead:
3818
+ // unknown must not be collapsed into dead (display-liveness D2).
3819
+ const live = !(facts?.owner === "dead" && !facts.alive);
3820
+ candidates.push({ child, live });
2871
3821
  }
2872
3822
  }
2873
- return out;
3823
+ // Name reuse: a newer DEAD record must not shadow an older record of the
3824
+ // same name that is still live (mirror of `pickChildByName`, which prefers a
3825
+ // live match over a newer retired one). Among equals the newest wins, hence
3826
+ // the newest-first iteration order.
3827
+ const chosen = new Map<string, StatusChildClassification>();
3828
+ for (const entry of candidates) {
3829
+ const prior = chosen.get(entry.child.name);
3830
+ if (!prior || !prior.live) chosen.set(entry.child.name, entry);
3831
+ }
3832
+ return candidates.filter((entry) => chosen.get(entry.child.name) === entry);
2874
3833
  }
2875
3834
 
2876
- /** Multi-child status text: one {@link renderLiveChildStatus} block per live child. */
2877
- export function renderStatusOverview(
2878
- children: Array<{
2879
- child: NonNullable<ReturnType<RunStore["findChild"]>>;
2880
- alive: boolean;
2881
- /** Run-dir ownership verdict (#209-3); see {@link renderLiveChildStatus}. */
2882
- owner?: "live" | "dead" | "unknown";
2883
- }>,
2884
- ): string {
2885
- if (children.length === 0) return "No live children.";
2886
- const noun = children.length === 1 ? "child" : "children";
2887
- const blocks = children.map(({ child, alive, owner }) =>
3835
+ /**
3836
+ * Live children under this cwd for the status overview.
3837
+ *
3838
+ * Pass `factsFor` to decide liveness from owner evidence instead of the record
3839
+ * state alone; see {@link classifyChildrenForStatus}.
3840
+ */
3841
+ export function listLiveChildrenForStatus(
3842
+ runs: ReturnType<RunStore["listRuns"]>,
3843
+ factsFor?: (child: StatusChildRecord) => StatusLivenessFacts,
3844
+ ): StatusChildRecord[] {
3845
+ return classifyChildrenForStatus(runs, factsFor).flatMap((entry) =>
3846
+ entry.live ? [entry.child] : [],
3847
+ );
3848
+ }
3849
+
3850
+ /** One child block for {@link renderStatusOverview}. */
3851
+ export interface StatusChildEntry {
3852
+ child: StatusChildRecord;
3853
+ alive: boolean;
3854
+ /** Run-dir ownership verdict (#209-3); see {@link renderLiveChildStatus}. */
3855
+ owner?: "live" | "dead" | "unknown";
3856
+ /**
3857
+ * Own process of an `rpc` child, when this session launched it. A pid is
3858
+ * not persisted, so a record from another session has none — and none is
3859
+ * invented here (D3).
3860
+ */
3861
+ pid?: number;
3862
+ /**
3863
+ * `false` = listed but not counted in the header (owner pid gone, see
3864
+ * {@link classifyChildrenForStatus}). Absent means live, so callers that only
3865
+ * hold live children keep their meaning.
3866
+ */
3867
+ live?: boolean;
3868
+ }
3869
+
3870
+ /** Multi-child status text: one {@link renderLiveChildStatus} block per child. */
3871
+ export function renderStatusOverview(children: StatusChildEntry[]): string {
3872
+ const deadCount = children.filter((entry) => entry.live === false).length;
3873
+ const liveCount = children.length - deadCount;
3874
+ const blocks = children.map(({ child, alive, owner, pid }) =>
2888
3875
  renderLiveChildStatus({
2889
3876
  name: child.name,
2890
3877
  state: child.state,
@@ -2896,9 +3883,22 @@ export function renderStatusOverview(
2896
3883
  spawnMode: child.spawnMode,
2897
3884
  alive,
2898
3885
  ...(owner ? { owner } : {}),
3886
+ ...(pid ? { pid } : {}),
2899
3887
  }),
2900
3888
  );
2901
- return [`Status · ${children.length} live ${noun}`, "", blocks.join("\n\n")].join("\n");
3889
+ const parts: string[] = [];
3890
+ if (liveCount === 0) {
3891
+ // Nothing live. With no record at all this stays the exact literal the
3892
+ // tests pin; with records that are all dead the count line below says so,
3893
+ // instead of implying the run directory is empty.
3894
+ parts.push("No live children.");
3895
+ } else {
3896
+ const noun = liveCount === 1 ? "child" : "children";
3897
+ parts.push(`Status · ${liveCount} live ${noun}`);
3898
+ }
3899
+ if (deadCount > 0) parts.push(`(${deadCount} dead)`);
3900
+ if (blocks.length > 0) parts.push("", blocks.join("\n\n"));
3901
+ return parts.join("\n");
2902
3902
  }
2903
3903
 
2904
3904
  /**
@@ -3242,6 +4242,41 @@ function findControlAgent(
3242
4242
  );
3243
4243
  }
3244
4244
 
4245
+ /**
4246
+ * #270: resolve a child from another store when this one has no record.
4247
+ *
4248
+ * Only reached on a miss, so the common case (child launched here) costs
4249
+ * nothing. The returned shape matches {@link findChild} so callers do not care
4250
+ * which store answered — except that `runDir` now belongs to the owning store,
4251
+ * which is why the caller re-derives its orchestrator run directory from it.
4252
+ */
4253
+ function findChildAcrossStores(input: {
4254
+ name: string;
4255
+ cwd: string;
4256
+ callerCwd?: string | undefined;
4257
+ ownRoot: string;
4258
+ }): {
4259
+ runId: string;
4260
+ run: ReturnType<RunStore["listRuns"]>[number];
4261
+ child: NonNullable<ReturnType<RunStore["findChild"]>>;
4262
+ } | null {
4263
+ const { found } = resolveChildAcrossStores({
4264
+ name: input.name,
4265
+ cwd: input.cwd,
4266
+ callerCwd: input.callerCwd,
4267
+ roots: candidateStoreRoots({ cwd: input.cwd, callerCwd: input.callerCwd }).filter(
4268
+ (candidate) => path.resolve(candidate.rootDir) !== path.resolve(input.ownRoot),
4269
+ ),
4270
+ });
4271
+ if (!found) return null;
4272
+ // Read the owning store again for the full record: `resolveChildAcrossStores`
4273
+ // only reports where the name was seen.
4274
+ const runs = new RunStore({ rootDir: found.storeRoot }).listRuns();
4275
+ const picked = pickChildByName(runs, input.name);
4276
+ if (!picked) return null;
4277
+ return { runId: picked.run.runId, run: picked.run, child: picked.child };
4278
+ }
4279
+
3245
4280
  function findChild(
3246
4281
  runs: ReturnType<RunStore["listRuns"]>,
3247
4282
  name: string,