pi-subagents 0.65.0 → 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 (104) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +1 -1
  3. package/agents/researcher.md +23 -13
  4. package/docs/agents.md +17 -3
  5. package/docs/configuration.md +34 -0
  6. package/docs/extension-api.md +94 -0
  7. package/docs/models.md +58 -1
  8. package/docs/observability.md +42 -2
  9. package/docs/tool-reference.md +11 -5
  10. package/docs/workflows.md +22 -7
  11. package/package.json +4 -1
  12. package/runner-server-preload.mjs +13 -0
  13. package/skills/pi-subagents/SKILL.md +2 -1
  14. package/skills/pi-subagents/references/execution-controls.md +19 -2
  15. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
  16. package/skills/pi-subagents/references/multi-lane-orchestration.md +2 -0
  17. package/src/agents/advertised-agent-prompt.ts +63 -0
  18. package/src/agents/agent-management.ts +14 -1
  19. package/src/agents/agent-serializer.ts +2 -0
  20. package/src/agents/agents.ts +8 -0
  21. package/src/api/preflight.ts +5 -1
  22. package/src/api/shared-types.ts +1 -1
  23. package/src/api/workflow-resources.ts +6 -0
  24. package/src/extension/config.ts +4 -2
  25. package/src/extension/index.ts +71 -4
  26. package/src/extension/public-execution.ts +0 -1
  27. package/src/extension/rpc.ts +4 -21
  28. package/src/extension/schemas.ts +9 -7
  29. package/src/extension/tool-description.ts +10 -5
  30. package/src/integrations/pi-web-session-liveness.ts +73 -0
  31. package/src/intercom/native-supervisor-channel.ts +102 -88
  32. package/src/intercom/supervisor-ui.ts +3 -2
  33. package/src/missions/workflow-state.ts +37 -16
  34. package/src/runs/background/active-async-capacity.ts +18 -18
  35. package/src/runs/background/async-execution.ts +8 -1
  36. package/src/runs/background/async-job-tracker.ts +35 -3
  37. package/src/runs/background/async-resume.ts +3 -1
  38. package/src/runs/background/async-retention.ts +9 -0
  39. package/src/runs/background/async-status-snapshot.ts +10 -12
  40. package/src/runs/background/async-status.ts +17 -9
  41. package/src/runs/background/auto-drain.ts +40 -29
  42. package/src/runs/background/chain-root-attachment.ts +8 -0
  43. package/src/runs/background/control-channel.ts +78 -44
  44. package/src/runs/background/notify.ts +88 -12
  45. package/src/runs/background/owned-process-tree.ts +6 -6
  46. package/src/runs/background/process-terminal.ts +23 -23
  47. package/src/runs/background/retained-nested-route-tracker.ts +96 -0
  48. package/src/runs/background/run-child-session.ts +62 -33
  49. package/src/runs/background/run-status.ts +75 -5
  50. package/src/runs/background/runner-aliases.ts +46 -8
  51. package/src/runs/background/runner-child-launch.ts +86 -0
  52. package/src/runs/background/stale-run-reconciler.ts +3 -1
  53. package/src/runs/background/subagent-runner.ts +430 -208
  54. package/src/runs/background/subagent-wait.ts +3 -0
  55. package/src/runs/background/wait-completions.ts +4 -0
  56. package/src/runs/foreground/async-steering-action.ts +19 -0
  57. package/src/runs/foreground/execution.ts +116 -26
  58. package/src/runs/foreground/foreground-history.ts +3 -1
  59. package/src/runs/foreground/prompt-audit.ts +9 -5
  60. package/src/runs/foreground/subagent-executor.ts +531 -227
  61. package/src/runs/foreground/workflow-detach-reconcile.ts +8 -5
  62. package/src/runs/foreground/workflow-foreground-steering.ts +56 -2
  63. package/src/runs/shared/acceptance.ts +16 -3
  64. package/src/runs/shared/agent-contract.ts +1 -1
  65. package/src/runs/shared/async-status-projection.ts +47 -47
  66. package/src/runs/shared/child-hooks.ts +151 -2
  67. package/src/runs/shared/child-launch.ts +18 -13
  68. package/src/runs/shared/child-session.ts +55 -24
  69. package/src/runs/shared/child-tool-plan.ts +2 -2
  70. package/src/runs/shared/completion-evidence.ts +2 -2
  71. package/src/runs/shared/completion-guard.ts +1 -0
  72. package/src/runs/shared/host-step-status.ts +11 -11
  73. package/src/runs/shared/llm-intent-arbiter.ts +30 -20
  74. package/src/runs/shared/model-exclusions.ts +2 -1
  75. package/src/runs/shared/model-fallback.ts +41 -8
  76. package/src/runs/shared/nested-events.ts +8 -8
  77. package/src/runs/shared/orca-progress-tabs.ts +6 -0
  78. package/src/runs/shared/parallel-handoff.ts +57 -12
  79. package/src/runs/shared/parallel-utils.ts +3 -2
  80. package/src/runs/shared/readonly-drain-observation.ts +42 -0
  81. package/src/runs/shared/readonly-model-continuation.ts +69 -0
  82. package/src/runs/shared/readonly-session-evidence.ts +307 -0
  83. package/src/runs/shared/run-fanout-budget.ts +8 -8
  84. package/src/runs/shared/runtime-acknowledged-extensions.ts +3 -3
  85. package/src/runs/shared/subagent-prompt-runtime.ts +13 -3
  86. package/src/runs/shared/worktree-cleanup-plan.ts +6 -3
  87. package/src/runs/shared/worktree-setup-command.ts +190 -0
  88. package/src/runs/shared/worktree.ts +403 -210
  89. package/src/shared/model-response-aliases.ts +13 -0
  90. package/src/shared/types.ts +89 -60
  91. package/src/shared/utils.ts +10 -2
  92. package/src/shared/watch-strategy.ts +2 -0
  93. package/src/shared/workflow-child-permit.ts +18 -13
  94. package/src/tui/fleet-status.ts +1 -1
  95. package/src/tui/fleet.ts +11 -5
  96. package/src/tui/render.ts +44 -15
  97. package/src/workflows/chat-progress.ts +3 -3
  98. package/src/workflows/scripted-workflow.ts +70 -16
  99. package/src/workflows/workflow-checklist.ts +15 -18
  100. package/src/workflows/workflow-child-summary.ts +57 -8
  101. package/src/workflows/workflow-preflight.ts +19 -19
  102. package/src/workflows/workflow-receipt.ts +3 -3
  103. package/src/workflows/workflow-resources.ts +96 -21
  104. package/src/workflows/workflow-settlement.ts +3 -0
