pi-subagents 0.65.1 → 0.66.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 +55 -0
  2. package/README.md +1 -1
  3. package/agents/researcher.md +23 -13
  4. package/docs/agents.md +16 -2
  5. package/docs/configuration.md +18 -0
  6. package/docs/extension-api.md +91 -0
  7. package/docs/models.md +58 -1
  8. package/docs/observability.md +42 -2
  9. package/docs/tool-reference.md +3 -3
  10. package/docs/workflows.md +14 -7
  11. package/package.json +2 -1
  12. package/skills/pi-subagents/references/execution-controls.md +14 -1
  13. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
  14. package/src/agents/advertised-agent-prompt.ts +63 -0
  15. package/src/agents/agent-management.ts +14 -1
  16. package/src/agents/agent-serializer.ts +2 -0
  17. package/src/agents/agents.ts +8 -0
  18. package/src/api/shared-types.ts +1 -1
  19. package/src/api/workflow-resources.ts +6 -0
  20. package/src/extension/index.ts +40 -2
  21. package/src/extension/public-execution.ts +0 -1
  22. package/src/extension/rpc.ts +4 -21
  23. package/src/extension/schemas.ts +8 -6
  24. package/src/extension/tool-description.ts +6 -5
  25. package/src/intercom/native-supervisor-channel.ts +82 -54
  26. package/src/runs/background/active-async-capacity.ts +18 -18
  27. package/src/runs/background/async-job-tracker.ts +35 -3
  28. package/src/runs/background/async-status-snapshot.ts +10 -12
  29. package/src/runs/background/async-status.ts +17 -9
  30. package/src/runs/background/auto-drain.ts +40 -29
  31. package/src/runs/background/chain-root-attachment.ts +8 -0
  32. package/src/runs/background/control-channel.ts +78 -44
  33. package/src/runs/background/notify.ts +86 -12
  34. package/src/runs/background/owned-process-tree.ts +6 -6
  35. package/src/runs/background/process-terminal.ts +23 -23
  36. package/src/runs/background/run-child-session.ts +60 -32
  37. package/src/runs/background/run-status.ts +75 -5
  38. package/src/runs/background/runner-aliases.ts +18 -7
  39. package/src/runs/background/runner-child-launch.ts +86 -0
  40. package/src/runs/background/stale-run-reconciler.ts +3 -1
  41. package/src/runs/background/subagent-runner.ts +412 -209
  42. package/src/runs/background/subagent-wait.ts +3 -0
  43. package/src/runs/background/wait-completions.ts +4 -0
  44. package/src/runs/foreground/async-steering-action.ts +19 -0
  45. package/src/runs/foreground/execution.ts +101 -25
  46. package/src/runs/foreground/subagent-executor.ts +474 -197
  47. package/src/runs/foreground/workflow-detach-reconcile.ts +8 -5
  48. package/src/runs/foreground/workflow-foreground-steering.ts +56 -2
  49. package/src/runs/shared/acceptance.ts +2 -2
  50. package/src/runs/shared/agent-contract.ts +1 -1
  51. package/src/runs/shared/async-status-projection.ts +47 -47
  52. package/src/runs/shared/child-hooks.ts +151 -2
  53. package/src/runs/shared/child-launch.ts +18 -13
  54. package/src/runs/shared/child-session.ts +42 -6
  55. package/src/runs/shared/child-tool-plan.ts +2 -2
  56. package/src/runs/shared/completion-evidence.ts +2 -2
  57. package/src/runs/shared/completion-guard.ts +1 -0
  58. package/src/runs/shared/host-step-status.ts +11 -11
  59. package/src/runs/shared/llm-intent-arbiter.ts +10 -9
  60. package/src/runs/shared/model-fallback.ts +10 -6
  61. package/src/runs/shared/nested-events.ts +5 -5
  62. package/src/runs/shared/orca-progress-tabs.ts +6 -0
  63. package/src/runs/shared/parallel-handoff.ts +57 -12
  64. package/src/runs/shared/parallel-utils.ts +2 -2
  65. package/src/runs/shared/readonly-drain-observation.ts +42 -0
  66. package/src/runs/shared/readonly-model-continuation.ts +69 -0
  67. package/src/runs/shared/readonly-session-evidence.ts +307 -0
  68. package/src/runs/shared/run-fanout-budget.ts +8 -8
  69. package/src/runs/shared/runtime-acknowledged-extensions.ts +3 -3
  70. package/src/runs/shared/subagent-prompt-runtime.ts +13 -3
  71. package/src/runs/shared/worktree-setup-command.ts +190 -0
  72. package/src/runs/shared/worktree.ts +332 -204
  73. package/src/shared/types.ts +81 -60
  74. package/src/shared/utils.ts +7 -2
  75. package/src/shared/workflow-child-permit.ts +18 -13
  76. package/src/tui/fleet.ts +11 -5
  77. package/src/tui/render.ts +23 -5
  78. package/src/workflows/chat-progress.ts +3 -3
  79. package/src/workflows/scripted-workflow.ts +38 -10
  80. package/src/workflows/workflow-checklist.ts +11 -15
  81. package/src/workflows/workflow-child-summary.ts +57 -8
  82. package/src/workflows/workflow-preflight.ts +19 -19
  83. package/src/workflows/workflow-receipt.ts +3 -3
  84. package/src/workflows/workflow-resources.ts +96 -21
  85. package/src/workflows/workflow-settlement.ts +3 -0
