pi-subagents 0.65.1 → 0.67.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 (148) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/README.md +5 -4
  3. package/agents/evidence-auditor.md +34 -0
  4. package/agents/researcher.md +23 -13
  5. package/agents/reviewer.md +3 -2
  6. package/docs/agents.md +20 -3
  7. package/docs/configuration.md +25 -5
  8. package/docs/extension-api.md +124 -18
  9. package/docs/missions.md +8 -0
  10. package/docs/models.md +59 -2
  11. package/docs/observability.md +46 -6
  12. package/docs/standalone-background.md +49 -0
  13. package/docs/tool-reference.md +20 -10
  14. package/docs/watchdog.md +35 -4
  15. package/docs/workflows.md +40 -19
  16. package/inspector-runner.mjs +2 -2
  17. package/package.json +2 -1
  18. package/prompts/parallel-review.md +1 -1
  19. package/{runner-server-preload.mjs → runner-peer-preload.mjs} +8 -3
  20. package/skills/pi-subagents/SKILL.md +14 -0
  21. package/skills/pi-subagents/references/execution-controls.md +20 -5
  22. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
  23. package/skills/pi-subagents/references/prompting-and-roles.md +2 -2
  24. package/src/agents/advertised-agent-prompt.ts +94 -0
  25. package/src/agents/agent-management.ts +14 -1
  26. package/src/agents/agent-serializer.ts +2 -0
  27. package/src/agents/agents.ts +14 -0
  28. package/src/agents/builtin-names.ts +1 -0
  29. package/src/api/delegation.ts +4 -0
  30. package/src/api/preflight.ts +76 -45
  31. package/src/api/shared-types.ts +3 -1
  32. package/src/api/workflow-resources.ts +6 -0
  33. package/src/extension/fanout-child.ts +63 -4
  34. package/src/extension/index.ts +58 -8
  35. package/src/extension/public-execution.ts +4 -3
  36. package/src/extension/rpc.ts +8 -21
  37. package/src/extension/schemas.ts +71 -80
  38. package/src/extension/tool-description.ts +29 -81
  39. package/src/inspectors/actions.ts +148 -0
  40. package/src/inspectors/ghostty/actions.ts +74 -0
  41. package/src/inspectors/ghostty/plugin.ts +17 -0
  42. package/src/inspectors/herdr/actions.ts +99 -179
  43. package/src/inspectors/herdr/plugin.ts +20 -0
  44. package/src/inspectors/herdr/project-panes.ts +1 -1
  45. package/src/inspectors/{herdr/inspector-runner.ts → inspector-runner.ts} +12 -12
  46. package/src/inspectors/plugins.ts +8 -0
  47. package/src/inspectors/{herdr/session-roots-codec.ts → session-roots-codec.ts} +3 -14
  48. package/src/inspectors/types.ts +51 -0
  49. package/src/intercom/intercom-bridge.ts +50 -8
  50. package/src/intercom/native-supervisor-channel.ts +104 -67
  51. package/src/runs/background/active-async-capacity.ts +22 -18
  52. package/src/runs/background/async-execution.ts +45 -56
  53. package/src/runs/background/async-job-tracker.ts +35 -3
  54. package/src/runs/background/async-resume.ts +5 -9
  55. package/src/runs/background/async-status-snapshot.ts +10 -12
  56. package/src/runs/background/async-status.ts +17 -9
  57. package/src/runs/background/auto-drain.ts +44 -30
  58. package/src/runs/background/binary-bootstrap.ts +33 -0
  59. package/src/runs/background/chain-root-attachment.ts +8 -0
  60. package/src/runs/background/control-channel.ts +78 -44
  61. package/src/runs/background/fleet-view.ts +30 -2
  62. package/src/runs/background/notify.ts +117 -13
  63. package/src/runs/background/owned-process-tree.ts +35 -8
  64. package/src/runs/background/process-terminal.ts +23 -23
  65. package/src/runs/background/run-child-session.ts +121 -36
  66. package/src/runs/background/run-status.ts +78 -5
  67. package/src/runs/background/runner-aliases.ts +28 -9
  68. package/src/runs/background/runner-child-launch.ts +88 -0
  69. package/src/runs/background/runner-child-sessions.ts +5 -4
  70. package/src/runs/background/scheduled-runs.ts +40 -13
  71. package/src/runs/background/stale-run-reconciler.ts +3 -1
  72. package/src/runs/background/steering.ts +20 -2
  73. package/src/runs/background/subagent-runner.ts +458 -239
  74. package/src/runs/background/subagent-wait.ts +54 -8
  75. package/src/runs/background/wait-completions.ts +4 -0
  76. package/src/runs/background/wait-tool.ts +1 -1
  77. package/src/runs/foreground/async-steering-action.ts +37 -7
  78. package/src/runs/foreground/execution.ts +145 -56
  79. package/src/runs/foreground/prompt-audit.ts +3 -1
  80. package/src/runs/foreground/subagent-executor.ts +584 -297
  81. package/src/runs/foreground/workflow-detach-reconcile.ts +10 -5
  82. package/src/runs/foreground/workflow-foreground-steering.ts +57 -2
  83. package/src/runs/shared/acceptance.ts +7 -4
  84. package/src/runs/shared/agent-contract.ts +1 -1
  85. package/src/runs/shared/async-status-projection.ts +51 -47
  86. package/src/runs/shared/capability-ceiling.ts +2 -0
  87. package/src/runs/shared/child-hooks.ts +167 -3
  88. package/src/runs/shared/child-launch.ts +28 -13
  89. package/src/runs/shared/child-lifecycle.ts +6 -3
  90. package/src/runs/shared/child-runtime-config.ts +3 -1
  91. package/src/runs/shared/child-session.ts +75 -8
  92. package/src/runs/shared/child-tool-plan.ts +124 -5
  93. package/src/runs/shared/completion-evidence.ts +2 -2
  94. package/src/runs/shared/completion-guard.ts +6 -3
  95. package/src/runs/shared/effective-system-prompt.ts +33 -0
  96. package/src/runs/shared/external-cli-runner.ts +9 -7
  97. package/src/runs/shared/host-step-status.ts +11 -11
  98. package/src/runs/shared/llm-intent-arbiter.ts +21 -11
  99. package/src/runs/shared/model-fallback.ts +12 -6
  100. package/src/runs/shared/nested-events.ts +5 -5
  101. package/src/runs/shared/orca-progress-tabs.ts +7 -1
  102. package/src/runs/shared/parallel-handoff.ts +57 -12
  103. package/src/runs/shared/parallel-utils.ts +2 -2
  104. package/src/runs/shared/pi-spawn.ts +10 -0
  105. package/src/runs/shared/readonly-drain-observation.ts +42 -0
  106. package/src/runs/shared/readonly-model-continuation.ts +69 -0
  107. package/src/runs/shared/readonly-session-evidence.ts +307 -0
  108. package/src/runs/shared/run-fanout-budget.ts +8 -8
  109. package/src/runs/shared/runtime-acknowledged-extensions.ts +3 -3
  110. package/src/runs/shared/subagent-prompt-runtime.ts +20 -4
  111. package/src/runs/shared/task-intent.ts +46 -13
  112. package/src/runs/shared/workflow-async-child-guidance.ts +18 -0
  113. package/src/runs/shared/worktree-setup-command.ts +190 -0
  114. package/src/runs/shared/worktree.ts +366 -208
  115. package/src/shared/fork-context.ts +15 -72
  116. package/src/shared/launch-contract.ts +65 -2
  117. package/src/shared/opencode-session-headers.ts +30 -0
  118. package/src/shared/types.ts +85 -61
  119. package/src/shared/utils.ts +7 -2
  120. package/src/shared/workflow-child-permit.ts +18 -13
  121. package/src/slash/delegation-adapters.ts +3 -1
  122. package/src/slash/delegation-request.ts +14 -0
  123. package/src/slash/slash-commands.ts +2 -1
  124. package/src/slash/subagents-admin.ts +11 -4
  125. package/src/tui/fleet-status.ts +164 -19
  126. package/src/tui/fleet.ts +27 -19
  127. package/src/tui/render.ts +172 -33
  128. package/src/watchdog/child-status.ts +8 -0
  129. package/src/watchdog/model-selection.ts +20 -0
  130. package/src/watchdog/permission-arbiter.ts +3 -1
  131. package/src/watchdog/register-child.ts +1 -0
  132. package/src/watchdog/register-main.ts +31 -27
  133. package/src/watchdog/review.ts +132 -67
  134. package/src/watchdog/runtime.ts +82 -20
  135. package/src/watchdog/scope.ts +1 -1
  136. package/src/watchdog/settings.ts +9 -3
  137. package/src/watchdog/tool-actions.ts +13 -12
  138. package/src/watchdog/turn-delta.ts +23 -0
  139. package/src/watchdog/types.ts +4 -0
  140. package/src/workflows/chat-progress.ts +3 -3
  141. package/src/workflows/scripted-workflow.ts +275 -17
  142. package/src/workflows/workflow-checklist.ts +13 -17
  143. package/src/workflows/workflow-child-summary.ts +57 -8
  144. package/src/workflows/workflow-preflight.ts +19 -19
  145. package/src/workflows/workflow-receipt.ts +3 -3
  146. package/src/workflows/workflow-resources.ts +96 -21
  147. package/src/workflows/workflow-settlement.ts +3 -0
  148. /package/src/inspectors/{herdr/shell-command.ts → shell-command.ts} +0 -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";