@@ -275,28 +275,36 @@ function parseSteerRequest(raw: unknown): SteerRequest | undefined {
275
275
  };
276
276
  }
277
277
 
278
- export function consumeSteerRequestsFromDir(dir: string, fsImpl: Pick<typeof fs, "existsSync" | "rmSync" | "readdirSync" | "readFileSync"> = fs): SteerRequest[] {
279
- if (!fsImpl.existsSync(dir)) return [];
278
+ export function consumeSteerRequestsFromDir(dir: string, fsImpl: Pick<typeof fs, "existsSync" | "rmSync" | "readdirSync" | "readFileSync"> = fs, onError: (error: unknown) => void = () => {}): SteerRequest[] {
280
279
  let entries: string[];
281
280
  try {
282
281
  entries = fsImpl.readdirSync(dir).filter((name) => name.endsWith(".json")).sort();
283
- } catch {
282
+ } catch (error) {
284
283
  // Leave requests in place so the periodic poll can retry the scan.
284
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") onError(error);
285
285
  return [];
286
286
  }
287
287
  const requests: SteerRequest[] = [];
288
288
  for (const entry of entries) {
289
289
  const requestPath = path.join(dir, entry);
290
290
  let parsed: SteerRequest | undefined;
291
+ let text: string;
292
+ try {
293
+ text = fsImpl.readFileSync(requestPath, "utf-8");
294
+ } catch (error) {
295
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") onError(error);
296
+ continue;
297
+ }
291
298
  try {
292
- parsed = parseSteerRequest(JSON.parse(fsImpl.readFileSync(requestPath, "utf-8")));
299
+ parsed = parseSteerRequest(JSON.parse(text));
293
300
  } catch {
294
301
  parsed = undefined;
295
302
  }
296
303
  try {
297
304
  fsImpl.rmSync(requestPath, { recursive: true });
298
- } catch {
305
+ } catch (error) {
299
306
  // Already removed by a concurrent check — do not execute it twice.
307
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") onError(error);
300
308
  continue;
301
309
  }
302
310
  if (parsed) requests.push(parsed);
@@ -304,8 +312,8 @@ export function consumeSteerRequestsFromDir(dir: string, fsImpl: Pick<typeof fs,
304
312
  return requests.sort((left, right) => left.ts - right.ts || left.id.localeCompare(right.id));
305
313
  }
306
314
 
307
- export function consumeSteerRequests(asyncDir: string, fsImpl: Pick<typeof fs, "existsSync" | "rmSync" | "readdirSync" | "readFileSync"> = fs): SteerRequest[] {
308
- return consumeSteerRequestsFromDir(steerRequestsDir(asyncDir), fsImpl);
315
+ export function consumeSteerRequests(asyncDir: string, fsImpl: Pick<typeof fs, "existsSync" | "rmSync" | "readdirSync" | "readFileSync"> = fs, onError?: (error: unknown) => void): SteerRequest[] {
316
+ return consumeSteerRequestsFromDir(steerRequestsDir(asyncDir), fsImpl, onError);
309
317
  }
310
318
 
311
319
  export function queueRevivalBrief(asyncDir: string, request: SteerRequest): string {
@@ -387,17 +395,26 @@ function parseStopRequest(raw: unknown): StopRequest | undefined {
387
395
  function consumeStopRequestFile(
388
396
  requestPath: string,
389
397
  fsImpl: Pick<typeof fs, "rmSync" | "readFileSync">,
398
+ onError?: (error: unknown) => void,
390
399
  ): StopRequest | undefined {
400
+ let text: string;
401
+ try {
402
+ text = fsImpl.readFileSync(requestPath, "utf-8");
403
+ } catch (error) {
404
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) onError?.(error);
405
+ return undefined;
406
+ }
391
407
  let request: StopRequest | undefined;
392
408
  try {
393
- request = parseStopRequest(JSON.parse(fsImpl.readFileSync(requestPath, "utf-8")));
409
+ request = parseStopRequest(JSON.parse(text));
394
410
  } catch {
395
411
  request = undefined;
396
412
  }
397
413
  try {
398
414
  fsImpl.rmSync(requestPath, { force: true, recursive: true });
399
- } catch {
400
- // Already removed by a concurrent check — do not execute it twice.
415
+ } catch (error) {
416
+ // Execute only after successful removal.
417
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) onError?.(error);
401
418
  return undefined;
402
419
  }
403
420
  return request;
@@ -406,6 +423,7 @@ function consumeStopRequestFile(
406
423
  export function consumeStopRequestPayloads(
407
424
  asyncDir: string,
408
425
  fsImpl: Pick<typeof fs, "existsSync" | "rmSync" | "readdirSync" | "readFileSync"> = fs,
426
+ onError?: (error: unknown) => void,
409
427
  ): StopRequest[] {
410
428
  const dir = stopRequestsDir(asyncDir);
411
429
  const requests: StopRequest[] = [];
@@ -413,18 +431,19 @@ export function consumeStopRequestPayloads(
413
431
  let entries: string[];
414
432
  try {
415
433
  entries = fsImpl.readdirSync(dir).filter((name) => name.endsWith(".json")).sort();
416
- } catch {
434
+ } catch (error) {
435
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) onError?.(error);
417
436
  entries = [];
418
437
  }
419
438
  for (const entry of entries) {
420
- const request = consumeStopRequestFile(path.join(dir, entry), fsImpl);
439
+ const request = consumeStopRequestFile(path.join(dir, entry), fsImpl, onError);
421
440
  if (request) requests.push(request);
422
441
  }
423
442
  }
424
443
 
425
444
  const legacyPath = stopRequestPath(asyncDir);
426
445
  if (fsImpl.existsSync(legacyPath)) {
427
- const request = consumeStopRequestFile(legacyPath, fsImpl);
446
+ const request = consumeStopRequestFile(legacyPath, fsImpl, onError);
428
447
  if (request) requests.push(request);
429
448
  }
430
449
  return requests.sort((left, right) => (left.ts ?? 0) - (right.ts ?? 0));
@@ -470,19 +489,21 @@ export function deliverStopRequest(input: {
470
489
  requestAsyncStop(input.asyncDir, { ...(input.source ? { source: input.source } : {}), ...(input.targetIndex !== undefined ? { targetIndex: input.targetIndex } : {}), ...(input.childId ? { childId: input.childId } : {}) }, { now: input.now });
471
490
  }
472
491
 
492
+
473
493
  /**
474
- * Runner side: watch the control inbox and route interrupt requests into
475
- * `onInterrupt`. Uses `fs.watch` when available and starts interval polling
494
+ * Active owner: watch and consume only kinds with installed handlers.
495
+ * Uses `fs.watch` when available and starts interval polling
476
496
  * only when native watching is unavailable or fails. Fires once per distinct
477
497
  * request. Returns a disposer.
478
498
  */
479
499
  export function watchAsyncControlInbox(
480
500
  asyncDir: string,
481
501
  opts: {
482
- onInterrupt: () => void;
502
+ onInterrupt?: () => void;
483
503
  onTimeout?: () => void;
484
504
  onStop?: (request: StopRequest) => void;
485
505
  onSteer?: (request: SteerRequest) => void;
506
+ onError?: (error: unknown, phase: "install" | "scan" | "callback", request?: SteerRequest) => void;
486
507
  pollIntervalMs?: number;
487
508
  safetyPollIntervalMs?: number;
488
509
  platform?: NodeJS.Platform;
@@ -493,22 +514,44 @@ export function watchAsyncControlInbox(
493
514
  const fsImpl = opts.fs ?? fs;
494
515
  const timers = opts.timers ?? { setInterval, clearInterval };
495
516
  const dir = controlInboxDir(asyncDir);
517
+ const report = (error: unknown, phase: "install" | "scan" | "callback", request?: SteerRequest): void => {
518
+ try {
519
+ if (opts.onError) opts.onError(error, phase, request);
520
+ else console.error(`Control inbox ${phase} failed:`, error);
521
+ } catch (reportError) {
522
+ console.error("Control inbox error reporter failed:", reportError);
523
+ }
524
+ };
525
+ const dirs = [
526
+ ...(opts.onInterrupt || opts.onTimeout || opts.onStop ? [dir] : []),
527
+ ...(opts.onStop ? [stopRequestsDir(asyncDir)] : []),
528
+ ...(opts.onSteer ? [steerRequestsDir(asyncDir)] : []),
529
+ ];
530
+ if (dirs.length === 0) return () => {};
496
531
  try {
497
- fsImpl.mkdirSync(dir, { recursive: true });
498
- } catch {
499
- // Best effort — the poll/watch below tolerates a missing dir.
532
+ for (const target of dirs) fsImpl.mkdirSync(target, { recursive: true });
533
+ } catch (error) {
534
+ report(error, "install");
500
535
  }
501
536
 
502
537
  let disposed = false;
503
538
  const check = (): void => {
504
539
  if (disposed) return;
505
540
  try {
506
- for (const stopRequest of consumeStopRequestPayloads(asyncDir, fsImpl)) opts.onStop?.(stopRequest);
507
- if (consumeTimeoutRequest(asyncDir, fsImpl)) opts.onTimeout?.();
508
- if (consumeInterruptRequest(asyncDir, fsImpl)) opts.onInterrupt();
509
- for (const request of consumeSteerRequests(asyncDir, fsImpl)) opts.onSteer?.(request);
510
- } catch {
511
- // Never let inbox errors crash the runner.
541
+ if (opts.onStop) for (const request of consumeStopRequestPayloads(asyncDir, fsImpl, (error) => report(error, "scan"))) {
542
+ try { opts.onStop(request); } catch (error) { report(error, "callback"); }
543
+ }
544
+ if (opts.onTimeout && consumeTimeoutRequest(asyncDir, fsImpl)) {
545
+ try { opts.onTimeout(); } catch (error) { report(error, "callback"); }
546
+ }
547
+ if (opts.onInterrupt && consumeInterruptRequest(asyncDir, fsImpl)) {
548
+ try { opts.onInterrupt(); } catch (error) { report(error, "callback"); }
549
+ }
550
+ if (opts.onSteer) for (const request of consumeSteerRequestsFromDir(steerRequestsDir(asyncDir), fsImpl, (error) => report(error, "scan"))) {
551
+ try { opts.onSteer(request); } catch (error) { report(error, "callback", request); }
552
+ }
553
+ } catch (error) {
554
+ report(error, "scan");
512
555
  }
513
556
  };
514
557
 
@@ -516,40 +559,31 @@ export function watchAsyncControlInbox(
516
559
  check();
517
560
 
518
561
  const watchers: fs.FSWatcher[] = [];
519
- const watchedDirs = new Set<string>();
520
562
  let interval: ReturnType<typeof setInterval> | undefined;
521
563
  let safetyInterval: ReturnType<typeof setInterval> | undefined;
522
564
  const startPolling = (): void => {
523
565
  if (interval || disposed) return;
566
+ if (safetyInterval) { timers.clearInterval(safetyInterval); safetyInterval = undefined; }
524
567
  interval = timers.setInterval(check, opts.pollIntervalMs ?? POLL_INTERVAL_MS);
525
568
  interval.unref?.();
526
569
  };
527
- const startSafetyPolling = (): void => {
528
- if (safetyInterval || disposed) return;
529
- safetyInterval = timers.setInterval(check, opts.safetyPollIntervalMs ?? CONTROL_SAFETY_POLL_INTERVAL_MS);
530
- safetyInterval.unref?.();
531
- };
532
- const watchDir = (target: string, create = false): void => {
533
- if (disposed || watchedDirs.has(target)) return;
534
- if (create) fsImpl.mkdirSync(target, { recursive: true });
535
- const watcher = fsImpl.watch(resolveWatchPath(target, fsImpl.realpathSync.native), () => check());
536
- watcher.on?.("error", startPolling);
537
- watchers.push(watcher);
538
- watchedDirs.add(target);
539
- };
540
570
  try {
541
571
  if (shouldUseNativeFsWatch("runner-control-inbox", opts.platform)) {
542
- watchDir(dir);
543
- watchDir(stopRequestsDir(asyncDir), true);
544
- watchDir(steerRequestsDir(asyncDir), true);
545
- startSafetyPolling();
572
+ for (const target of dirs) {
573
+ const watcher = fsImpl.watch(resolveWatchPath(target, fsImpl.realpathSync.native), check);
574
+ watcher.on?.("error", startPolling);
575
+ watchers.push(watcher);
576
+ }
577
+ if (!interval) {
578
+ safetyInterval = timers.setInterval(check, opts.safetyPollIntervalMs ?? CONTROL_SAFETY_POLL_INTERVAL_MS);
579
+ safetyInterval.unref?.();
580
+ }
546
581
  } else {
547
582
  startPolling();
548
583
  }
549
584
  } catch {
550
585
  startPolling();
551
586
  }
552
-
553
587
  return () => {
554
588
  if (disposed) return;
555
589
  disposed = true;
@@ -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";
@@ -123,6 +125,7 @@ export interface RegisterSubagentNotifyOptions {
123
125
 
124
126
  export interface CompletionNotifier {
125
127
  deliver(result: CompletionNotification): Promise<boolean>;
128
+ hasPendingDelivery(): boolean;
126
129
  dispose(): void;
127
130
  }
128
131
 
@@ -188,11 +191,17 @@ function structuredOutputText(value: unknown): string | undefined {
188
191
  }
189
192
  }
190
193
 
194
+ function isDegenerateOutput(text: string): boolean {
195
+ return !text.trim() || text.trim() === "</think>";
196
+ }
197
+
191
198
  function childInlinePreview(child: CompletionChild): { preview?: string; truncated?: boolean; unavailableReason?: string } {
192
199
  const output = typeof child.output === "string" ? child.output : "";
193
200
  const outputReference = childSavedOutputPath(child);
194
201
  const referenceOnly = Boolean(outputReference && output.trim().startsWith("Output saved to:"));
195
- 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;
196
205
  if (!raw?.trim()) {
197
206
  return { unavailableReason: referenceOnly ? "saved output is file-only" : "no safe inline output" };
198
207
  }
@@ -282,6 +291,7 @@ export function formatSingleCompletion(details: SubagentNotifyDetails): string {
282
291
  : undefined;
283
292
  return [
284
293
  `${taskKind} ${details.status}: **${details.agent}**${details.taskInfo ?? ""}`,
294
+ details.workflowReceiptPath ? `Workflow receipt: ${details.workflowReceiptPath}` : undefined,
285
295
  "",
286
296
  scheduleLine,
287
297
  scheduleLine ? "" : undefined,
@@ -302,7 +312,11 @@ export function parseSubagentNotifyContent(content: string): SubagentNotifyDetai
302
312
  const lines = content.split("\n");
303
313
  const match = (lines[0] ?? "").match(/^(Background task|Detached foreground task) (completed|failed|paused|stopped): \*\*(.+?)\*\*(?:\s+(\([^)]*\)))?$/);
304
314
  if (!match) return undefined;
305
- 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);
306
320
  // Restore the schedule origin so a re-rendered notice keeps its attribution and
307
321
  // does not fold the line into the result preview.
308
322
  const scheduleMatch = (body[0] ?? "").match(/^Scheduled run from \*\*(.+?)\*\* \(schedule (.+?)\)\.$/);
@@ -358,6 +372,7 @@ export function parseSubagentNotifyContent(content: string): SubagentNotifyDetai
358
372
  ...(match[1] === "Detached foreground task" ? { source: "foreground" as const } : {}),
359
373
  ...(match[4] ? { taskInfo: match[4] } : {}),
360
374
  ...(parsedScheduleOrigin ? { scheduleOrigin: parsedScheduleOrigin } : {}),
375
+ ...(workflowReceiptPath ? { workflowReceiptPath } : {}),
361
376
  resultPreview,
362
377
  ...(handoffPath ? { handoffPath } : {}),
363
378
  ...(workflowRunId ? { workflowRunId } : {}),
@@ -376,6 +391,7 @@ export function formatGroupedCompletion(details: SubagentNotifyDetails[]): strin
376
391
  if (!detail) continue;
377
392
  const sessionLine = formatSessionLine(detail);
378
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}`);
379
395
  blocks.push(formatResultPreview(detail));
380
396
  blocks.push(...formatWatchdogBlockerLines(detail));
381
397
  if (detail.handoffPath) blocks.push(`Parallel handoff: ${detail.handoffPath}`);
@@ -386,7 +402,31 @@ export function formatGroupedCompletion(details: SubagentNotifyDetails[]): strin
386
402
  return blocks.join("\n").trimEnd();
387
403
  }
388
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
+
389
428
  interface PendingCompletion {
429
+ trace?: TraceIdentity;
390
430
  key: string;
391
431
  details: SubagentNotifyDetails;
392
432
  sessionId: string;
@@ -447,6 +487,10 @@ export function buildCompletionDetails(result: CompletionNotification): Subagent
447
487
  ? result.parallelHandoff as { path?: unknown }
448
488
  : undefined;
449
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;
450
494
  const rawRunId = typeof result.runId === "string" ? result.runId : typeof result.id === "string" ? result.id : undefined;
451
495
  const workflowRunId = (result.mode === "workflow" || agent === "workflow") && rawRunId ? rawRunId : undefined;
452
496
  const directChild = !workflowRunId && result.results?.length === 1 ? result.results[0]! : undefined;
@@ -455,10 +499,17 @@ export function buildCompletionDetails(result: CompletionNotification): Subagent
455
499
  : undefined;
456
500
  const directSummary = summary.trim();
457
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;
458
509
  const directNoOutputSummary = directChild && (!directSummary
459
510
  || directSummary === "(no output)"
460
511
  || (directAgent && directSummary === `${directAgent}:\n(no output)`));
461
- const resultPreview = directStructuredPreview && directNoOutputSummary
512
+ const resultPreview = directStructuredPreview && (directNoOutputSummary || directDegenerateSummary)
462
513
  ? `Structured output:\n${directStructuredPreview}`
463
514
  : summary;
464
515
  const childRuns = result.results?.flatMap((child) => {
@@ -516,6 +567,7 @@ export function buildCompletionDetails(result: CompletionNotification): Subagent
516
567
  agent,
517
568
  status,
518
569
  ...(scheduleOrigin ? { scheduleOrigin } : {}),
570
+ ...(workflowReceiptPath ? { workflowReceiptPath } : {}),
519
571
  ...(result.source ? { source: result.source } : {}),
520
572
  ...(taskInfo ? { taskInfo } : {}),
521
573
  resultPreview,
@@ -547,8 +599,9 @@ export default function registerSubagentNotify(
547
599
  && typeof completionOwnerId === "string"
548
600
  && completionOwnerId === state.completionOwnerId);
549
601
 
550
- const settle = (items: PendingCompletion[], accepted: boolean) => {
602
+ const settle = (items: PendingCompletion[], accepted: boolean, reason?: NotificationReason) => {
551
603
  for (const item of items) {
604
+ if (reason) traceNotification(reason, item.trace);
552
605
  pending.delete(item.key);
553
606
  if (accepted) markSeenWithTtl(seen, item.key, now(), ttlMs);
554
607
  item.resolve(accepted);
@@ -561,10 +614,12 @@ export default function registerSubagentNotify(
561
614
  const owned = item.details.source === "foreground"
562
615
  ? item.sessionId === state.currentSessionId
563
616
  : ownsResult(item.sessionId, item.completionOwnerId);
617
+ if (!owned) traceNotification(item.details.source === "foreground" ? "emit_foreground_session_mismatch" : "emit_not_owned", item.trace);
564
618
  (owned ? accepted : rejected).push(item);
565
619
  }
566
620
  settle(rejected, false);
567
- settle(accepted, sendCompletion(pi, accepted));
621
+ const sent = sendCompletion(pi, accepted);
622
+ settle(accepted, sent, sent ? "send_accepted" : "send_failed");
568
623
  };
569
624
  const getBatcher = (result: CompletionNotification) => {
570
625
  const key = completionBatchKey(result);
@@ -582,17 +637,35 @@ export default function registerSubagentNotify(
582
637
  };
583
638
 
584
639
  const deliver = (result: CompletionNotification): Promise<boolean> => {
585
- 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
+ }
586
644
  if (result.source === "foreground") {
587
- if (result.sessionId !== state.currentSessionId) return Promise.resolve(false);
588
- } else if (!ownsResult(result.sessionId, result.completionOwnerId)) return Promise.resolve(false);
589
- 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
+ }
590
657
  const key = buildCompletionKey(result, "notify");
591
658
  const seenAt = seen.get(key);
592
- 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
+ }
593
663
  if (seenAt !== undefined) seen.delete(key);
594
664
  const inFlight = pending.get(key);
595
- if (inFlight) return inFlight;
665
+ if (inFlight) {
666
+ traceNotification("deduped_pending", result);
667
+ return inFlight;
668
+ }
596
669
  const details = buildCompletionDetails(result);
597
670
  let resolve!: (accepted: boolean) => void;
598
671
  const completion = new Promise<boolean>((settleCompletion) => { resolve = settleCompletion; });
@@ -605,6 +678,7 @@ export default function registerSubagentNotify(
605
678
  triggerTurn: result.triggerTurn !== false,
606
679
  resolve,
607
680
  };
681
+ if (notificationDebug.enabled) item.trace = traceIdentity(result);
608
682
  if (details.source === "foreground") {
609
683
  emit([item]);
610
684
  return completion;
@@ -615,6 +689,7 @@ export default function registerSubagentNotify(
615
689
  emit([item]);
616
690
  return completion;
617
691
  }
692
+ if (batchConfig.enabled) traceNotification("batch_deferred", item.trace);
618
693
  batcher.push(item);
619
694
  return completion;
620
695
  };
@@ -628,10 +703,11 @@ export default function registerSubagentNotify(
628
703
 
629
704
  return {
630
705
  deliver,
706
+ hasPendingDelivery: () => pending.size > 0,
631
707
  dispose() {
632
708
  if (disposed) return;
633
709
  disposed = true;
634
- for (const batcher of batchers.values()) settle(batcher.dispose(), false);
710
+ for (const batcher of batchers.values()) settle(batcher.dispose(), false, "dispose_pending");
635
711
  batchers.clear();
636
712
  for (const unsubscribe of [unsubscribeAsync, unsubscribeForeground]) {
637
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) {