pi-legion 3.0.1 → 3.1.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.
package/index.ts CHANGED
@@ -83,6 +83,7 @@ import {
83
83
  SUBAGENT_NOTIFY_TYPE,
84
84
  } from "./src/extension/notify.ts";
85
85
  import { renderSubagentNotice } from "./src/extension/notice-renderer.ts";
86
+ import { renderSpawnResult } from "./src/extension/spawn-render.ts";
86
87
  import { PARENT_PLAYBOOK, TOOL_DESCRIPTION } from "./src/extension/playbook.ts";
87
88
  import { configureSessionErrors, recordSessionError } from "./src/extension/session-errors.ts";
88
89
  import { getAgentDir } from "./src/agents/paths.ts";
@@ -94,6 +95,7 @@ import {
94
95
  type OrchestratorLegionContext,
95
96
  } from "./src/runs/orchestrator.ts";
96
97
  import { RunStore, pickChildByName } from "./src/runs/store.ts";
98
+ import { RUN_OWNER_FILE, ownerState } from "./src/runs/owner.ts";
97
99
  import { resolveLaunchWorktree } from "./src/runs/worktree.ts";
98
100
  import {
99
101
  type AgentConfig,
@@ -103,6 +105,7 @@ import {
103
105
  ErrorCodes,
104
106
  type ModelOrigin,
105
107
  type RetireOptions,
108
+ type SpawnMode,
106
109
  SubagentError,
107
110
  type SubagentsSettings,
108
111
  } from "./src/shared/types.ts";
@@ -122,7 +125,7 @@ const TaskItem = Type.Object({
122
125
  worktree: Type.Optional(
123
126
  Type.Boolean({
124
127
  description:
125
- "Parent decides isolation for this child. true = own git branch, child opens an MR. false = write the current checkout.",
128
+ "Parent decides isolation for this child. true = own git branch, child opens an MR. false = write the current checkout. Reserved for `centurion` (#214): any other role asking for true is downgraded to false with a one-time warning.",
126
129
  }),
127
130
  ),
128
131
  verify: Type.Optional(
@@ -174,7 +177,7 @@ const SubagentParams = Type.Object({
174
177
  worktree: Type.Optional(
175
178
  Type.Boolean({
176
179
  description:
177
- "Parent decides isolation. true = isolated git branch, child opens an MR. false = edit the current checkout. Omit to use the role default (bundled worker: true).",
180
+ "Parent decides isolation. true = isolated git branch, child opens an MR. false = edit the current checkout. Reserved for `centurion` (#214): only a centurion gets a subtree worktree, every other role is downgraded to false with a one-time warning — dispatch a centurion when you need an isolated delivery unit.",
178
181
  }),
179
182
  ),
180
183
  verify: Type.Optional(
@@ -293,6 +296,19 @@ function runStoreFor(cwd: string, dbPathOverride?: string): RunStore {
293
296
  return new RunStore({ rootDir: path.join(cwd, ".pi-subagents") });
294
297
  }
295
298
 
299
+ /**
300
+ * Backend THIS process was started on (#209), for `run.json`'s `herdr.supervisor`.
301
+ *
302
+ * A host/root session and an RPC child are both ordinary `pi` processes, so the
303
+ * historic `rpc` marker is the truth for them. An in-process child shares this
304
+ * process, and its backend travels on the ALS child context — the one case where
305
+ * the truthful marker is not `rpc`. Reading it from there (instead of writing a
306
+ * literal) is what stops the record from claiming RPC for an inprocess child.
307
+ */
308
+ function currentProcessSpawnMode(): SpawnMode {
309
+ return currentChildContext()?.spawnMode ?? "rpc";
310
+ }
311
+
296
312
  function notice(pi: ExtensionAPI, text: string): void {
297
313
  pi.sendMessage({ customType: SUBAGENT_NOTIFY_TYPE, content: text, display: true }, { triggerTurn: true, deliverAs: "followUp" });
298
314
  }
@@ -399,6 +415,9 @@ function resolveLegionIdentity(input: {
399
415
  const nodeRuns = input.store.listRuns().filter((run) => run.nodeId === nodeId || run.herdr.node_id === nodeId);
400
416
  let nodeRun = nodeRuns.at(-1);
401
417
  if (!nodeRun) {
418
+ // Contract D3: a run synthesized while attaching to an already-existing
419
+ // node has no launch arbitration behind it, so it keeps the historic
420
+ // marker instead of inventing a backend.
402
421
  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 } });
403
422
  }
404
423
  if (node.runId !== nodeRun.runId) updateLegionRunId(db, nodeId, nodeRun.runId);
@@ -442,7 +461,7 @@ async function resumeLegionTeam(input: { identity: LegionRuntimeIdentity; input:
442
461
  }
443
462
 
444
463
  function createRootLegion(input: { pi: ExtensionAPI; supervisor: LegionSupervisor; store: RunStore; cwd: string; task: string; settings: ReturnType<typeof loadSubagentSettings>; maxDepth: number; eventBus: LegionEventBus }): LegionRuntimeIdentity {
445
- const rootRun = input.store.createRun({ task: input.task, cwd: input.cwd, maxDepth: input.maxDepth, nodeId: "root", herdr: { supervisor: "rpc" } });
464
+ const rootRun = input.store.createRun({ task: input.task, cwd: input.cwd, maxDepth: input.maxDepth, nodeId: "root", herdr: { supervisor: currentProcessSpawnMode() } });
446
465
  const dbPath = path.resolve(legionDbPath(input.store.runDir(rootRun.runId)));
447
466
  const db = openLegionDb(dbPath);
448
467
  const now = Date.now();
@@ -692,6 +711,9 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
692
711
  label: "Vexillatio",
693
712
  description: "Launch only read-only utility agents and return their collected output.",
694
713
  parameters: VexillatioParams,
714
+ // D4: pi never renders `details` on its own, so without this the
715
+ // arbitrated spawnMode in `details.handles[].spawnMode` is invisible.
716
+ renderResult: (result, opts, theme) => renderSpawnResult(result, opts, theme),
695
717
  async execute(_id, params, signal, _onUpdate, ctx: ExtensionContext): Promise<AgentToolResult<unknown>> {
696
718
  if (signal?.aborted) {
697
719
  return ok("interrupted (parent turn ended; children still running)");
@@ -722,9 +744,10 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
722
744
  },
723
745
  onUtilityChild: (retire) => pendingUtilityRetires.push(retire),
724
746
  });
725
- if (signal?.aborted) {
726
- return ok("interrupted (parent turn ended; children still running)");
727
- }
747
+ // `launchUtilityFamily` already folds the abort into its own return
748
+ // (head line + collected segments + `interrupted` details, #227 D4).
749
+ // Returning `result` unconditionally is what keeps that work visible;
750
+ // re-checking `signal.aborted` here would throw it away again.
728
751
  return result;
729
752
  },
730
753
  });
@@ -760,10 +783,9 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
760
783
  const childActivitySources = new Set<string>();
761
784
  let statusNodeOpener: ((nodeId: string) => void) | undefined;
762
785
  let lastConfirm: ((message: string) => Promise<boolean>) | undefined;
763
- // turn_start/turn_end maintain this so a flush window can wait out a
764
- // busy parent instead of queueing behind the whole turn (join extends
765
- // the window at most MAX_BUSY_EXTENSIONS times).
766
- let parentTurnActive = false;
786
+ // Run-scoped completion deferral lives in the runtime outbox (D-A): a child
787
+ // finishing while this agent run is still active is held until
788
+ // `agent_settled` instead of being queued as an undrained followUp.
767
789
  const rpcEventSource = supervisor as LegionSupervisor & RpcEventSource;
768
790
  const runtime = createSessionRuntime({
769
791
  sendMessage: (message, options) => pi.sendMessage(message, options),
@@ -957,6 +979,9 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
957
979
  description: TOOL_DESCRIPTION,
958
980
  parameters: SubagentParams,
959
981
 
982
+ // D4: the same renderer as `vexillatio` — it reads both detail shapes.
983
+ renderResult: (result, opts, theme) => renderSpawnResult(result, opts, theme),
984
+
960
985
  // The parameter list is fixed by Pi's `ExtensionAPI.registerTool`
961
986
  // contract — an options object is not ours to choose.
962
987
  // pi-lens-ignore: long-parameter-list
@@ -999,7 +1024,6 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
999
1024
  runtime.setJoinConfig({
1000
1025
  mode: settings.joinMode ?? DEFAULT_JOIN_MODE,
1001
1026
  flushMs: settings.joinFlushMs ?? DEFAULT_FLUSH_MS,
1002
- parentBusy: () => parentTurnActive,
1003
1027
  });
1004
1028
 
1005
1029
  if (action === "list") return ok(renderAgentList(agents, team, teamWarnings));
@@ -1104,7 +1128,18 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
1104
1128
  onUpdate,
1105
1129
  });
1106
1130
  if (signal?.aborted) {
1107
- return ok("interrupted (parent turn ended; children still running)");
1131
+ // The frozen head line still leads, but the machine facts must
1132
+ // survive: `launchFamily` already returns its own interrupted result
1133
+ // (frozen head + `runId` + `spawns`) when it observes the abort; this
1134
+ // outer net only catches an abort seen after that check. Replacing the
1135
+ // whole result with a bare `ok()` used to drop `details.spawns` — the
1136
+ // only record of the arbitrated backend once `handles` is cleared (#209/D3).
1137
+ const details = result.details;
1138
+ const carried =
1139
+ details !== null && typeof details === "object" && !Array.isArray(details)
1140
+ ? (details as Record<string, unknown>)
1141
+ : undefined;
1142
+ return ok("interrupted (parent turn ended; children still running)", carried);
1108
1143
  }
1109
1144
  return result;
1110
1145
  },
@@ -1175,19 +1210,22 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
1175
1210
  });
1176
1211
  pi.on("session_info_changed", bindUi);
1177
1212
  pi.on("turn_start", (event, ctx) => {
1178
- parentTurnActive = true;
1179
1213
  bindUi(event, ctx);
1180
1214
  });
1181
1215
  pi.on("turn_end", (event, ctx) => {
1182
- parentTurnActive = false;
1183
1216
  bindUi(event, ctx);
1184
1217
  });
1185
1218
  pi.on("agent_start", (event, ctx) => {
1219
+ runtime.notifyParentRunStart();
1186
1220
  bindUi(event, ctx);
1187
1221
  });
1188
1222
  pi.on("agent_end", (event, ctx) => {
1189
1223
  bindUi(event, ctx);
1190
1224
  });
1225
+ pi.on("agent_settled", (event, ctx) => {
1226
+ runtime.notifyParentSettled();
1227
+ bindUi(event, ctx);
1228
+ });
1191
1229
  pi.on("input", bindUi);
1192
1230
  pi.on("tool_result", (_event, ctx) => {
1193
1231
  bindUi(_event, ctx);
@@ -1317,6 +1355,7 @@ async function controlAction(input: {
1317
1355
  return stopAction({
1318
1356
  params,
1319
1357
  runtime: input.runtime,
1358
+ store: input.store,
1320
1359
  retire: async (name, opts) => {
1321
1360
  const merged = opts ?? retireOpts;
1322
1361
  const job = input.runtime.get(name);
@@ -1358,18 +1397,23 @@ async function controlAction(input: {
1358
1397
  );
1359
1398
  }
1360
1399
  if (!params.name) {
1361
- const live = listLiveChildrenForStatus(store.listRuns());
1400
+ const runs = store.listRuns();
1401
+ const live = listLiveChildrenForStatus(runs);
1362
1402
  return ok(
1363
1403
  renderStatusOverview(
1364
- live.map((child) => ({
1365
- child,
1366
- alive: input.supervisor.isAlive(child.name),
1367
- })),
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 } : {}) };
1408
+ }),
1368
1409
  ),
1369
1410
  );
1370
1411
  }
1371
- const found = findChild(store.listRuns(), params.name);
1412
+ const runs = store.listRuns();
1413
+ const found = findChild(runs, params.name);
1372
1414
  if (!found) return unknownChild(params.name, store, cwd);
1415
+ const alive = input.supervisor.isAlive(params.name);
1416
+ const owner = ownerVerdictFor(store, runs, params.name, alive);
1373
1417
  return ok(renderLiveChildStatus({
1374
1418
  name: found.child.name,
1375
1419
  state: found.child.state,
@@ -1378,7 +1422,9 @@ async function controlAction(input: {
1378
1422
  sessionFile: found.child.sessionFile,
1379
1423
  execution: found.child.execution,
1380
1424
  acceptance: found.child.acceptance,
1381
- alive: input.supervisor.isAlive(params.name),
1425
+ spawnMode: found.child.spawnMode,
1426
+ alive,
1427
+ ...(owner ? { owner } : {}),
1382
1428
  }));
1383
1429
  }
1384
1430
 
@@ -1526,7 +1572,7 @@ async function collectChild(
1526
1572
  }
1527
1573
  }
1528
1574
 
1529
- /** Retire the RPC child while preserving its outcome and session for resume. */
1575
+ /** Retire the child while preserving its outcome and session for resume. */
1530
1576
  async function retireChild(
1531
1577
  ctx: ChildContext,
1532
1578
  ): Promise<AgentToolResult<unknown>> {
@@ -1543,7 +1589,7 @@ async function retireChild(
1543
1589
  await persistChild(ctx.store, ctx.found.runId, ctx.orchestrator, ctx.name);
1544
1590
  return ok(
1545
1591
  `Retired ${ctx.name} (execution=${child.execution?.status ?? "unknown"}). ` +
1546
- `RPC process stopped; session kept for resume: ${child.sessionFile}`,
1592
+ `Child stopped; session kept for resume: ${child.sessionFile}`,
1547
1593
  );
1548
1594
  } catch (error) {
1549
1595
  return fail(`retire failed: ${String(error)}`, ErrorCodes.RETIRE_FAILED);
@@ -1585,7 +1631,7 @@ async function reviveChild(
1585
1631
  await orchestrator.steer(name, params.message);
1586
1632
  followChild(ctx, { watch: true });
1587
1633
  return ok(
1588
- `${action === "resume" ? "Resumed" : "Continued"} ${name} (live RPC child steered).`,
1634
+ `${action === "resume" ? "Resumed" : "Continued"} ${name} (live child steered).`,
1589
1635
  );
1590
1636
  }
1591
1637
 
@@ -1638,7 +1684,7 @@ async function launchFamily(input: {
1638
1684
  content: [
1639
1685
  {
1640
1686
  type: "text",
1641
- text: `playbook: ${plan.steps.map((s) => s.agent).join(", ")} → RPC launch → watch`,
1687
+ text: `playbook: ${plan.steps.map((s) => s.agent).join(", ")} → launch → watch`,
1642
1688
  },
1643
1689
  ],
1644
1690
  details: {},
@@ -1652,7 +1698,7 @@ async function launchFamily(input: {
1652
1698
  task: plan.task,
1653
1699
  cwd,
1654
1700
  maxDepth,
1655
- ...(legion ? { path: parseNestedPathEnv(process.env.PI_SUBAGENT_PARENT_PATH), nodeId: legion.nodeId, herdr: { supervisor: "rpc" as const, node_id: legion.nodeId } } : { herdr: { supervisor: "rpc" as const } }),
1701
+ ...(legion ? { path: parseNestedPathEnv(process.env.PI_SUBAGENT_PARENT_PATH), nodeId: legion.nodeId, herdr: { supervisor: currentProcessSpawnMode(), node_id: legion.nodeId } } : { herdr: { supervisor: currentProcessSpawnMode() } }),
1656
1702
  });
1657
1703
  if (legion && legion.nodeId !== "root" && legion.parentRunId) {
1658
1704
  const node = getNode(legion.db, legion.nodeId);
@@ -1714,16 +1760,26 @@ async function launchFamily(input: {
1714
1760
  results: [],
1715
1761
  handles: [],
1716
1762
  timeoutByName: new Map(),
1763
+ // #209/D3: the arbitrated mode survives `handles` being cleared on an
1764
+ // abort (interrupted results run after that), so it is kept per name
1765
+ // here — same precedent as `timeoutByName`.
1766
+ spawnByName: new Map(),
1717
1767
  ...(nameGate.name ? { launchName: nameGate.name } : {}),
1718
1768
  };
1719
1769
 
1770
+ /** `details.spawns` for the handles this request actually resolved. */
1771
+ const spawnDetails = () => {
1772
+ const spawns = [...session.spawnByName.values()];
1773
+ return spawns.length > 0 ? { spawns } : {};
1774
+ };
1775
+
1720
1776
  const interruptedResult = async (): Promise<AgentToolResult<unknown> | undefined> => {
1721
1777
  if (!input.signal?.aborted) return undefined;
1722
1778
  for (const name of session.handles) await abandonLaunchedChild(session, name);
1723
1779
  session.handles.length = 0;
1724
1780
  return {
1725
1781
  content: [{ type: "text", text: session.results.join("\n") || "interrupted (parent turn ended; children still running)" }],
1726
- details: { runId: run.runId, handles: [], async: params.async ?? true },
1782
+ details: { runId: run.runId, handles: [], async: params.async ?? true, ...spawnDetails() },
1727
1783
  };
1728
1784
  };
1729
1785
 
@@ -1770,13 +1826,13 @@ async function launchFamily(input: {
1770
1826
 
1771
1827
  if (isAsync && session.handles.length > 0) {
1772
1828
  session.results.push(
1773
- "↳ async: a completion message is queued when each RPC child finishes. It wakes this session when idle; otherwise it is delivered after the current turn. Use wait or collect for results. Do not tell children to message the parent.",
1829
+ "↳ async: a completion message is queued when each child finishes. It wakes this session when idle; otherwise it is delivered after the current turn. Use wait or collect for results. Do not tell children to message the parent.",
1774
1830
  );
1775
1831
  }
1776
1832
 
1777
1833
  return {
1778
1834
  content: [{ type: "text", text: session.results.join("\n") }],
1779
- details: { runId: run.runId, handles: session.handles, async: isAsync },
1835
+ details: { runId: run.runId, handles: session.handles, async: isAsync, ...spawnDetails() },
1780
1836
  };
1781
1837
  }
1782
1838
 
@@ -1800,6 +1856,8 @@ interface LaunchSession {
1800
1856
  results: string[];
1801
1857
  handles: string[];
1802
1858
  timeoutByName: Map<string, number>;
1859
+ /** Arbitrated backend per resolved handle; survives an abort clearing `handles`. */
1860
+ spawnByName: Map<string, { name: string; agent: string; spawnMode: SpawnMode }>;
1803
1861
  /** Explicit child name for this single-step launch; omit to auto-allocate. */
1804
1862
  launchName?: string;
1805
1863
  }
@@ -1911,6 +1969,13 @@ async function launchStep(
1911
1969
  await session.store.addChild(session.runId, handle.child);
1912
1970
  }
1913
1971
  session.handles.push(handle.name);
1972
+ // Recorded before the abort check below: an interrupted result runs after
1973
+ // `handles` is cleared and still has to name the arbitrated backend.
1974
+ session.spawnByName.set(handle.name, {
1975
+ name: handle.name,
1976
+ agent: effective.name,
1977
+ spawnMode: handle.spawnMode,
1978
+ });
1914
1979
  if (effective.timeoutMs !== undefined) {
1915
1980
  session.timeoutByName.set(handle.name, effective.timeoutMs);
1916
1981
  }
@@ -1919,7 +1984,7 @@ async function launchStep(
1919
1984
  return;
1920
1985
  }
1921
1986
  session.results.push(
1922
- `▶ ${handle.name} (${effective.name}) supervisor=rpc`,
1987
+ `▶ ${handle.name} (${effective.name}) supervisor=${handle.spawnMode}`,
1923
1988
  );
1924
1989
  // An inherited model the target CLI cannot express is dropped on purpose
1925
1990
  // (the parent's pi model means nothing to cursor). Say so, or the user
@@ -1935,7 +2000,7 @@ async function launchStep(
1935
2000
  content: [
1936
2001
  {
1937
2002
  type: "text",
1938
- text: `playbook: ${handle.name} started via RPC`,
2003
+ text: `playbook: ${handle.name} started (supervisor=${handle.spawnMode})`,
1939
2004
  },
1940
2005
  ],
1941
2006
  details: {},
@@ -2226,7 +2291,7 @@ async function launchUtilityFamily(input: {
2226
2291
  cwd: input.cwd,
2227
2292
  maxDepth: effectiveMaxDepth(catalog.settings.legion?.maxDepth),
2228
2293
  path: parseNestedPathEnv(process.env.PI_SUBAGENT_PARENT_PATH),
2229
- herdr: { supervisor: "rpc" },
2294
+ herdr: { supervisor: currentProcessSpawnMode() },
2230
2295
  });
2231
2296
  if (!run) return fail("vexillatio parent run is not available", ErrorCodes.NOT_FOUND);
2232
2297
  const runId = run.runId;
@@ -2315,6 +2380,7 @@ async function launchUtilityFamily(input: {
2315
2380
  name: handle.name,
2316
2381
  agent: item.agent.name,
2317
2382
  sessionFile: handle.sessionFile,
2383
+ spawnMode: handle.spawnMode,
2318
2384
  orchestrator,
2319
2385
  retire,
2320
2386
  },
@@ -2330,6 +2396,7 @@ async function launchUtilityFamily(input: {
2330
2396
  name: string;
2331
2397
  agent: string;
2332
2398
  sessionFile: string;
2399
+ spawnMode: SpawnMode;
2333
2400
  orchestrator: Orchestrator;
2334
2401
  retire: (opts?: RetireOptions) => Promise<void>;
2335
2402
  }> = [];
@@ -2345,6 +2412,8 @@ async function launchUtilityFamily(input: {
2345
2412
  agent: handle.agent,
2346
2413
  sessionFile: handle.sessionFile,
2347
2414
  timeoutMs,
2415
+ // Arbitrated at launch; never re-derived here (#209).
2416
+ spawnMode: handle.spawnMode,
2348
2417
  orchestrator: handle.orchestrator,
2349
2418
  store,
2350
2419
  watch,
@@ -2386,13 +2455,19 @@ async function launchUtilityFamily(input: {
2386
2455
  return `✗ ${handle.name}: collect failed: ${error instanceof Error ? error.message : String(error)}\nsessionFile: ${handle.sessionFile}`;
2387
2456
  }
2388
2457
  }));
2389
- if (input.signal?.aborted) return ok("interrupted (parent turn ended; children still running)");
2458
+ if (input.signal?.aborted) return interruptedUtilityResult({
2459
+ runId,
2460
+ handles,
2461
+ stillRunning,
2462
+ results,
2463
+ collected,
2464
+ });
2390
2465
  results.push(...collected);
2391
2466
  return {
2392
2467
  content: [{ type: "text", text: results.join("\n\n") || "No utility agents were launched." }],
2393
2468
  details: {
2394
2469
  runId,
2395
- handles: handles.map(({ name, agent, sessionFile }) => ({ name, agent, sessionFile })),
2470
+ handles: handles.map(({ name, agent, sessionFile, spawnMode }) => ({ name, agent, sessionFile, spawnMode })),
2396
2471
  async: false,
2397
2472
  ...(stillRunning.size > 0
2398
2473
  ? { stillRunning: handles.filter(({ name }) => stillRunning.has(name)).map(({ name }) => name) }
@@ -2401,6 +2476,46 @@ async function launchUtilityFamily(input: {
2401
2476
  };
2402
2477
  }
2403
2478
 
2479
+ /**
2480
+ * Build the interrupted vexillatio result from what the collect loop already produced.
2481
+ *
2482
+ * `#227 D4`: an abort must not throw away work that already finished. The frozen
2483
+ * head line stays first and verbatim (`test/unit/teams.test.ts` matches it with a
2484
+ * regex), the collected segments are appended below it, and `details` carries the
2485
+ * same machine facts a non-interrupted return publishes plus `interrupted: true`.
2486
+ *
2487
+ * With no handles (every launch failed) the text degrades to the head line plus
2488
+ * the failure lines only — no empty segment.
2489
+ */
2490
+ function interruptedUtilityResult(input: {
2491
+ runId: string;
2492
+ handles: Array<{ name: string; agent: string; sessionFile: string; spawnMode?: SpawnMode }>;
2493
+ stillRunning: Set<string>;
2494
+ results: string[];
2495
+ collected: string[];
2496
+ }): AgentToolResult<unknown> {
2497
+ const head = "interrupted (parent turn ended; children still running)";
2498
+ const segments = [...input.results, ...input.collected].filter((segment) => segment.length > 0);
2499
+ const stillRunning = input.handles
2500
+ .filter(({ name }) => input.stillRunning.has(name))
2501
+ .map(({ name }) => name);
2502
+ // Routed through `ok()` so the widened `details` parameter has exactly one
2503
+ // real consumer instead of being a dead signature (#227 review F3).
2504
+ return ok(segments.length > 0 ? `${head}\n\n${segments.join("\n\n")}` : head, {
2505
+ runId: input.runId,
2506
+ handles: input.handles.map(({ name, agent, sessionFile, spawnMode }) => ({
2507
+ name,
2508
+ agent,
2509
+ sessionFile,
2510
+ // Optional on purpose: a legacy handle must render as absent, never `rpc`.
2511
+ ...(spawnMode ? { spawnMode } : {}),
2512
+ })),
2513
+ async: false,
2514
+ interrupted: true,
2515
+ ...(stillRunning.length > 0 ? { stillRunning } : {}),
2516
+ });
2517
+ }
2518
+
2404
2519
  /**
2405
2520
  * Resolve an explicit `name=` on launch.
2406
2521
  *
@@ -2546,12 +2661,17 @@ function followJob(
2546
2661
  watch: boolean;
2547
2662
  retire?: (opts?: RetireOptions) => Promise<void>;
2548
2663
  treeIdentity?: { nodeId: string; parentId: string | null; depth: number };
2664
+ /** Arbitrated backend from `Handle.spawnMode`; omitted when unknown (#209). */
2665
+ spawnMode?: SpawnMode;
2549
2666
  },
2550
2667
  ): void {
2551
2668
  const child = input.orchestrator
2552
2669
  .childrenSnapshot()
2553
2670
  .find((entry) => entry.name === input.name);
2554
2671
  const existing = runtime.get(input.name);
2672
+ // The caller's Handle owns the arbitration; the persisted ChildRecord records
2673
+ // the same decision. Neither is re-derived here — no handle, no value.
2674
+ const spawnMode = input.spawnMode ?? child?.spawnMode;
2555
2675
  runtime.track({
2556
2676
  name: input.name,
2557
2677
  runId: input.runId,
@@ -2562,6 +2682,7 @@ function followJob(
2562
2682
  ...(child?.model ? { model: child.model } : {}),
2563
2683
  ...(child?.thinking !== undefined ? { thinking: child.thinking } : {}),
2564
2684
  ...(child?.worktreeBranch ? { worktreeBranch: child.worktreeBranch } : {}),
2685
+ ...(spawnMode ? { spawnMode } : {}),
2565
2686
  ...(input.treeIdentity ? input.treeIdentity : child?.nodeId ? {
2566
2687
  nodeId: child.nodeId,
2567
2688
  parentId: child.nodeId.includes(".") ? child.nodeId.slice(0, child.nodeId.lastIndexOf(".")) : null,
@@ -2638,6 +2759,19 @@ function followChild(ctx: ChildContext, opts: { watch: boolean }): void {
2638
2759
  });
2639
2760
  }
2640
2761
 
2762
+ /**
2763
+ * The arbitrated backend recorded for one handled child of this request.
2764
+ *
2765
+ * Spreads to nothing when unknown, so the caller never has to invent a value.
2766
+ */
2767
+ function spawnModeOf(
2768
+ session: LaunchSession,
2769
+ name: string,
2770
+ ): { spawnMode?: SpawnMode } {
2771
+ const recorded = session.spawnByName.get(name);
2772
+ return recorded ? { spawnMode: recorded.spawnMode } : {};
2773
+ }
2774
+
2641
2775
  function followLaunched(
2642
2776
  session: LaunchSession,
2643
2777
  name: string,
@@ -2655,6 +2789,8 @@ function followLaunched(
2655
2789
  orchestrator: session.orchestrator,
2656
2790
  store: session.store,
2657
2791
  watch: opts.watch,
2792
+ // Handle-derived, recorded before an abort can clear `handles` (#209).
2793
+ ...spawnModeOf(session, name),
2658
2794
  ...(child?.nodeId ? {
2659
2795
  treeIdentity: {
2660
2796
  nodeId: child.nodeId,
@@ -2742,11 +2878,13 @@ export function renderStatusOverview(
2742
2878
  children: Array<{
2743
2879
  child: NonNullable<ReturnType<RunStore["findChild"]>>;
2744
2880
  alive: boolean;
2881
+ /** Run-dir ownership verdict (#209-3); see {@link renderLiveChildStatus}. */
2882
+ owner?: "live" | "dead" | "unknown";
2745
2883
  }>,
2746
2884
  ): string {
2747
2885
  if (children.length === 0) return "No live children.";
2748
2886
  const noun = children.length === 1 ? "child" : "children";
2749
- const blocks = children.map(({ child, alive }) =>
2887
+ const blocks = children.map(({ child, alive, owner }) =>
2750
2888
  renderLiveChildStatus({
2751
2889
  name: child.name,
2752
2890
  state: child.state,
@@ -2755,7 +2893,9 @@ export function renderStatusOverview(
2755
2893
  sessionFile: child.sessionFile,
2756
2894
  execution: child.execution,
2757
2895
  acceptance: child.acceptance,
2896
+ spawnMode: child.spawnMode,
2758
2897
  alive,
2898
+ ...(owner ? { owner } : {}),
2759
2899
  }),
2760
2900
  );
2761
2901
  return [`Status · ${children.length} live ${noun}`, "", blocks.join("\n\n")].join("\n");
@@ -2882,6 +3022,26 @@ export function renderWait(results: WaitResult[]): string {
2882
3022
  return parts.join("\n");
2883
3023
  }
2884
3024
 
3025
+ /**
3026
+ * Run-dir ownership verdict for a child THIS process does not track (#209-3).
3027
+ *
3028
+ * `supervisor.isAlive` is an in-memory registry of children spawned by this
3029
+ * process, so it is always false for a foreign run (another session, or a
3030
+ * restart). The run dir's `owner.json` pid is the durable liveness marker; it
3031
+ * only needs to be consulted when the in-memory answer is "not alive".
3032
+ */
3033
+ function ownerVerdictFor(
3034
+ store: RunStore,
3035
+ runs: ReturnType<RunStore["listRuns"]>,
3036
+ name: string,
3037
+ alive: boolean,
3038
+ ): "live" | "dead" | "unknown" | undefined {
3039
+ if (alive) return undefined;
3040
+ const picked = pickChildByName(runs, name);
3041
+ if (!picked) return "unknown";
3042
+ return ownerState(path.join(store.runDir(picked.run.runId), RUN_OWNER_FILE));
3043
+ }
3044
+
2885
3045
  /**
2886
3046
  * Retire a named child or every child tracked by this session.
2887
3047
  * `force: true` → hard path (graceful:false / SIGTERM→SIGKILL); omit/false → graceful.
@@ -2889,6 +3049,11 @@ export function renderWait(results: WaitResult[]): string {
2889
3049
  export async function stopAction(input: {
2890
3050
  params: { name?: string; all?: boolean; force?: boolean };
2891
3051
  runtime: SessionRuntime;
3052
+ /**
3053
+ * Run store used to resolve a named target this session does not track
3054
+ * (foreign/restarted runs). Omitted callers keep the runtime-only scope.
3055
+ */
3056
+ store?: RunStore;
2892
3057
  retire: (name: string, opts?: RetireOptions) => Promise<void>;
2893
3058
  }): Promise<AgentToolResult<unknown>> {
2894
3059
  const { params, runtime, retire } = input;
@@ -2906,11 +3071,21 @@ export async function stopAction(input: {
2906
3071
  if (params.all) {
2907
3072
  targets = active.map((job) => job.name);
2908
3073
  } else {
2909
- const target = jobs.find((job) => job.name === params.name);
3074
+ // Local const so the earlier `name`/`all` guard narrows through the lookup.
3075
+ const name = params.name;
3076
+ const target = name ? jobs.find((job) => job.name === name) : undefined;
2910
3077
  if (!target) {
2911
- return fail(`unknown child: ${params.name} (not tracked by this session).`, ErrorCodes.NOT_FOUND);
3078
+ // Not tracked by this session, but a foreign/restarted run may still
3079
+ // own a persisted record — the same records `status` can display. Fall
3080
+ // back to it so stop and retire agree on what is reachable.
3081
+ const found = input.store && name ? findChild(input.store.listRuns(), name) : null;
3082
+ if (!found) {
3083
+ return fail(`unknown child: ${params.name} (not tracked by this session).`, ErrorCodes.NOT_FOUND);
3084
+ }
3085
+ targets = found.child.state === "retired" ? [] : [found.child.name];
3086
+ } else {
3087
+ targets = target.state === "retired" ? [] : [target.name];
2912
3088
  }
2913
- targets = target.state === "retired" ? [] : [target.name];
2914
3089
  }
2915
3090
  if (targets.length === 0) return ok("No non-retired children to stop.");
2916
3091
 
@@ -3080,34 +3255,6 @@ function findChild(
3080
3255
  return { runId: picked.run.runId, run: picked.run, child: picked.child };
3081
3256
  }
3082
3257
 
3083
- function renderChild(
3084
- child: NonNullable<ReturnType<RunStore["findChild"]>>,
3085
- action: string,
3086
- ): AgentToolResult<unknown> {
3087
- const lines = [
3088
- `${child.name} — state=${child.state} agent=${child.agent ?? "?"} kind=${child.kind ?? "?"}`,
3089
- "supervisor=rpc",
3090
- `session=${child.sessionFile}`,
3091
- ];
3092
- if (child.execution) {
3093
- lines.push(
3094
- `execution: ${child.execution.status}${child.execution.reason ? ` (${child.execution.reason})` : ""}`,
3095
- );
3096
- if (child.execution.usage) {
3097
- lines.push(
3098
- `usage: in=${child.execution.usage.input} out=${child.execution.usage.output} cost=${child.execution.usage.cost.toFixed(4)}`,
3099
- );
3100
- }
3101
- }
3102
- if (child.acceptance)
3103
- lines.push(
3104
- `acceptance: ${child.acceptance.status} (${child.acceptance.level})`,
3105
- );
3106
- if (action === "collect" && !child.execution)
3107
- lines.push("(not yet collected — call collect while the agent is alive)");
3108
- return ok(lines.join("\n"));
3109
- }
3110
-
3111
3258
  function renderCollect(
3112
3259
  name: string,
3113
3260
  collected: {
@@ -3165,8 +3312,20 @@ function renderCollect(
3165
3312
  return lines.join("\n");
3166
3313
  }
3167
3314
 
3168
- function ok(text: string): AgentToolResult<unknown> {
3169
- return { content: [{ type: "text", text }], details: {} };
3315
+ /**
3316
+ * Render a success/neutral result to the model.
3317
+ *
3318
+ * `details` defaults to `{}`; callers that already computed machine facts
3319
+ * (runId/handles for an interrupted collect) pass them through instead of
3320
+ * discarding them (#227 D4) — `interruptedUtilityResult` is that caller.
3321
+ * `isError` is deliberately never set — the rendering chain and every assertion
3322
+ * in this repo work without it.
3323
+ */
3324
+ function ok(
3325
+ text: string,
3326
+ details: Record<string, unknown> = {},
3327
+ ): AgentToolResult<unknown> {
3328
+ return { content: [{ type: "text", text }], details };
3170
3329
  }
3171
3330
 
3172
3331
  /**