@@ -52,6 +54,16 @@ export interface SubagentNotifyDetails {
52
54
  watchdogBlockers?: SubagentNotifyWatchdogBlocker[];
53
55
  }
54
56
 
57
+ export interface IncrementalChildCompletion {
58
+ workflowRunId: string;
59
+ childKey: string;
60
+ childRunId?: string;
61
+ outcome: "completed" | "failed" | "paused" | "stopped";
62
+ outputReference?: string;
63
+ error?: string;
64
+ workflowRunning: boolean;
65
+ }
66
+
55
67
  export interface CompletionNotification {
56
68
  [key: string]: unknown;
57
69
  id?: string | null;
@@ -189,11 +201,17 @@ function structuredOutputText(value: unknown): string | undefined {
189
201
  }
190
202
  }
191
203
 
204
+ function isDegenerateOutput(text: string): boolean {
205
+ return !text.trim() || text.trim() === "</think>";
206
+ }
207
+
192
208
  function childInlinePreview(child: CompletionChild): { preview?: string; truncated?: boolean; unavailableReason?: string } {
193
209
  const output = typeof child.output === "string" ? child.output : "";
194
210
  const outputReference = childSavedOutputPath(child);
195
211
  const referenceOnly = Boolean(outputReference && output.trim().startsWith("Output saved to:"));
196
- const raw = child.outputState === "absent" || referenceOnly ? structuredOutputText(child.structuredOutput) : output || structuredOutputText(child.structuredOutput);
212
+ const raw = child.outputState === "absent" || referenceOnly
213
+ ? structuredOutputText(child.structuredOutput)
214
+ : isDegenerateOutput(output) ? structuredOutputText(child.structuredOutput) ?? output : output;
197
215
  if (!raw?.trim()) {
198
216
  return { unavailableReason: referenceOnly ? "saved output is file-only" : "no safe inline output" };
199
217
  }
@@ -283,6 +301,7 @@ export function formatSingleCompletion(details: SubagentNotifyDetails): string {
283
301
  : undefined;
284
302
  return [
285
303
  `${taskKind} ${details.status}: **${details.agent}**${details.taskInfo ?? ""}`,
304
+ details.workflowReceiptPath ? `Workflow receipt: ${details.workflowReceiptPath}` : undefined,
286
305
  "",
287
306
  scheduleLine,
288
307
  scheduleLine ? "" : undefined,
@@ -299,11 +318,35 @@ export function formatSingleCompletion(details: SubagentNotifyDetails): string {
299
318
  .join("\n");
300
319
  }
301
320
 
321
+ export function scheduledCompletionTriggersTurn(origin: ScheduleOrigin | undefined, outcome: string): boolean {
322
+ return !(origin?.quiet === true && outcome === "completed");
323
+ }
324
+
325
+ export function formatIncrementalChildCompletion(child: IncrementalChildCompletion): string {
326
+ const statusText = child.outcome === "completed" ? "completed"
327
+ : child.outcome === "failed" ? "failed"
328
+ : child.outcome === "paused" ? "paused (needs attention)"
329
+ : "stopped";
330
+ const workflowStatus = child.workflowRunning ? "workflow still running" : "workflow finished";
331
+ return [
332
+ `Workflow child ${statusText}: **${child.childKey}**`,
333
+ `Workflow run: ${child.workflowRunId}`,
334
+ ...(child.childRunId ? [`Child run: ${child.childRunId}`] : []),
335
+ ...(child.outputReference ? [`Output: ${child.outputReference}`] : []),
336
+ ...(child.error ? [`Error: ${child.error}`] : []),
337
+ `Status: ${workflowStatus}`,
338
+ ].join("\n");
339
+ }
340
+
302
341
  export function parseSubagentNotifyContent(content: string): SubagentNotifyDetails | undefined {
303
342
  const lines = content.split("\n");
304
343
  const match = (lines[0] ?? "").match(/^(Background task|Detached foreground task) (completed|failed|paused|stopped): \*\*(.+?)\*\*(?:\s+(\([^)]*\)))?$/);
305
344
  if (!match) return undefined;
306
- let body = lines.slice(2);
345
+ // Only the header slot before the blank separator can carry a receipt.
346
+ // Identical lines anywhere in model output remain preview text.
347
+ const receiptHeader = lines[1]?.startsWith("Workflow receipt: ") && lines[2] === "";
348
+ const workflowReceiptPath = receiptHeader ? lines[1]!.slice("Workflow receipt: ".length) : undefined;
349
+ let body = lines.slice(receiptHeader ? 3 : 2);
307
350
  // Restore the schedule origin so a re-rendered notice keeps its attribution and
308
351
  // does not fold the line into the result preview.
309
352
  const scheduleMatch = (body[0] ?? "").match(/^Scheduled run from \*\*(.+?)\*\* \(schedule (.+?)\)\.$/);
@@ -359,6 +402,7 @@ export function parseSubagentNotifyContent(content: string): SubagentNotifyDetai
359
402
  ...(match[1] === "Detached foreground task" ? { source: "foreground" as const } : {}),
360
403
  ...(match[4] ? { taskInfo: match[4] } : {}),
361
404
  ...(parsedScheduleOrigin ? { scheduleOrigin: parsedScheduleOrigin } : {}),
405
+ ...(workflowReceiptPath ? { workflowReceiptPath } : {}),
362
406
  resultPreview,
363
407
  ...(handoffPath ? { handoffPath } : {}),
364
408
  ...(workflowRunId ? { workflowRunId } : {}),
@@ -377,6 +421,7 @@ export function formatGroupedCompletion(details: SubagentNotifyDetails[]): strin
377
421
  if (!detail) continue;
378
422
  const sessionLine = formatSessionLine(detail);
379
423
  blocks.push(`${index + 1}. ${detail.agent}${detail.taskInfo ?? ""}${detail.scheduleOrigin ? ` — scheduled run from ${detail.scheduleOrigin.name ?? detail.scheduleOrigin.id} (schedule ${detail.scheduleOrigin.id})` : ""}`);
424
+ if (detail.workflowReceiptPath) blocks.push(`Workflow receipt: ${detail.workflowReceiptPath}`);
380
425
  blocks.push(formatResultPreview(detail));
381
426
  blocks.push(...formatWatchdogBlockerLines(detail));
382
427
  if (detail.handoffPath) blocks.push(`Parallel handoff: ${detail.handoffPath}`);
@@ -387,7 +432,31 @@ export function formatGroupedCompletion(details: SubagentNotifyDetails[]): strin
387
432
  return blocks.join("\n").trimEnd();
388
433
  }
389
434
 
435
+ const notificationDebug = debuglog("pi-subagents-notify");
436
+ type TraceIdentity = Pick<CompletionNotification, "id" | "runId" | "source">;
437
+ type NotificationReason = "disposed" | "missing_session" | "foreground_session_mismatch" | "not_owned"
438
+ | "intercom_delivered" | "deduped_ttl" | "deduped_pending" | "batch_deferred"
439
+ | "emit_foreground_session_mismatch" | "emit_not_owned" | "send_accepted" | "send_failed" | "dispose_pending";
440
+
441
+ // Slice before sanitizing: diagnostic work and each identity are bounded even for
442
+ // malformed result metadata. Never include task/output, paths, or error bodies.
443
+ function traceIdentity(result: TraceIdentity): TraceIdentity {
444
+ const identity = (value: unknown) => typeof value === "string"
445
+ ? value.slice(0, 128).replace(/[^a-zA-Z0-9_.:-]/g, "_") : undefined;
446
+ return { id: identity(result.id), runId: identity(result.runId), source: result.source === "foreground" ? "foreground" : "async" };
447
+ }
448
+
449
+ function traceNotification(reason: NotificationReason, result?: TraceIdentity): void {
450
+ if (!notificationDebug.enabled) return;
451
+ try {
452
+ notificationDebug("%s", JSON.stringify({ reason, ...(result ? traceIdentity(result) : {}) }));
453
+ } catch {
454
+ // Diagnostics must never change delivery acknowledgement.
455
+ }
456
+ }
457
+
390
458
  interface PendingCompletion {
459
+ trace?: TraceIdentity;
391
460
  key: string;
392
461
  details: SubagentNotifyDetails;
393
462
  sessionId: string;
@@ -448,6 +517,10 @@ export function buildCompletionDetails(result: CompletionNotification): Subagent
448
517
  ? result.parallelHandoff as { path?: unknown }
449
518
  : undefined;
450
519
  const handoffPath = typeof parallelHandoff?.path === "string" ? parallelHandoff.path : undefined;
520
+ const receipt = result.workflowReceipt;
521
+ const receiptPath = receipt && typeof receipt === "object" && !Array.isArray(receipt)
522
+ ? (receipt as Record<string, unknown>).path : undefined;
523
+ const workflowReceiptPath = typeof receiptPath === "string" && receiptPath ? receiptPath : undefined;
451
524
  const rawRunId = typeof result.runId === "string" ? result.runId : typeof result.id === "string" ? result.id : undefined;
452
525
  const workflowRunId = (result.mode === "workflow" || agent === "workflow") && rawRunId ? rawRunId : undefined;
453
526
  const directChild = !workflowRunId && result.results?.length === 1 ? result.results[0]! : undefined;
@@ -456,10 +529,17 @@ export function buildCompletionDetails(result: CompletionNotification): Subagent
456
529
  : undefined;
457
530
  const directSummary = summary.trim();
458
531
  const directAgent = typeof directChild?.agent === "string" ? directChild.agent : agent;
532
+ const directSummaryBody = directAgent && summary.trimStart().startsWith(`${directAgent}:\n`)
533
+ ? summary.trimStart().slice(directAgent.length + 2)
534
+ : directSummary;
535
+ const directDegenerateSummary = directChild
536
+ && isDegenerateOutput(typeof directChild.output === "string" ? directChild.output : "")
537
+ && isDegenerateOutput(directSummaryBody)
538
+ && structuredOutputText(directChild.structuredOutput) !== undefined;
459
539
  const directNoOutputSummary = directChild && (!directSummary
460
540
  || directSummary === "(no output)"
461
541
  || (directAgent && directSummary === `${directAgent}:\n(no output)`));
462
- const resultPreview = directStructuredPreview && directNoOutputSummary
542
+ const resultPreview = directStructuredPreview && (directNoOutputSummary || directDegenerateSummary)
463
543
  ? `Structured output:\n${directStructuredPreview}`
464
544
  : summary;
465
545
  const childRuns = result.results?.flatMap((child) => {
@@ -517,6 +597,7 @@ export function buildCompletionDetails(result: CompletionNotification): Subagent
517
597
  agent,
518
598
  status,
519
599
  ...(scheduleOrigin ? { scheduleOrigin } : {}),
600
+ ...(workflowReceiptPath ? { workflowReceiptPath } : {}),
520
601
  ...(result.source ? { source: result.source } : {}),
521
602
  ...(taskInfo ? { taskInfo } : {}),
522
603
  resultPreview,
@@ -548,8 +629,9 @@ export default function registerSubagentNotify(
548
629
  && typeof completionOwnerId === "string"
549
630
  && completionOwnerId === state.completionOwnerId);
550
631
 
551
- const settle = (items: PendingCompletion[], accepted: boolean) => {
632
+ const settle = (items: PendingCompletion[], accepted: boolean, reason?: NotificationReason) => {
552
633
  for (const item of items) {
634
+ if (reason) traceNotification(reason, item.trace);
553
635
  pending.delete(item.key);
554
636
  if (accepted) markSeenWithTtl(seen, item.key, now(), ttlMs);
555
637
  item.resolve(accepted);
@@ -562,10 +644,12 @@ export default function registerSubagentNotify(
562
644
  const owned = item.details.source === "foreground"
563
645
  ? item.sessionId === state.currentSessionId
564
646
  : ownsResult(item.sessionId, item.completionOwnerId);
647
+ if (!owned) traceNotification(item.details.source === "foreground" ? "emit_foreground_session_mismatch" : "emit_not_owned", item.trace);
565
648
  (owned ? accepted : rejected).push(item);
566
649
  }
567
650
  settle(rejected, false);
568
- settle(accepted, sendCompletion(pi, accepted));
651
+ const sent = sendCompletion(pi, accepted);
652
+ settle(accepted, sent, sent ? "send_accepted" : "send_failed");
569
653
  };
570
654
  const getBatcher = (result: CompletionNotification) => {
571
655
  const key = completionBatchKey(result);
@@ -583,17 +667,35 @@ export default function registerSubagentNotify(
583
667
  };
584
668
 
585
669
  const deliver = (result: CompletionNotification): Promise<boolean> => {
586
- if (disposed || typeof result.sessionId !== "string") return Promise.resolve(false);
670
+ if (disposed || typeof result.sessionId !== "string") {
671
+ traceNotification(disposed ? "disposed" : "missing_session", result);
672
+ return Promise.resolve(false);
673
+ }
587
674
  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);
675
+ if (result.sessionId !== state.currentSessionId) {
676
+ traceNotification("foreground_session_mismatch", result);
677
+ return Promise.resolve(false);
678
+ }
679
+ } else if (!ownsResult(result.sessionId, result.completionOwnerId)) {
680
+ traceNotification("not_owned", result);
681
+ return Promise.resolve(false);
682
+ }
683
+ if (result.intercomDelivered === true) {
684
+ traceNotification("intercom_delivered", result);
685
+ return Promise.resolve(true);
686
+ }
591
687
  const key = buildCompletionKey(result, "notify");
592
688
  const seenAt = seen.get(key);
593
- if (seenAt !== undefined && now() - seenAt <= ttlMs) return Promise.resolve(true);
689
+ if (seenAt !== undefined && now() - seenAt <= ttlMs) {
690
+ traceNotification("deduped_ttl", result);
691
+ return Promise.resolve(true);
692
+ }
594
693
  if (seenAt !== undefined) seen.delete(key);
595
694
  const inFlight = pending.get(key);
596
- if (inFlight) return inFlight;
695
+ if (inFlight) {
696
+ traceNotification("deduped_pending", result);
697
+ return inFlight;
698
+ }
597
699
  const details = buildCompletionDetails(result);
598
700
  let resolve!: (accepted: boolean) => void;
599
701
  const completion = new Promise<boolean>((settleCompletion) => { resolve = settleCompletion; });
@@ -603,9 +705,10 @@ export default function registerSubagentNotify(
603
705
  details,
604
706
  sessionId: result.sessionId,
605
707
  completionOwnerId: result.completionOwnerId,
606
- triggerTurn: result.triggerTurn !== false,
708
+ triggerTurn: result.triggerTurn !== false && scheduledCompletionTriggersTurn(result.scheduleOrigin, details.status),
607
709
  resolve,
608
710
  };
711
+ if (notificationDebug.enabled) item.trace = traceIdentity(result);
609
712
  if (details.source === "foreground") {
610
713
  emit([item]);
611
714
  return completion;
@@ -616,6 +719,7 @@ export default function registerSubagentNotify(
616
719
  emit([item]);
617
720
  return completion;
618
721
  }
722
+ if (batchConfig.enabled) traceNotification("batch_deferred", item.trace);
619
723
  batcher.push(item);
620
724
  return completion;
621
725
  };
@@ -633,7 +737,7 @@ export default function registerSubagentNotify(
633
737
  dispose() {
634
738
  if (disposed) return;
635
739
  disposed = true;
636
- for (const batcher of batchers.values()) settle(batcher.dispose(), false);
740
+ for (const batcher of batchers.values()) settle(batcher.dispose(), false, "dispose_pending");
637
741
  batchers.clear();
638
742
  for (const unsubscribe of [unsubscribeAsync, unsubscribeForeground]) {
639
743
  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;
@@ -35,6 +35,23 @@ function activeProcessGroupMembers(processGroupId: number): number[] | { diagnos
35
35
  return members;
36
36
  }
37
37
 
38
+ function knownDetachedDescendants(processGroupId: number): number[] {
39
+ const result = spawnSync("ps", ["-axo", "pid=,ppid=,pgid=,stat="], { encoding: "utf-8" });
40
+ if (result.error || result.status !== 0) return [];
41
+ const owned = new Set<number>();
42
+ const outside: { pid: number; ppid: number }[] = [];
43
+ for (const line of result.stdout.split("\n")) {
44
+ const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)/.exec(line);
45
+ if (!match || match[4]!.startsWith("Z")) continue;
46
+ const pid = Number(match[1]);
47
+ const ppid = Number(match[2]);
48
+ const pgid = Number(match[3]);
49
+ if (pgid === processGroupId) owned.add(pid);
50
+ else outside.push({ pid, ppid });
51
+ }
52
+ return outside.filter((row) => owned.has(row.ppid)).map((row) => row.pid);
53
+ }
54
+
38
55
  async function waitUntilGroupTerminal(
39
56
  processGroupId: number,
40
57
  timeoutMs: number,
@@ -52,37 +69,47 @@ async function waitUntilGroupTerminal(
52
69
  }
53
70
  }
54
71
 
55
- function observed(processGroupId: number): ProcessTreeTerminalV1 {
72
+ function observed(processGroupId: number): ProcessTreeTerminal {
56
73
  return { state: "observed", mechanism: "posix-process-group", processGroupId, verifiedAt: Date.now() };
57
74
  }
58
75
 
76
+ function observedUnlessLiveDetached(processGroupId: number, detached: readonly number[]): ProcessTreeTerminal {
77
+ const live = detached.filter((pid) => {
78
+ const result = spawnSync("ps", ["-o", "stat=", "-p", String(pid)], { encoding: "utf-8" });
79
+ return result.status === 0 && Boolean(result.stdout.trim()) && !result.stdout.trim().startsWith("Z");
80
+ });
81
+ if (live.length === 0) return observed(processGroupId);
82
+ return { state: "unknown", reason: "verification-failed", diagnostic: `Owned detached descendant(s) still active: ${live.join(", ")}.` };
83
+ }
84
+
59
85
  /** Owns one writer process group and arbitrates its cleanup exactly once. */
60
86
  export interface OwnedProcessTreeController {
61
- terminate(): Promise<ProcessTreeTerminalV1>;
62
- finishAfterWriterClose(): Promise<ProcessTreeTerminalV1>;
87
+ terminate(): Promise<ProcessTreeTerminal>;
88
+ finishAfterWriterClose(): Promise<ProcessTreeTerminal>;
63
89
  }
64
90
 
65
91
  export function createOwnedProcessTreeController(
66
92
  pid: number,
67
93
  options: { termGraceMs?: number; killVerifyMs?: number } = {},
68
94
  ): OwnedProcessTreeController {
69
- let termination: Promise<ProcessTreeTerminalV1> | undefined;
95
+ let termination: Promise<ProcessTreeTerminal> | undefined;
70
96
  const posixGroupOwned = process.platform !== "win32";
71
97
  const target = posixGroupOwned ? -pid : pid;
72
98
 
73
- const terminate = (): Promise<ProcessTreeTerminalV1> => {
99
+ const terminate = (): Promise<ProcessTreeTerminal> => {
74
100
  if (termination) return termination;
75
101
  termination = (async () => {
76
102
  if (!posixGroupOwned) {
77
103
  signalProcess(target, "SIGTERM");
78
104
  return { state: "unknown", reason: "unsupported-platform" };
79
105
  }
106
+ const detached = knownDetachedDescendants(pid);
80
107
  const term = signalProcess(target, "SIGTERM");
81
108
  if (term !== "sent" && term !== "absent") {
82
109
  return { state: "unknown", reason: "signal-failed", diagnostic: term.diagnostic };
83
110
  }
84
111
  const termExit = await waitUntilGroupTerminal(pid, options.termGraceMs ?? DEFAULT_TERM_GRACE_MS);
85
- if (termExit === false) return observed(pid);
112
+ if (termExit === false) return observedUnlessLiveDetached(pid, detached);
86
113
 
87
114
  const kill = signalProcess(target, "SIGKILL");
88
115
  if (kill !== "sent" && kill !== "absent") {
@@ -95,7 +122,7 @@ export function createOwnedProcessTreeController(
95
122
  if (killExit !== false) {
96
123
  return { state: "unknown", reason: "verification-failed", diagnostic: killExit.diagnostic };
97
124
  }
98
- return observed(pid);
125
+ return observedUnlessLiveDetached(pid, detached);
99
126
  })();
100
127
  return termination;
101
128
  };
@@ -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,