@@ -6,6 +6,7 @@
6
6
  * observation channel, not a delivery acknowledgement.
7
7
  */
8
8
 
9
+ import { debuglog } from "node:util";
9
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
11
  import { buildCompletionKey, markSeenWithTtl } from "./completion-dedupe.ts";
11
12
  import {
@@ -34,6 +35,7 @@ export interface SubagentNotifyChildOutput {
34
35
  export type SubagentNotifyWatchdogBlocker = Pick<ChildWatchdogWarningSummary, "summary" | "addressed" | "stalemate"> & { agent: string };
35
36
 
36
37
  export interface SubagentNotifyDetails {
38
+ workflowReceiptPath?: string;
37
39
  agent: string;
38
40
  status: "completed" | "failed" | "paused" | "stopped";
39
41
  source?: "async" | "foreground";
@@ -189,11 +191,17 @@ function structuredOutputText(value: unknown): string | undefined {
189
191
  }
190
192
  }
191
193
 
194
+ function isDegenerateOutput(text: string): boolean {
195
+ return !text.trim() || text.trim() === "</think>";
196
+ }
197
+
192
198
  function childInlinePreview(child: CompletionChild): { preview?: string; truncated?: boolean; unavailableReason?: string } {
193
199
  const output = typeof child.output === "string" ? child.output : "";
194
200
  const outputReference = childSavedOutputPath(child);
195
201
  const referenceOnly = Boolean(outputReference && output.trim().startsWith("Output saved to:"));
196
- const raw = child.outputState === "absent" || referenceOnly ? structuredOutputText(child.structuredOutput) : output || structuredOutputText(child.structuredOutput);
202
+ const raw = child.outputState === "absent" || referenceOnly
203
+ ? structuredOutputText(child.structuredOutput)
204
+ : isDegenerateOutput(output) ? structuredOutputText(child.structuredOutput) ?? output : output;
197
205
  if (!raw?.trim()) {
198
206
  return { unavailableReason: referenceOnly ? "saved output is file-only" : "no safe inline output" };
199
207
  }
@@ -283,6 +291,7 @@ export function formatSingleCompletion(details: SubagentNotifyDetails): string {
283
291
  : undefined;
284
292
  return [
285
293
  `${taskKind} ${details.status}: **${details.agent}**${details.taskInfo ?? ""}`,
294
+ details.workflowReceiptPath ? `Workflow receipt: ${details.workflowReceiptPath}` : undefined,
286
295
  "",
287
296
  scheduleLine,
288
297
  scheduleLine ? "" : undefined,
@@ -303,7 +312,11 @@ export function parseSubagentNotifyContent(content: string): SubagentNotifyDetai
303
312
  const lines = content.split("\n");
304
313
  const match = (lines[0] ?? "").match(/^(Background task|Detached foreground task) (completed|failed|paused|stopped): \*\*(.+?)\*\*(?:\s+(\([^)]*\)))?$/);
305
314
  if (!match) return undefined;
306
- let body = lines.slice(2);
315
+ // Only the header slot before the blank separator can carry a receipt.
316
+ // Identical lines anywhere in model output remain preview text.
317
+ const receiptHeader = lines[1]?.startsWith("Workflow receipt: ") && lines[2] === "";
318
+ const workflowReceiptPath = receiptHeader ? lines[1]!.slice("Workflow receipt: ".length) : undefined;
319
+ let body = lines.slice(receiptHeader ? 3 : 2);
307
320
  // Restore the schedule origin so a re-rendered notice keeps its attribution and
308
321
  // does not fold the line into the result preview.
309
322
  const scheduleMatch = (body[0] ?? "").match(/^Scheduled run from \*\*(.+?)\*\* \(schedule (.+?)\)\.$/);
@@ -359,6 +372,7 @@ export function parseSubagentNotifyContent(content: string): SubagentNotifyDetai
359
372
  ...(match[1] === "Detached foreground task" ? { source: "foreground" as const } : {}),
360
373
  ...(match[4] ? { taskInfo: match[4] } : {}),
361
374
  ...(parsedScheduleOrigin ? { scheduleOrigin: parsedScheduleOrigin } : {}),
375
+ ...(workflowReceiptPath ? { workflowReceiptPath } : {}),
362
376
  resultPreview,
363
377
  ...(handoffPath ? { handoffPath } : {}),
364
378
  ...(workflowRunId ? { workflowRunId } : {}),
@@ -377,6 +391,7 @@ export function formatGroupedCompletion(details: SubagentNotifyDetails[]): strin
377
391
  if (!detail) continue;
378
392
  const sessionLine = formatSessionLine(detail);
379
393
  blocks.push(`${index + 1}. ${detail.agent}${detail.taskInfo ?? ""}${detail.scheduleOrigin ? ` — scheduled run from ${detail.scheduleOrigin.name ?? detail.scheduleOrigin.id} (schedule ${detail.scheduleOrigin.id})` : ""}`);
394
+ if (detail.workflowReceiptPath) blocks.push(`Workflow receipt: ${detail.workflowReceiptPath}`);
380
395
  blocks.push(formatResultPreview(detail));
381
396
  blocks.push(...formatWatchdogBlockerLines(detail));
382
397
  if (detail.handoffPath) blocks.push(`Parallel handoff: ${detail.handoffPath}`);
@@ -387,7 +402,31 @@ export function formatGroupedCompletion(details: SubagentNotifyDetails[]): strin
387
402
  return blocks.join("\n").trimEnd();
388
403
  }
389
404
 
405
+ const notificationDebug = debuglog("pi-subagents-notify");
406
+ type TraceIdentity = Pick<CompletionNotification, "id" | "runId" | "source">;
407
+ type NotificationReason = "disposed" | "missing_session" | "foreground_session_mismatch" | "not_owned"
408
+ | "intercom_delivered" | "deduped_ttl" | "deduped_pending" | "batch_deferred"
409
+ | "emit_foreground_session_mismatch" | "emit_not_owned" | "send_accepted" | "send_failed" | "dispose_pending";
410
+
411
+ // Slice before sanitizing: diagnostic work and each identity are bounded even for
412
+ // malformed result metadata. Never include task/output, paths, or error bodies.
413
+ function traceIdentity(result: TraceIdentity): TraceIdentity {
414
+ const identity = (value: unknown) => typeof value === "string"
415
+ ? value.slice(0, 128).replace(/[^a-zA-Z0-9_.:-]/g, "_") : undefined;
416
+ return { id: identity(result.id), runId: identity(result.runId), source: result.source === "foreground" ? "foreground" : "async" };
417
+ }
418
+
419
+ function traceNotification(reason: NotificationReason, result?: TraceIdentity): void {
420
+ if (!notificationDebug.enabled) return;
421
+ try {
422
+ notificationDebug("%s", JSON.stringify({ reason, ...(result ? traceIdentity(result) : {}) }));
423
+ } catch {
424
+ // Diagnostics must never change delivery acknowledgement.
425
+ }
426
+ }
427
+
390
428
  interface PendingCompletion {
429
+ trace?: TraceIdentity;
391
430
  key: string;
392
431
  details: SubagentNotifyDetails;
393
432
  sessionId: string;
@@ -448,6 +487,10 @@ export function buildCompletionDetails(result: CompletionNotification): Subagent
448
487
  ? result.parallelHandoff as { path?: unknown }
449
488
  : undefined;
450
489
  const handoffPath = typeof parallelHandoff?.path === "string" ? parallelHandoff.path : undefined;
490
+ const receipt = result.workflowReceipt;
491
+ const receiptPath = receipt && typeof receipt === "object" && !Array.isArray(receipt)
492
+ ? (receipt as Record<string, unknown>).path : undefined;
493
+ const workflowReceiptPath = typeof receiptPath === "string" && receiptPath ? receiptPath : undefined;
451
494
  const rawRunId = typeof result.runId === "string" ? result.runId : typeof result.id === "string" ? result.id : undefined;
452
495
  const workflowRunId = (result.mode === "workflow" || agent === "workflow") && rawRunId ? rawRunId : undefined;
453
496
  const directChild = !workflowRunId && result.results?.length === 1 ? result.results[0]! : undefined;
@@ -456,10 +499,17 @@ export function buildCompletionDetails(result: CompletionNotification): Subagent
456
499
  : undefined;
457
500
  const directSummary = summary.trim();
458
501
  const directAgent = typeof directChild?.agent === "string" ? directChild.agent : agent;
502
+ const directSummaryBody = directAgent && summary.trimStart().startsWith(`${directAgent}:\n`)
503
+ ? summary.trimStart().slice(directAgent.length + 2)
504
+ : directSummary;
505
+ const directDegenerateSummary = directChild
506
+ && isDegenerateOutput(typeof directChild.output === "string" ? directChild.output : "")
507
+ && isDegenerateOutput(directSummaryBody)
508
+ && structuredOutputText(directChild.structuredOutput) !== undefined;
459
509
  const directNoOutputSummary = directChild && (!directSummary
460
510
  || directSummary === "(no output)"
461
511
  || (directAgent && directSummary === `${directAgent}:\n(no output)`));
462
- const resultPreview = directStructuredPreview && directNoOutputSummary
512
+ const resultPreview = directStructuredPreview && (directNoOutputSummary || directDegenerateSummary)
463
513
  ? `Structured output:\n${directStructuredPreview}`
464
514
  : summary;
465
515
  const childRuns = result.results?.flatMap((child) => {
@@ -517,6 +567,7 @@ export function buildCompletionDetails(result: CompletionNotification): Subagent
517
567
  agent,
518
568
  status,
519
569
  ...(scheduleOrigin ? { scheduleOrigin } : {}),
570
+ ...(workflowReceiptPath ? { workflowReceiptPath } : {}),
520
571
  ...(result.source ? { source: result.source } : {}),
521
572
  ...(taskInfo ? { taskInfo } : {}),
522
573
  resultPreview,
@@ -548,8 +599,9 @@ export default function registerSubagentNotify(
548
599
  && typeof completionOwnerId === "string"
549
600
  && completionOwnerId === state.completionOwnerId);
550
601
 
551
- const settle = (items: PendingCompletion[], accepted: boolean) => {
602
+ const settle = (items: PendingCompletion[], accepted: boolean, reason?: NotificationReason) => {
552
603
  for (const item of items) {
604
+ if (reason) traceNotification(reason, item.trace);
553
605
  pending.delete(item.key);
554
606
  if (accepted) markSeenWithTtl(seen, item.key, now(), ttlMs);
555
607
  item.resolve(accepted);
@@ -562,10 +614,12 @@ export default function registerSubagentNotify(
562
614
  const owned = item.details.source === "foreground"
563
615
  ? item.sessionId === state.currentSessionId
564
616
  : ownsResult(item.sessionId, item.completionOwnerId);
617
+ if (!owned) traceNotification(item.details.source === "foreground" ? "emit_foreground_session_mismatch" : "emit_not_owned", item.trace);
565
618
  (owned ? accepted : rejected).push(item);
566
619
  }
567
620
  settle(rejected, false);
568
- settle(accepted, sendCompletion(pi, accepted));
621
+ const sent = sendCompletion(pi, accepted);
622
+ settle(accepted, sent, sent ? "send_accepted" : "send_failed");
569
623
  };
570
624
  const getBatcher = (result: CompletionNotification) => {
571
625
  const key = completionBatchKey(result);
@@ -583,17 +637,35 @@ export default function registerSubagentNotify(
583
637
  };
584
638
 
585
639
  const deliver = (result: CompletionNotification): Promise<boolean> => {
586
- if (disposed || typeof result.sessionId !== "string") return Promise.resolve(false);
640
+ if (disposed || typeof result.sessionId !== "string") {
641
+ traceNotification(disposed ? "disposed" : "missing_session", result);
642
+ return Promise.resolve(false);
643
+ }
587
644
  if (result.source === "foreground") {
588
- if (result.sessionId !== state.currentSessionId) return Promise.resolve(false);
589
- } else if (!ownsResult(result.sessionId, result.completionOwnerId)) return Promise.resolve(false);
590
- if (result.intercomDelivered === true) return Promise.resolve(true);
645
+ if (result.sessionId !== state.currentSessionId) {
646
+ traceNotification("foreground_session_mismatch", result);
647
+ return Promise.resolve(false);
648
+ }
649
+ } else if (!ownsResult(result.sessionId, result.completionOwnerId)) {
650
+ traceNotification("not_owned", result);
651
+ return Promise.resolve(false);
652
+ }
653
+ if (result.intercomDelivered === true) {
654
+ traceNotification("intercom_delivered", result);
655
+ return Promise.resolve(true);
656
+ }
591
657
  const key = buildCompletionKey(result, "notify");
592
658
  const seenAt = seen.get(key);
593
- if (seenAt !== undefined && now() - seenAt <= ttlMs) return Promise.resolve(true);
659
+ if (seenAt !== undefined && now() - seenAt <= ttlMs) {
660
+ traceNotification("deduped_ttl", result);
661
+ return Promise.resolve(true);
662
+ }
594
663
  if (seenAt !== undefined) seen.delete(key);
595
664
  const inFlight = pending.get(key);
596
- if (inFlight) return inFlight;
665
+ if (inFlight) {
666
+ traceNotification("deduped_pending", result);
667
+ return inFlight;
668
+ }
597
669
  const details = buildCompletionDetails(result);
598
670
  let resolve!: (accepted: boolean) => void;
599
671
  const completion = new Promise<boolean>((settleCompletion) => { resolve = settleCompletion; });
@@ -606,6 +678,7 @@ export default function registerSubagentNotify(
606
678
  triggerTurn: result.triggerTurn !== false,
607
679
  resolve,
608
680
  };
681
+ if (notificationDebug.enabled) item.trace = traceIdentity(result);
609
682
  if (details.source === "foreground") {
610
683
  emit([item]);
611
684
  return completion;
@@ -616,6 +689,7 @@ export default function registerSubagentNotify(
616
689
  emit([item]);
617
690
  return completion;
618
691
  }
692
+ if (batchConfig.enabled) traceNotification("batch_deferred", item.trace);
619
693
  batcher.push(item);
620
694
  return completion;
621
695
  };
@@ -633,7 +707,7 @@ export default function registerSubagentNotify(
633
707
  dispose() {
634
708
  if (disposed) return;
635
709
  disposed = true;
636
- for (const batcher of batchers.values()) settle(batcher.dispose(), false);
710
+ for (const batcher of batchers.values()) settle(batcher.dispose(), false, "dispose_pending");
637
711
  batchers.clear();
638
712
  for (const unsubscribe of [unsubscribeAsync, unsubscribeForeground]) {
639
713
  try {
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import type { ProcessTreeTerminalV1 } from "../../shared/types.ts";
2
+ import type { ProcessTreeTerminal } from "../../shared/types.ts";
3
3
 
4
4
  const DEFAULT_TERM_GRACE_MS = 3000;
5
5
  const DEFAULT_KILL_VERIFY_MS = 1000;
@@ -52,25 +52,25 @@ async function waitUntilGroupTerminal(
52
52
  }
53
53
  }
54
54
 
55
- function observed(processGroupId: number): ProcessTreeTerminalV1 {
55
+ function observed(processGroupId: number): ProcessTreeTerminal {
56
56
  return { state: "observed", mechanism: "posix-process-group", processGroupId, verifiedAt: Date.now() };
57
57
  }
58
58
 
59
59
  /** Owns one writer process group and arbitrates its cleanup exactly once. */
60
60
  export interface OwnedProcessTreeController {
61
- terminate(): Promise<ProcessTreeTerminalV1>;
62
- finishAfterWriterClose(): Promise<ProcessTreeTerminalV1>;
61
+ terminate(): Promise<ProcessTreeTerminal>;
62
+ finishAfterWriterClose(): Promise<ProcessTreeTerminal>;
63
63
  }
64
64
 
65
65
  export function createOwnedProcessTreeController(
66
66
  pid: number,
67
67
  options: { termGraceMs?: number; killVerifyMs?: number } = {},
68
68
  ): OwnedProcessTreeController {
69
- let termination: Promise<ProcessTreeTerminalV1> | undefined;
69
+ let termination: Promise<ProcessTreeTerminal> | undefined;
70
70
  const posixGroupOwned = process.platform !== "win32";
71
71
  const target = posixGroupOwned ? -pid : pid;
72
72
 
73
- const terminate = (): Promise<ProcessTreeTerminalV1> => {
73
+ const terminate = (): Promise<ProcessTreeTerminal> => {
74
74
  if (termination) return termination;
75
75
  termination = (async () => {
76
76
  if (!posixGroupOwned) {
@@ -4,10 +4,10 @@ import { writeAtomicJson, writePrivateAtomicJson } from "../../shared/atomic-jso
4
4
  import {
5
5
  SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
6
6
  type AsyncStatus,
7
- type CanonicalSessionTerminalV1,
8
- type ProcessInstanceExitV1,
7
+ type CanonicalSessionTerminal,
8
+ type ProcessInstanceExit,
9
9
  type ProcessTerminalReason,
10
- type ProcessTerminalV1,
10
+ type ProcessTerminal,
11
11
  } from "../../shared/types.ts";
12
12
  import { canonicalSessionId, inspectSessionLease } from "../shared/session-lease.ts";
13
13
  import { releaseActiveRunIndex } from "./active-run-index.ts";
@@ -16,7 +16,7 @@ export interface ProcessTerminalCandidate {
16
16
  version: 1;
17
17
  runId: string;
18
18
  runnerProcessInstanceId: string;
19
- writers: Record<string, ProcessInstanceExitV1[]>;
19
+ writers: Record<string, ProcessInstanceExit[]>;
20
20
  expectedWriters?: Record<string, number>;
21
21
  sessionFile?: string;
22
22
  revivalLeaseToken?: string;
@@ -34,7 +34,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
34
34
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
35
35
  }
36
36
 
37
- function validProcessInstance(value: unknown, kind?: "runner" | "pi-writer"): value is ProcessInstanceExitV1 {
37
+ function validProcessInstance(value: unknown, kind?: "runner" | "pi-writer"): value is ProcessInstanceExit {
38
38
  if (!isRecord(value)) return false;
39
39
  if (typeof value.processInstanceId !== "string" || value.processInstanceId.length === 0) return false;
40
40
  if (kind ? value.kind !== kind : (value.kind !== "runner" && value.kind !== "pi-writer")) return false;
@@ -56,7 +56,7 @@ function validProcessInstance(value: unknown, kind?: "runner" | "pi-writer"): va
56
56
  && (value.processTree.diagnostic === undefined || typeof value.processTree.diagnostic === "string");
57
57
  }
58
58
 
59
- function validInstance(value: unknown): value is ProcessInstanceExitV1 {
59
+ function validInstance(value: unknown): value is ProcessInstanceExit {
60
60
  return validProcessInstance(value, "pi-writer");
61
61
  }
62
62
 
@@ -78,7 +78,7 @@ export function readProcessTerminalCandidate(asyncDir: string): ProcessTerminalC
78
78
  if (!isRecord(raw) || raw.version !== 1 || typeof raw.runId !== "string" || typeof raw.runnerProcessInstanceId !== "string" || !isRecord(raw.writers)) {
79
79
  throw new Error(`Invalid process-terminal candidate in '${asyncDir}'.`);
80
80
  }
81
- const writers: Record<string, ProcessInstanceExitV1[]> = {};
81
+ const writers: Record<string, ProcessInstanceExit[]> = {};
82
82
  for (const [index, entries] of Object.entries(raw.writers)) {
83
83
  if (!Array.isArray(entries) || !entries.every(validInstance)) throw new Error(`Invalid writer process records for child '${index}'.`);
84
84
  writers[index] = entries;
@@ -137,7 +137,7 @@ export function markProcessTerminalCandidateLeaseRelease(asyncDir: string, token
137
137
  writeProcessTerminalCandidate(asyncDir, { ...candidate, revivalLeaseReleaseAcknowledged: acknowledged });
138
138
  }
139
139
 
140
- function unknownProof(runId: string, runnerProcessInstanceId: string, reason: ProcessTerminalReason, diagnostic?: string): ProcessTerminalV1 {
140
+ function unknownProof(runId: string, runnerProcessInstanceId: string, reason: ProcessTerminalReason, diagnostic?: string): ProcessTerminal {
141
141
  return { version: 1, state: "unknown", runId, runnerProcessInstanceId, reason, ...(diagnostic ? { diagnostic } : {}) };
142
142
  }
143
143
 
@@ -147,7 +147,7 @@ function resumeDisposition(state: string | undefined, sessionFile: string | unde
147
147
  return sessionFile && fs.existsSync(sessionFile) ? "resumable" : "unavailable";
148
148
  }
149
149
 
150
- function sessionProjection(candidate: ProcessTerminalCandidate, lease: ReturnType<typeof inspectSessionLease>): CanonicalSessionTerminalV1 | undefined {
150
+ function sessionProjection(candidate: ProcessTerminalCandidate, lease: ReturnType<typeof inspectSessionLease>): CanonicalSessionTerminal | undefined {
151
151
  if (!candidate.sessionFile || lease.state !== "free") return undefined;
152
152
  if (candidate.revivalLeaseToken && candidate.revivalLeaseReleaseAcknowledged !== true) return undefined;
153
153
  return {
@@ -158,7 +158,7 @@ function sessionProjection(candidate: ProcessTerminalCandidate, lease: ReturnTyp
158
158
  };
159
159
  }
160
160
 
161
- function validateProof(raw: unknown, asyncDir: string, fallback?: { runId?: string; runnerProcessInstanceId?: string }): raw is ProcessTerminalV1 {
161
+ function validateProof(raw: unknown, asyncDir: string, fallback?: { runId?: string; runnerProcessInstanceId?: string }): raw is ProcessTerminal {
162
162
  if (!isRecord(raw) || raw.version !== 1 || !["pending", "observed", "unknown", "not-started"].includes(String(raw.state)) || typeof raw.runId !== "string" || !raw.runId || typeof raw.runnerProcessInstanceId !== "string" || !raw.runnerProcessInstanceId) {
163
163
  throw new Error(`Invalid process-terminal proof in '${asyncDir}'.`);
164
164
  }
@@ -177,21 +177,21 @@ function validateProof(raw: unknown, asyncDir: string, fallback?: { runId?: stri
177
177
  return true;
178
178
  }
179
179
 
180
- export function sanitizeProcessTerminal(value: unknown, fallback: { runId?: string; runnerProcessInstanceId?: string }, label = "status"): ProcessTerminalV1 | undefined {
180
+ export function sanitizeProcessTerminal(value: unknown, fallback: { runId?: string; runnerProcessInstanceId?: string }, label = "status"): ProcessTerminal | undefined {
181
181
  if (value === undefined) return undefined;
182
182
  try {
183
183
  validateProof(value, label, fallback);
184
- return value as ProcessTerminalV1;
184
+ return value as ProcessTerminal;
185
185
  } catch (error) {
186
186
  return unknownProof(fallback.runId ?? label, fallback.runnerProcessInstanceId ?? "unknown", "proof-write-failed", errorMessage(error));
187
187
  }
188
188
  }
189
189
 
190
- export function readProcessTerminal(asyncDir: string, fallback?: { runId?: string; runnerProcessInstanceId?: string }): ProcessTerminalV1 | undefined {
190
+ export function readProcessTerminal(asyncDir: string, fallback?: { runId?: string; runnerProcessInstanceId?: string }): ProcessTerminal | undefined {
191
191
  try {
192
192
  const raw = JSON.parse(fs.readFileSync(processTerminalPath(asyncDir), "utf-8")) as unknown;
193
193
  validateProof(raw, asyncDir, fallback);
194
- return raw as ProcessTerminalV1;
194
+ return raw as ProcessTerminal;
195
195
  } catch (error) {
196
196
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
197
197
  return unknownProof(fallback?.runId ?? path.basename(asyncDir), fallback?.runnerProcessInstanceId ?? "unknown", "proof-write-failed", errorMessage(error));
@@ -199,12 +199,12 @@ export function readProcessTerminal(asyncDir: string, fallback?: { runId?: strin
199
199
  }
200
200
 
201
201
  function stepProcessTerminalProof(
202
- proof: ProcessTerminalV1,
202
+ proof: ProcessTerminal,
203
203
  childIndex: number,
204
- state: ProcessTerminalV1["state"],
205
- records: ProcessInstanceExitV1[],
206
- resumeDispositionValue: ProcessTerminalV1["resumeDisposition"],
207
- ): ProcessTerminalV1 {
204
+ state: ProcessTerminal["state"],
205
+ records: ProcessInstanceExit[],
206
+ resumeDispositionValue: ProcessTerminal["resumeDisposition"],
207
+ ): ProcessTerminal {
208
208
  const base = {
209
209
  version: 1 as const,
210
210
  runId: proof.runId,
@@ -221,7 +221,7 @@ function stepProcessTerminalProof(
221
221
  return { ...base, state };
222
222
  }
223
223
 
224
- function overlayStatus(asyncDir: string, proof: ProcessTerminalV1, candidate?: ProcessTerminalCandidate): void {
224
+ function overlayStatus(asyncDir: string, proof: ProcessTerminal, candidate?: ProcessTerminalCandidate): void {
225
225
  const statusPath = path.join(asyncDir, "status.json");
226
226
  try {
227
227
  const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
@@ -244,13 +244,13 @@ export function finalizeProcessTerminal(
244
244
  asyncDir: string,
245
245
  runId: string,
246
246
  runnerClose: RunnerCloseObservation,
247
- ): ProcessTerminalV1 {
247
+ ): ProcessTerminal {
248
248
  const existing = readProcessTerminal(asyncDir, { runId, runnerProcessInstanceId: runnerClose.processInstanceId });
249
249
  if (existing && fs.existsSync(processTerminalPath(asyncDir))) {
250
250
  if (existing.state === "observed" && existing.runId === runId && existing.runnerProcessInstanceId === runnerClose.processInstanceId) return existing;
251
251
  if (existing.state === "unknown") return existing;
252
252
  }
253
- let proof: ProcessTerminalV1;
253
+ let proof: ProcessTerminal;
254
254
  let candidateForOverlay: ProcessTerminalCandidate | undefined;
255
255
  try {
256
256
  const candidate = readProcessTerminalCandidate(asyncDir);
@@ -279,7 +279,7 @@ export function finalizeProcessTerminal(
279
279
  } else if (allWriters.some((writer) => writer.kind === "pi-writer" && writer.processTree.state !== "observed")) {
280
280
  proof = unknownProof(runId, runnerClose.processInstanceId, "process-tree-unverified");
281
281
  } else {
282
- const runner: ProcessInstanceExitV1 = { kind: "runner", ...runnerClose };
282
+ const runner: ProcessInstanceExit = { kind: "runner", ...runnerClose };
283
283
  const canonicalSession = session && sessionProjection(candidate, session);
284
284
  proof = {
285
285
  version: 1,
@@ -8,7 +8,7 @@
8
8
  import type { Message } from "@earendil-works/pi-ai";
9
9
  import type { ChildTranscriptWriter } from "../../shared/child-transcript.ts";
10
10
  import { extractTextFromContent, extractToolArgsPreview, getFinalOutput, hasEmptyTerminalAssistantResponse } from "../../shared/utils.ts";
11
- import type { EffectsProjection, RuntimeAcknowledgedChildExtensionsV1, SubagentOutputState, ToolBudgetState, Usage } from "../../shared/types.ts";
11
+ import type { EffectsProjection, RuntimeAcknowledgedChildExtensions, SubagentOutputState, ToolBudgetState, Usage } from "../../shared/types.ts";
12
12
  import {
13
13
  acceptChildWatchdogEvent,
14
14
  applyChildWatchdogMessage,
@@ -22,9 +22,10 @@ import { projectChildLifecycle, type ChildLifecycleAction, type ChildLifecycleSt
22
22
  import { formatSubagentModelVerificationError } from "../shared/model-fallback.ts";
23
23
  import { isMutatingTool, resolveCurrentPath } from "../shared/long-running-guard.ts";
24
24
  import { effectiveToolTimeoutMs, formatToolTimeoutMessage, toolTimeoutCallKey } from "../shared/tool-timeout.ts";
25
- import type { InProcessChildLaunch } from "../shared/child-launch.ts";
25
+ import { createReportedChildSessionInput, type InProcessChildLaunch } from "../shared/child-launch.ts";
26
26
  import { projectChildSessionEventForJson, type ChildSession, type ChildSessionEvent, type ChildSessionFactory } from "../shared/child-session.ts";
27
27
  import { formatSteerMessage } from "../shared/subagent-prompt-runtime.ts";
28
+ import { getReadonlySessionEvidence, requestReadonlySessionEvidence, type SettledReadonlyEvidence } from "../shared/readonly-session-evidence.ts";
28
29
  import type { SteerDeliveryStatus, SteerRequest } from "./control-channel.ts";
29
30
 
30
31
  export interface ChildEventContext {
@@ -95,6 +96,16 @@ export interface RunChildSessionInput {
95
96
  modelVerificationRegistry?: Array<{ provider: string; id: string; fullId: string }>;
96
97
  modelResponseAliases?: Record<string, string[]>;
97
98
  mutationTools?: readonly string[];
99
+ /** Internal guarded continuation handoff; never part of persisted results. */
100
+ readonlyContinuation?: { source: ChildSession; expected: SettledReadonlyEvidence; modelId: string };
101
+ collectReadonlyEvidence?: boolean;
102
+ canContinue?: () => boolean;
103
+ }
104
+
105
+ const settledChildren = new WeakMap<RunChildSessionResult, ChildSession>();
106
+ export function getSettledReadonlyChild(result: RunChildSessionResult): ChildSession | undefined {
107
+ const child = settledChildren.get(result);
108
+ return child && getReadonlySessionEvidence(child) ? child : undefined;
98
109
  }
99
110
 
100
111
  export interface RunChildSessionResult {
@@ -123,7 +134,7 @@ export interface RunChildSessionResult {
123
134
  toolBudget?: ToolBudgetState;
124
135
  toolBudgetBlocked?: boolean;
125
136
  structuredOutput?: unknown;
126
- runtimeAcknowledgedExtensions?: RuntimeAcknowledgedChildExtensionsV1;
137
+ runtimeAcknowledgedExtensions?: RuntimeAcknowledgedChildExtensions;
127
138
  abortRecoveryDiagnostic?: string;
128
139
  effects?: EffectsProjection;
129
140
  }
@@ -474,6 +485,10 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
474
485
  }
475
486
  }
476
487
  if (event.message.errorMessage) assistantError = event.message.errorMessage;
488
+ else if (hasToolCall && event.message.stopReason === "toolUse") {
489
+ // A recovered request can finish via a terminating tool, without a text stop.
490
+ assistantError = undefined;
491
+ }
477
492
  const eventUsage = event.message.usage;
478
493
  if (eventUsage) {
479
494
  usage.turns++;
@@ -533,29 +548,33 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
533
548
  : interrupted || (forcedDrainAfterFinalSuccess && !forcedDrainAfterEmptyTerminal)
534
549
  ? 0
535
550
  : finalError || promptError !== undefined ? 1 : 0;
536
- void closed.then(() => resolve(omitUndefined({
537
- exitCode,
538
- messages,
539
- usage,
540
- toolCount,
541
- durationMs: Date.now() - startedAt,
542
- model,
543
- error: stopped ? stopMessage() : timedOut ? (error ?? timeoutMessage()) : interrupted || (forcedDrainAfterFinalSuccess && !forcedDrainAfterEmptyTerminal) ? undefined : finalError,
544
- finalOutput: (timedOut || stopped) && !finalOutput.trim() ? (stopped ? stopMessage() : error ?? timeoutMessage()) : finalOutput,
545
- outputState: finalOutput.trim() ? "present" : "absent",
546
- interrupted: interrupted || undefined,
547
- timedOut: timedOut || undefined,
548
- stopped: stopped || undefined,
549
- observedMutationAttempt,
550
- structuredOutputToolInvoked,
551
- structuredOutputMessageStartIndex,
552
- watchdog: childWatchdogState,
553
- sessionFile: session?.sessionFile,
554
- currentTool,
555
- currentToolArgs,
556
- currentPath,
557
- afterCompactionSettlement: afterCompactionSettlement || undefined,
558
- })));
551
+ void closed.then(() => {
552
+ const result: RunChildSessionResult = omitUndefined({
553
+ exitCode,
554
+ messages,
555
+ usage,
556
+ toolCount,
557
+ durationMs: Date.now() - startedAt,
558
+ model,
559
+ error: stopped ? stopMessage() : timedOut ? (error ?? timeoutMessage()) : interrupted || (forcedDrainAfterFinalSuccess && !forcedDrainAfterEmptyTerminal) ? undefined : finalError,
560
+ finalOutput: (timedOut || stopped) && !finalOutput.trim() ? (stopped ? stopMessage() : error ?? timeoutMessage()) : finalOutput,
561
+ outputState: finalOutput.trim() ? "present" : "absent",
562
+ interrupted: interrupted || undefined,
563
+ timedOut: timedOut || undefined,
564
+ stopped: stopped || undefined,
565
+ observedMutationAttempt,
566
+ structuredOutputToolInvoked,
567
+ structuredOutputMessageStartIndex,
568
+ watchdog: childWatchdogState,
569
+ sessionFile: session?.sessionFile,
570
+ currentTool,
571
+ currentToolArgs,
572
+ currentPath,
573
+ afterCompactionSettlement: afterCompactionSettlement || undefined,
574
+ });
575
+ if (session && !forced && !forcedTermination && !interrupted && !timedOut && !stopped && getReadonlySessionEvidence(session)) settledChildren.set(result, session);
576
+ resolve(result);
577
+ });
559
578
  };
560
579
 
561
580
  input.registerInterrupt?.(() => {
@@ -575,17 +594,25 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
575
594
 
576
595
  void (async () => {
577
596
  try {
578
- const created = await input.factory.create({
579
- ...input.launch.session,
580
- onExtensionError: (extensionError) => {
581
- input.transcriptWriter?.writeStderrLine(`Extension error (${extensionError.extensionPath}, ${extensionError.event}): ${extensionError.error instanceof Error ? extensionError.error.message : String(extensionError.error)}`);
582
- },
583
- });
597
+ const continuation = input.readonlyContinuation;
598
+ const checkContinuation = () => {
599
+ if (!continuation) return;
600
+ if (interrupted || timedOut || stopped || input.canContinue?.() !== true
601
+ || (input.runDeadlineAt !== undefined && Date.now() >= input.runDeadlineAt)
602
+ || getReadonlySessionEvidence(continuation.source) !== continuation.expected
603
+ || continuation.source.detached || continuation.source.shutDown) throw new Error("Read-only continuation handoff vetoed");
604
+ };
605
+ checkContinuation();
606
+ const createInput = createReportedChildSessionInput(input.launch, input.transcriptWriter);
607
+ if (input.collectReadonlyEvidence || continuation) requestReadonlySessionEvidence(createInput, continuation?.expected);
608
+ const created = await input.factory.create(createInput);
584
609
  if (settled) {
585
610
  void created.dispose();
586
611
  return;
587
612
  }
588
613
  session = created;
614
+ checkContinuation();
615
+ if (continuation && (created.modelId !== continuation.modelId || input.launch.capture.completionIntentContext?.()?.model?.api !== continuation.expected.api)) throw new Error("Read-only continuation model changed");
589
616
  unsubscribe = created.subscribe(processEvent);
590
617
  input.registerWatchdogStatus?.((event) => processEvent(event as unknown as ChildSessionEvent));
591
618
  input.registerSteer?.(async (request) => {
@@ -602,6 +629,7 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
602
629
  : { state: "delivered", deliveryStatus: "delivered", message: "Pi accepted the steering input." };
603
630
  });
604
631
  if (interrupted || timedOut || stopped) abortChild();
632
+ checkContinuation();
605
633
  await created.prompt(input.prompt);
606
634
  promptSettled = true;
607
635
  settle(undefined);