pi-subagents 0.38.0 → 0.40.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 (59) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +85 -13
  3. package/agents/worker.md +1 -0
  4. package/package.json +1 -1
  5. package/prompts/review-loop.md +1 -1
  6. package/skills/pi-subagents/SKILL.md +4 -3
  7. package/skills/pi-subagents/references/execution-controls.md +22 -3
  8. package/skills/pi-subagents/references/management-authoring-rpc.md +5 -1
  9. package/skills/pi-subagents/references/prompting-and-roles.md +17 -3
  10. package/src/agents/agent-management.ts +71 -30
  11. package/src/agents/agent-serializer.ts +4 -0
  12. package/src/agents/agents.ts +60 -1
  13. package/src/api/preflight.ts +16 -12
  14. package/src/extension/chain-validation.ts +17 -1
  15. package/src/extension/rpc.ts +2 -0
  16. package/src/extension/schemas.ts +16 -3
  17. package/src/extension/tool-description.ts +6 -5
  18. package/src/intercom/result-intercom.ts +33 -4
  19. package/src/runs/background/async-execution.ts +63 -16
  20. package/src/runs/background/async-job-tracker.ts +5 -1
  21. package/src/runs/background/async-resume.ts +1 -0
  22. package/src/runs/background/async-status.ts +18 -2
  23. package/src/runs/background/chain-append.ts +48 -5
  24. package/src/runs/background/control-channel.ts +60 -0
  25. package/src/runs/background/notify.ts +26 -4
  26. package/src/runs/background/result-watcher.ts +20 -3
  27. package/src/runs/background/run-status.ts +29 -3
  28. package/src/runs/background/subagent-runner.ts +302 -18
  29. package/src/runs/foreground/async-stop-action.ts +65 -0
  30. package/src/runs/foreground/chain-execution.ts +143 -43
  31. package/src/runs/foreground/execution.ts +384 -202
  32. package/src/runs/foreground/foreground-control.ts +30 -0
  33. package/src/runs/foreground/subagent-executor.ts +314 -77
  34. package/src/runs/shared/capability-ceiling.ts +51 -19
  35. package/src/runs/shared/chain-outputs.ts +3 -1
  36. package/src/runs/shared/dynamic-fanout.ts +1 -1
  37. package/src/runs/shared/nested-events.ts +97 -20
  38. package/src/runs/shared/parallel-utils.ts +43 -12
  39. package/src/runs/shared/pi-args.ts +45 -3
  40. package/src/runs/shared/process-signal.ts +19 -0
  41. package/src/runs/shared/run-history.ts +45 -9
  42. package/src/runs/shared/runtime-acknowledged-extensions.ts +71 -0
  43. package/src/runs/shared/subagent-prompt-runtime.ts +30 -0
  44. package/src/runs/shared/usage-budget.ts +65 -0
  45. package/src/runs/shared/workflow-graph.ts +26 -1
  46. package/src/shared/settings.ts +17 -1
  47. package/src/shared/types.ts +124 -8
  48. package/src/shared/utils.ts +12 -1
  49. package/src/slash/slash-commands.ts +43 -1
  50. package/src/slash/slash-live-state.ts +5 -3
  51. package/src/tui/fleet-status.ts +12 -2
  52. package/src/tui/fleet.ts +181 -8
  53. package/src/tui/render.ts +4 -2
  54. package/src/watchdog/register-main.ts +14 -7
  55. package/src/watchdog/review.ts +4 -3
  56. package/src/watchdog/runtime.ts +170 -17
  57. package/src/watchdog/scope.ts +62 -0
  58. package/src/watchdog/settings.ts +41 -1
  59. package/src/watchdog/types.ts +10 -0
@@ -55,10 +55,12 @@ import { evaluateCompletionMutationGuard } from "../shared/completion-guard.ts";
55
55
  import { getPiSpawnCommand } from "../shared/pi-spawn.ts";
56
56
  import { createJsonlWriter } from "../../shared/jsonl-writer.ts";
57
57
  import { attachPostExitStdioGuard, trySignalChild } from "../../shared/post-exit-stdio-guard.ts";
58
- import { applyThinkingSuffix, buildPiArgs, cleanupTempDir, resolvePiLaunchToolPlan } from "../shared/pi-args.ts";
59
- import { decodeSubagentCapabilityCeiling, resolveCurrentSubagentCapabilityCeiling, SUBAGENT_CAPABILITY_CEILING_ENV } from "../shared/capability-ceiling.ts";
58
+ import { applyThinkingSuffix, buildPiArgs, cleanupTempDir, projectLaunchResolvedChildExtensions, resolvePiLaunchToolPlan } from "../shared/pi-args.ts";
59
+ import { readRuntimeAcknowledgedExtensions } from "../shared/runtime-acknowledged-extensions.ts";
60
+ import { assertAgentAllowedByCapabilityCeiling, decodeSubagentCapabilityCeiling, intersectSubagentCapabilityCeilings, resolveCurrentSubagentCapabilityCeiling, SUBAGENT_CAPABILITY_CEILING_ENV } from "../shared/capability-ceiling.ts";
60
61
  import { resolveEffectiveThinking } from "../../shared/model-info.ts";
61
62
  import { MISSING_STRUCTURED_OUTPUT_CALL_ERROR, readStructuredOutput } from "../shared/structured-output.ts";
63
+ import { formatProcessSignalError, isUnexplainedProcessSignal } from "../shared/process-signal.ts";
62
64
  import { readChildToolDiagnosticError } from "../shared/tool-availability.ts";
63
65
  import { captureSingleOutputSnapshot, extractChildWrittenOutput, formatSavedOutputReference, injectOutputPathSystemPrompt, resolveSingleOutput, validateFileOnlyOutputMode, type SingleOutputSnapshot } from "../shared/single-output.ts";
64
66
  import {
@@ -121,6 +123,47 @@ function sumUsage(target: Usage, source: Usage): void {
121
123
  target.turns += source.turns;
122
124
  }
123
125
 
126
+ function persistSingleResultMetadata(input: {
127
+ metadataPath?: string;
128
+ enabled: boolean;
129
+ runId?: string;
130
+ agent: string;
131
+ task: string;
132
+ result: SingleResult;
133
+ }): void {
134
+ if (!input.enabled || !input.metadataPath) return;
135
+ const target = input.result;
136
+ writeMetadata(input.metadataPath, {
137
+ runId: input.runId,
138
+ agent: input.agent,
139
+ task: input.task,
140
+ exitCode: target.exitCode,
141
+ processSignal: target.processSignal,
142
+ usage: target.usage,
143
+ model: target.model,
144
+ attemptedModels: target.attemptedModels,
145
+ modelAttempts: target.modelAttempts,
146
+ durationMs: target.progressSummary?.durationMs,
147
+ toolCount: target.progressSummary?.toolCount,
148
+ error: target.error,
149
+ agentContract: target.agentContract,
150
+ launchContractDigest: target.launchContractDigest,
151
+ launchResolvedExtensions: target.launchResolvedExtensions,
152
+ runtimeAcknowledgedExtensions: target.runtimeAcknowledgedExtensions,
153
+ execution: target.execution,
154
+ acceptance: target.acceptance,
155
+ capabilityCeiling: target.capabilityCeiling,
156
+ capabilityAudit: target.capabilityAudit,
157
+ review: target.review,
158
+ effects: target.effects,
159
+ transcriptPath: target.transcriptPath,
160
+ transcriptError: target.transcriptError,
161
+ skills: target.skills,
162
+ skillsWarning: target.skillsWarning,
163
+ timestamp: Date.now(),
164
+ });
165
+ }
166
+
124
167
  function formatTimeoutMessage(timeoutMs: number): string {
125
168
  return `Subagent timed out after ${timeoutMs}ms.`;
126
169
  }
@@ -148,6 +191,21 @@ function buildPendingAcceptanceLedger(acceptance: ResolvedAcceptanceConfig): Acc
148
191
  };
149
192
  }
150
193
 
194
+ function buildInterruptedAcceptanceLedger(acceptance: ResolvedAcceptanceConfig): AcceptanceLedger {
195
+ const pending = buildPendingAcceptanceLedger(acceptance);
196
+ if (acceptance.level === "none") {
197
+ pending.status = "not-required";
198
+ pending.evidenceStatus = "not-required";
199
+ } else {
200
+ pending.runtimeChecks = [{
201
+ id: "interrupted",
202
+ status: "not-applicable",
203
+ message: "Acceptance was not evaluated because the subagent was interrupted.",
204
+ }];
205
+ }
206
+ return pending;
207
+ }
208
+
151
209
  function appendRecentOutput(progress: AgentProgress, lines: string[]): void {
152
210
  if (lines.length === 0) return;
153
211
  progress.recentOutput.push(...lines.filter((line) => line.trim()));
@@ -203,7 +261,7 @@ function snapshotResult(result: SingleResult, progress: AgentProgress): SingleRe
203
261
  * unbounded `messages` transcript in favour of compact tool-call summaries so a
204
262
  * single `tool_execution_update` line stays well under the child-stdout protocol
205
263
  * cap (`MAX_CHILD_PENDING_LINE_BYTES`). Non-streaming consumers such as
206
- * detached-exit recovery call snapshotResult directly and keep the full transcript.
264
+ * foreground detach receipts and terminal consumers use snapshotResult directly.
207
265
  */
208
266
  function snapshotStreamResult(result: SingleResult, progress: AgentProgress): SingleResult {
209
267
  const snapshot = snapshotResult(result, progress);
@@ -244,7 +302,7 @@ async function runSingleAttempt(
244
302
  childIndex: options.index ?? 0,
245
303
  })
246
304
  : undefined;
247
- const { args, env: sharedEnv, tempDir, toolDiagnosticPath, capabilityAudit } = buildPiArgs({
305
+ const { args, env: sharedEnv, tempDir, toolDiagnosticPath, runtimeAcknowledgedExtensionsPath, capabilityAudit } = buildPiArgs({
248
306
  baseArgs: ["--mode", "json", "-p"],
249
307
  task,
250
308
  sessionEnabled: shared.sessionEnabled,
@@ -292,7 +350,9 @@ async function runSingleAttempt(
292
350
  structuredOutput: Boolean(options.structuredOutput),
293
351
  capabilityCeiling: options.capabilityCeiling,
294
352
  inheritedCapabilityCeiling: decodeSubagentCapabilityCeiling(process.env[SUBAGENT_CAPABILITY_CEILING_ENV]),
353
+ agentName: agent.name,
295
354
  });
355
+ const launchResolvedExtensions = projectLaunchResolvedChildExtensions(toolPlan);
296
356
  const launchContractDigest = launchBindingDigest({
297
357
  definitionDigest: agentDefinitionDigest(agent),
298
358
  task: shared.originalTask ?? task,
@@ -312,11 +372,14 @@ async function runSingleAttempt(
312
372
  ...(options.structuredOutput ? { structuredOutputSchema: options.structuredOutput.schema } : {}),
313
373
  });
314
374
  const result: SingleResult = withRunContext({
375
+ index: options.index ?? 0,
315
376
  agent: agent.name,
316
377
  task: shared.originalTask ?? task,
317
378
  ...(options.agentContract ? { agentContract: options.agentContract } : {}),
318
379
  launchContractDigest,
380
+ launchResolvedExtensions,
319
381
  exitCode: 0,
382
+ outputState: "absent",
320
383
  messages: [],
321
384
  usage: emptyUsage(),
322
385
  model: modelArg,
@@ -399,7 +462,7 @@ async function runSingleAttempt(
399
462
  });
400
463
  const jsonlWriter = createJsonlWriter(shared.jsonlPath, proc.stdout);
401
464
  let processClosed = false;
402
- let settled = false;
465
+ let lifecycleFinished = false;
403
466
  let detached = false;
404
467
  let intercomStarted = false;
405
468
  let assistantError: string | undefined;
@@ -438,19 +501,43 @@ async function runSingleAttempt(
438
501
  }
439
502
  };
440
503
 
441
- const detachForIntercom = () => {
442
- detached = true;
443
- processClosed = true;
444
- result.detached = true;
445
- result.detachedReason = "intercom coordination";
446
- progress.status = "detached";
447
- progress.durationMs = Date.now() - startTime;
448
- result.progressSummary = {
449
- toolCount: progress.toolCount,
450
- tokens: progress.tokens,
451
- durationMs: progress.durationMs,
504
+ const detachForeground = (reason: string): boolean => {
505
+ if (detached || processClosed || lifecycleFinished || options.signal?.aborted) return false;
506
+ const receiptProgress = snapshotProgress(progress);
507
+ receiptProgress.status = "detached";
508
+ receiptProgress.durationMs = Date.now() - startTime;
509
+ const receipt = snapshotResult(result, receiptProgress);
510
+ receipt.exitCode = -2;
511
+ receipt.detached = true;
512
+ receipt.detachedReason = reason;
513
+ receipt.finalOutput = reason === "intercom coordination"
514
+ ? "Detached for intercom coordination before task completion."
515
+ : reason === "user request"
516
+ ? "Detached at user request before task completion."
517
+ : `Detached for ${reason} before task completion.`;
518
+ receipt.outputMode = options.outputMode ?? "inline";
519
+ if (options.outputPath) {
520
+ receipt.outputSaveError = reason === "user request"
521
+ ? "Output file was not finalized because the subagent detached at user request."
522
+ : `Output file was not finalized because the subagent detached for ${reason}.`;
523
+ }
524
+ receipt.progressSummary = {
525
+ toolCount: receiptProgress.toolCount,
526
+ tokens: receiptProgress.tokens,
527
+ durationMs: receiptProgress.durationMs,
452
528
  };
453
- finish(-2);
529
+ // The attempt is not detached until the outer coordinator has accepted and
530
+ // synchronously published the receipt. A persistence/callback failure must
531
+ // leave this attempt attached and abortable rather than orphaning it.
532
+ let accepted = false;
533
+ try {
534
+ accepted = options.onDetachReceipt?.(receipt) === true;
535
+ } catch {
536
+ return false;
537
+ }
538
+ if (!accepted) return false;
539
+ detached = true;
540
+ return true;
454
541
  };
455
542
 
456
543
  // If the child emits a terminal assistant stop but never exits,
@@ -491,9 +578,9 @@ async function runSingleAttempt(
491
578
  armWatchdogTail();
492
579
  return;
493
580
  }
494
- if (childExited || finalDrainTimer || settled || processClosed || detached) return;
581
+ if (childExited || finalDrainTimer || lifecycleFinished || processClosed) return;
495
582
  finalDrainTimer = setTimeout(() => {
496
- if (settled || processClosed || detached) return;
583
+ if (lifecycleFinished || processClosed) return;
497
584
  const termSent = trySignalChild(proc, "SIGTERM");
498
585
  if (!termSent) return;
499
586
  forcedTerminationSignal = true;
@@ -501,7 +588,7 @@ async function runSingleAttempt(
501
588
  result.error = result.error ?? `Subagent process did not exit within ${FINAL_STOP_GRACE_MS}ms after its terminal event. Forcing termination.`;
502
589
  }
503
590
  finalHardKillTimer = setTimeout(() => {
504
- if (settled || processClosed || detached) return;
591
+ if (lifecycleFinished || processClosed) return;
505
592
  forcedTerminationSignal = trySignalChild(proc, "SIGKILL") || forcedTerminationSignal;
506
593
  }, HARD_KILL_MS);
507
594
  finalHardKillTimer.unref?.();
@@ -509,7 +596,7 @@ async function runSingleAttempt(
509
596
  finalDrainTimer.unref?.();
510
597
  };
511
598
  function armWatchdogTail(): void {
512
- if ((!cleanTerminalAssistantStopReceived && !agentSettledReceived) || watchdogTailTimer || settled || processClosed || detached) return;
599
+ if ((!cleanTerminalAssistantStopReceived && !agentSettledReceived) || watchdogTailTimer || lifecycleFinished || processClosed) return;
513
600
  watchdogTailTimer = setTimeout(() => {
514
601
  watchdogTailTimer = undefined;
515
602
  updateChildWatchdogState({
@@ -535,7 +622,7 @@ async function runSingleAttempt(
535
622
  };
536
623
 
537
624
  const unsubscribeIntercomDetach = options.intercomEvents?.on?.(INTERCOM_DETACH_REQUEST_EVENT, (payload) => {
538
- if (!options.allowIntercomDetach || detached || processClosed) return;
625
+ if (!options.allowIntercomDetach || processClosed) return;
539
626
  if (!payload || typeof payload !== "object") return;
540
627
  const event = payload as { requestId?: unknown; runId?: unknown; agent?: unknown; childIndex?: unknown };
541
628
  const requestId = event.requestId;
@@ -546,13 +633,13 @@ async function runSingleAttempt(
546
633
  if (typeof event.agent === "string" && event.agent !== agent.name) return;
547
634
  if (typeof event.childIndex === "number" && event.childIndex !== (options.index ?? 0)) return;
548
635
  } else if (!intercomStarted) return;
549
- options.intercomEvents?.emit(INTERCOM_DETACH_RESPONSE_EVENT, { requestId, accepted: true, runId: options.runId, agent: agent.name, childIndex: options.index ?? 0 });
550
- detachForIntercom();
636
+ const accepted = detachForeground("intercom coordination");
637
+ options.intercomEvents?.emit(INTERCOM_DETACH_RESPONSE_EVENT, { requestId, accepted, runId: options.runId, agent: agent.name, childIndex: options.index ?? 0 });
551
638
  });
552
639
 
553
640
  const finish = (code: number) => {
554
- if (settled) return;
555
- settled = true;
641
+ if (lifecycleFinished) return;
642
+ lifecycleFinished = true;
556
643
  clearFinalDrainTimers();
557
644
  clearWatchdogTailTimer();
558
645
  clearStdioGuard();
@@ -637,7 +724,7 @@ async function runSingleAttempt(
637
724
  };
638
725
  const requestTurnBudgetAbort = (turnCount: number) => {
639
726
  const budget = options.turnBudget;
640
- if (!budget || result.timedOut || result.turnBudgetExceeded || interruptedByControl || processClosed || settled || detached) return;
727
+ if (!budget || result.timedOut || result.turnBudgetExceeded || interruptedByControl || processClosed || lifecycleFinished) return;
641
728
  const message = turnBudgetExceededMessage(budget, turnCount);
642
729
  result.turnBudgetExceeded = true;
643
730
  result.wrapUpRequested = true;
@@ -650,12 +737,12 @@ async function runSingleAttempt(
650
737
  fireUpdate();
651
738
  trySignalChild(proc, "SIGINT");
652
739
  turnBudgetTerminationTimer = setTimeout(() => {
653
- if (processClosed || settled || detached || result.timedOut) return;
740
+ if (processClosed || lifecycleFinished || result.timedOut) return;
654
741
  trySignalChild(proc, "SIGTERM");
655
742
  }, 1000);
656
743
  turnBudgetTerminationTimer.unref?.();
657
744
  turnBudgetHardKillTimer = setTimeout(() => {
658
- if (processClosed || settled || detached || result.timedOut) return;
745
+ if (processClosed || lifecycleFinished || result.timedOut) return;
659
746
  trySignalChild(proc, "SIGKILL");
660
747
  }, 4000);
661
748
  turnBudgetHardKillTimer.unref?.();
@@ -892,7 +979,7 @@ async function runSingleAttempt(
892
979
 
893
980
  if (controlConfig.enabled) {
894
981
  activityTimer = setInterval(() => {
895
- if (processClosed || settled || detached) return;
982
+ if (processClosed || lifecycleFinished) return;
896
983
  const now = Date.now();
897
984
  if (updateActivityState(now)) {
898
985
  progress.durationMs = now - startTime;
@@ -904,7 +991,7 @@ async function runSingleAttempt(
904
991
 
905
992
  if (attemptTimeout) {
906
993
  timeoutTimer = setTimeout(() => {
907
- if (processClosed || settled || detached || interruptedByControl) return;
994
+ if (processClosed || lifecycleFinished || interruptedByControl) return;
908
995
  result.timedOut = true;
909
996
  result.error = attemptTimeout.message;
910
997
  result.finalOutput = attemptTimeout.message;
@@ -914,12 +1001,12 @@ async function runSingleAttempt(
914
1001
  fireUpdate();
915
1002
  trySignalChild(proc, "SIGINT");
916
1003
  timeoutTerminationTimer = setTimeout(() => {
917
- if (processClosed || settled || detached) return;
1004
+ if (processClosed || lifecycleFinished) return;
918
1005
  trySignalChild(proc, "SIGTERM");
919
1006
  }, 1000);
920
1007
  timeoutTerminationTimer.unref?.();
921
1008
  timeoutHardKillTimer = setTimeout(() => {
922
- if (processClosed || settled || detached) return;
1009
+ if (processClosed || lifecycleFinished) return;
923
1010
  trySignalChild(proc, "SIGKILL");
924
1011
  }, 4000);
925
1012
  timeoutHardKillTimer.unref?.();
@@ -963,19 +1050,33 @@ async function runSingleAttempt(
963
1050
  clearFinalDrainTimers();
964
1051
  });
965
1052
  proc.on("close", (code, signal) => {
1053
+ if (lifecycleFinished) return;
1054
+ processClosed = true;
966
1055
  clearFinalDrainTimers();
967
1056
  clearStdioGuard();
968
1057
  void jsonlWriter.close().catch(() => {
969
1058
  // JSONL artifact flush is best effort.
970
1059
  });
971
1060
  const toolDiagnosticError = readChildToolDiagnosticError(toolDiagnosticPath);
1061
+ result.runtimeAcknowledgedExtensions = readRuntimeAcknowledgedExtensions(runtimeAcknowledgedExtensionsPath);
972
1062
  cleanupTempDir(tempDir);
973
1063
  stdoutReader.end();
974
1064
  stderrReader.end();
975
1065
  const stderr = stderrTail.text();
976
1066
  const rawStdout = rawStdoutTail.text();
977
1067
  let closeError = result.error ?? toolDiagnosticError ?? assistantError;
978
- const forcedDrainAfterFinalSuccess = forcedTerminationSignal && (cleanTerminalAssistantStopReceived || agentSettledReceived) && !closeError;
1068
+ const forcedDrainAfterFinalSuccess = Boolean(forcedTerminationSignal || signal) && (cleanTerminalAssistantStopReceived || agentSettledReceived) && !closeError;
1069
+ if (signal) result.processSignal = signal;
1070
+ if (!closeError && isUnexplainedProcessSignal({
1071
+ processSignal: signal,
1072
+ interrupted: result.interrupted,
1073
+ timedOut: result.timedOut,
1074
+ stopped: result.stopped,
1075
+ turnBudgetExceeded: result.turnBudgetExceeded,
1076
+ forcedDrainAfterFinalSuccess,
1077
+ })) {
1078
+ closeError = formatProcessSignalError(signal!);
1079
+ }
979
1080
  if (code !== 0 && rawStdout.trim() && !closeError && !forcedDrainAfterFinalSuccess) {
980
1081
  closeError = rawStdout.trim();
981
1082
  }
@@ -983,61 +1084,12 @@ async function runSingleAttempt(
983
1084
  closeError = stderr.trim();
984
1085
  }
985
1086
  const finalCode = forcedDrainAfterFinalSuccess ? 0 : forcedTerminationSignal || signal ? (code ?? 1) : (code ?? 0);
986
- if (signal) result.processSignal = signal;
987
- if (detached) {
988
- const recoveredProgress = snapshotProgress(progress);
989
- const recoveredResult = snapshotResult(result, recoveredProgress);
990
- if (!recoveredResult.error && closeError) recoveredResult.error = closeError;
991
- recoveredResult.exitCode = recoveredResult.error && finalCode === 0 ? 1 : finalCode;
992
- recoveredProgress.status = recoveredResult.exitCode === 0 ? "completed" : "failed";
993
- recoveredProgress.durationMs = Date.now() - startTime;
994
- if (recoveredResult.error) recoveredProgress.error = recoveredResult.error;
995
- recoveredResult.progressSummary = {
996
- toolCount: recoveredProgress.toolCount,
997
- tokens: recoveredProgress.tokens,
998
- durationMs: recoveredProgress.durationMs,
999
- };
1000
- const acceptanceOutput = getFinalOutput(recoveredResult.messages ?? []).trim()
1001
- || recoveredResult.error
1002
- || recoveredResult.finalOutput
1003
- || "Detached child exited without final output.";
1004
- acceptanceOutputByResult.set(recoveredResult, acceptanceOutput);
1005
- let fullOutput = stripAcceptanceReport(acceptanceOutput);
1006
- fullOutput = fullOutput.trim() || recoveredResult.error || recoveredResult.finalOutput || "Detached child exited without final output.";
1007
- recoveredResult.outputMode = options.outputMode ?? "inline";
1008
- if (options.outputPath && recoveredResult.exitCode === 0) {
1009
- const resolvedOutput = resolveSingleOutput(options.outputPath, fullOutput, shared.outputSnapshot);
1010
- fullOutput = stripAcceptanceReport(resolvedOutput.fullOutput);
1011
- recoveredResult.savedOutputPath = resolvedOutput.savedPath;
1012
- recoveredResult.outputSaveError = resolvedOutput.saveError;
1013
- if (resolvedOutput.savedPath) {
1014
- recoveredResult.outputReference = formatSavedOutputReference(resolvedOutput.savedPath, fullOutput);
1015
- } else {
1016
- recoveredResult.exitCode = 1;
1017
- recoveredResult.error = `Output file was not finalized after detached child exit: ${resolvedOutput.saveError ?? options.outputPath}`;
1018
- recoveredProgress.status = "failed";
1019
- recoveredProgress.error = recoveredResult.error;
1020
- }
1021
- }
1022
- recoveredResult.finalOutput = options.outputMode === "file-only" && recoveredResult.savedOutputPath && recoveredResult.outputReference
1023
- ? recoveredResult.outputReference.message
1024
- : fullOutput;
1025
- if (recoveredResult.artifactPaths && options.artifactConfig?.enabled !== false && options.artifactConfig?.includeOutput !== false) {
1026
- try {
1027
- writeArtifact(recoveredResult.artifactPaths.outputPath, fullOutput);
1028
- } catch {
1029
- // Detached children may outlive test/temp cleanup; recovered status is best-effort.
1030
- }
1031
- }
1032
- options.onDetachedExit?.(recoveredResult);
1033
- finish(-2);
1034
- return;
1035
- }
1036
1087
  if (!result.error && closeError) result.error = closeError;
1037
- processClosed = true;
1038
1088
  finish(finalCode);
1039
1089
  });
1040
1090
  proc.on("error", (error) => {
1091
+ if (lifecycleFinished) return;
1092
+ processClosed = true;
1041
1093
  clearFinalDrainTimers();
1042
1094
  clearStdioGuard();
1043
1095
  void jsonlWriter.close().catch(() => {
@@ -1054,7 +1106,7 @@ async function runSingleAttempt(
1054
1106
 
1055
1107
  if (options.signal) {
1056
1108
  const kill = () => {
1057
- if (processClosed || detached) return;
1109
+ if (processClosed || lifecycleFinished) return;
1058
1110
  proc.kill("SIGTERM");
1059
1111
  setTimeout(() => !proc.killed && proc.kill("SIGKILL"), 3000);
1060
1112
  };
@@ -1067,7 +1119,7 @@ async function runSingleAttempt(
1067
1119
 
1068
1120
  if (options.interruptSignal) {
1069
1121
  const interrupt = () => {
1070
- if (processClosed || detached || settled) return;
1122
+ if (processClosed || lifecycleFinished) return;
1071
1123
  if (result.timedOut) return;
1072
1124
  interruptedByControl = true;
1073
1125
  clearTimeoutTimers();
@@ -1079,7 +1131,7 @@ async function runSingleAttempt(
1079
1131
  fireUpdate();
1080
1132
  trySignalChild(proc, "SIGINT");
1081
1133
  setTimeout(() => {
1082
- if (settled || processClosed || detached) return;
1134
+ if (lifecycleFinished || processClosed) return;
1083
1135
  trySignalChild(proc, "SIGTERM");
1084
1136
  }, 1000).unref?.();
1085
1137
  };
@@ -1089,6 +1141,16 @@ async function runSingleAttempt(
1089
1141
  removeInterruptListener = () => options.interruptSignal?.removeEventListener("abort", interrupt);
1090
1142
  }
1091
1143
  }
1144
+
1145
+ // Publish only after every callback and cleanup guard captured by detach or
1146
+ // later lifecycle events has initialized. Consumers may invoke synchronously.
1147
+ try {
1148
+ options.onDetachReady?.((reason = "user request") => detachForeground(reason));
1149
+ } catch (error) {
1150
+ // A consumer callback is advisory. Keep the child attached and observable
1151
+ // rather than rejecting this attempt and orphaning its live process.
1152
+ appendRecentOutput(progress, [`Foreground detach callback failed: ${error instanceof Error ? error.message : String(error)}`]);
1153
+ }
1092
1154
  });
1093
1155
  result.exitCode = exitCode;
1094
1156
  if (interruptedByControl) {
@@ -1106,16 +1168,6 @@ async function runSingleAttempt(
1106
1168
  };
1107
1169
  return result;
1108
1170
  }
1109
- if (result.detached) {
1110
- result.exitCode = -2;
1111
- result.finalOutput = "Detached for intercom coordination before task completion.";
1112
- result.outputMode = options.outputMode ?? "inline";
1113
- if (options.outputPath) {
1114
- result.outputSaveError = "Output file was not finalized because the subagent detached for intercom coordination.";
1115
- }
1116
- return result;
1117
- }
1118
-
1119
1171
  if (result.error && result.exitCode === 0) {
1120
1172
  result.exitCode = 1;
1121
1173
  }
@@ -1177,6 +1229,7 @@ async function runSingleAttempt(
1177
1229
 
1178
1230
  const acceptanceOutput = getFinalOutput(result.messages ?? []);
1179
1231
  let fullOutput = stripAcceptanceReport(acceptanceOutput);
1232
+ result.outputState = fullOutput.trim() || result.structuredOutput !== undefined ? "present" : "absent";
1180
1233
  if (result.timedOut) {
1181
1234
  const timeoutMessage = formatTimeoutMessage(options.timeoutMs ?? 0);
1182
1235
  fullOutput = fullOutput.trim()
@@ -1236,6 +1289,7 @@ async function runSingleAttempt(
1236
1289
  result.outputSaveError = resolvedOutput.saveError;
1237
1290
  if (resolvedOutput.savedPath) {
1238
1291
  result.outputReference = formatSavedOutputReference(resolvedOutput.savedPath, fullOutput);
1292
+ if (result.outputState === "absent") result.outputState = "unknown";
1239
1293
  }
1240
1294
  }
1241
1295
  artifactOutputByResult.set(result, fullOutput);
@@ -1265,7 +1319,7 @@ async function runSingleAttempt(
1265
1319
  /**
1266
1320
  * Run a subagent synchronously (blocking until complete)
1267
1321
  */
1268
- export async function runSync(
1322
+ async function runSyncCompletion(
1269
1323
  runtimeCwd: string,
1270
1324
  agents: AgentConfig[],
1271
1325
  agentName: string,
@@ -1274,11 +1328,12 @@ export async function runSync(
1274
1328
  ): Promise<SingleResult> {
1275
1329
  options = {
1276
1330
  ...options,
1277
- capabilityCeiling: options.capabilityCeiling ?? resolveCurrentSubagentCapabilityCeiling(options.parentSessionId),
1331
+ capabilityCeiling: intersectSubagentCapabilityCeilings(options.capabilityCeiling ?? resolveCurrentSubagentCapabilityCeiling(options.parentSessionId), decodeSubagentCapabilityCeiling(process.env[SUBAGENT_CAPABILITY_CEILING_ENV])),
1278
1332
  };
1279
1333
  const agent = agents.find((a) => a.name === agentName);
1280
1334
  if (!agent) {
1281
1335
  return withRunContext({
1336
+ index: options.index ?? 0,
1282
1337
  agent: agentName,
1283
1338
  task,
1284
1339
  exitCode: 1,
@@ -1287,9 +1342,23 @@ export async function runSync(
1287
1342
  error: `Unknown agent: ${agentName}`,
1288
1343
  }, options.context);
1289
1344
  }
1345
+ try {
1346
+ assertAgentAllowedByCapabilityCeiling(agent.name, options.capabilityCeiling);
1347
+ } catch (error) {
1348
+ return withRunContext({
1349
+ agent: agent.name,
1350
+ task,
1351
+ exitCode: 1,
1352
+ messages: [],
1353
+ usage: emptyUsage(),
1354
+ error: error instanceof Error ? error.message : String(error),
1355
+ ...(options.capabilityCeiling ? { capabilityCeiling: options.capabilityCeiling } : {}),
1356
+ }, options.context);
1357
+ }
1290
1358
  const outputModeValidationError = validateFileOnlyOutputMode(options.outputMode, options.outputPath, `Single run (${agentName})`);
1291
1359
  if (outputModeValidationError) {
1292
1360
  return withRunContext({
1361
+ index: options.index ?? 0,
1293
1362
  agent: agentName,
1294
1363
  task,
1295
1364
  exitCode: 1,
@@ -1326,6 +1395,7 @@ export async function runSync(
1326
1395
  );
1327
1396
  if (skillNames.some((skill) => skill.trim() === "pi-subagents") && missingSkills.includes("pi-subagents")) {
1328
1397
  return withRunContext({
1398
+ index: options.index ?? 0,
1329
1399
  agent: agentName,
1330
1400
  task,
1331
1401
  exitCode: 1,
@@ -1385,83 +1455,42 @@ export async function runSync(
1385
1455
  }
1386
1456
 
1387
1457
  const persistResultMetadata = (target: SingleResult): void => {
1388
- if (!artifactPathsResult || options.artifactConfig?.enabled === false || options.artifactConfig?.includeMetadata === false) return;
1389
- writeMetadata(artifactPathsResult.metadataPath, {
1458
+ persistSingleResultMetadata({
1459
+ metadataPath: artifactPathsResult?.metadataPath,
1460
+ enabled: options.artifactConfig?.enabled !== false && options.artifactConfig?.includeMetadata !== false,
1390
1461
  runId: options.runId,
1391
1462
  agent: agentName,
1392
1463
  task,
1393
- exitCode: target.exitCode,
1394
- processSignal: target.processSignal,
1395
- usage: target.usage,
1396
- model: target.model,
1397
- attemptedModels: target.attemptedModels,
1398
- modelAttempts: target.modelAttempts,
1399
- durationMs: target.progressSummary?.durationMs,
1400
- toolCount: target.progressSummary?.toolCount,
1401
- error: target.error,
1402
- agentContract: target.agentContract,
1403
- launchContractDigest: target.launchContractDigest,
1404
- execution: target.execution,
1405
- acceptance: target.acceptance,
1406
- capabilityCeiling: target.capabilityCeiling,
1407
- capabilityAudit: target.capabilityAudit,
1408
- review: target.review,
1409
- effects: target.effects,
1410
- ...(transcriptWriter ? { transcriptPath: artifactPathsResult.transcriptPath } : {}),
1411
- transcriptError: target.transcriptError,
1412
- skills: target.skills,
1413
- skillsWarning: target.skillsWarning,
1414
- timestamp: Date.now(),
1464
+ result: target,
1415
1465
  });
1416
1466
  };
1417
1467
 
1418
- const detachedAwareOptions: RunSyncOptions = options.onDetachedExit
1419
- ? {
1420
- ...options,
1421
- onDetachedExit: (recoveredResult) => {
1422
- void (async () => {
1423
- const childWrittenOutput = options.outputPath
1424
- ? extractChildWrittenOutput(recoveredResult.messages, options.outputPath, options.cwd ?? runtimeCwd)
1425
- : undefined;
1426
- recoveredResult.acceptance = await evaluateAcceptance({
1427
- acceptance: effectiveAcceptance,
1428
- output: acceptanceOutputByResult.get(recoveredResult) ?? recoveredResult.finalOutput ?? "",
1429
- fileOutput: childWrittenOutput !== undefined && options.outputPath
1430
- ? { content: childWrittenOutput, path: options.outputPath, authoritative: options.outputMode === "file-only" }
1431
- : undefined,
1432
- cwd: options.cwd ?? runtimeCwd,
1433
- reportOptional: isAgentContractV1(options.agentContract),
1434
- });
1435
- const acceptanceFailure = acceptanceFailureMessage(recoveredResult.acceptance);
1436
- stripAcceptanceReportsFromMessages(recoveredResult.messages);
1437
- if (acceptanceFailure && recoveredResult.acceptance.explicit && recoveredResult.exitCode === 0 && !isAgentContractV1(options.agentContract)) {
1438
- recoveredResult.exitCode = 1;
1439
- recoveredResult.error = recoveredResult.error ? `${recoveredResult.error}\n${acceptanceFailure}` : acceptanceFailure;
1440
- if (recoveredResult.progress) {
1441
- recoveredResult.progress.status = "failed";
1442
- recoveredResult.progress.error = recoveredResult.error;
1443
- }
1444
- }
1445
- if (isAgentContractV1(options.agentContract)) attachContractProjections(recoveredResult);
1446
- persistResultMetadata(recoveredResult);
1447
- options.onDetachedExit?.(recoveredResult);
1448
- })().catch((error) => {
1449
- const message = error instanceof Error ? error.message : String(error);
1450
- recoveredResult.exitCode = 1;
1451
- recoveredResult.error = recoveredResult.error ? `${recoveredResult.error}\nAcceptance evaluation failed: ${message}` : `Acceptance evaluation failed: ${message}`;
1452
- options.onDetachedExit?.(recoveredResult);
1453
- });
1454
- },
1455
- }
1456
- : options;
1457
-
1468
+ let intercomDetached = false;
1469
+ let detachedReason: string | undefined;
1470
+ const attemptOptions: RunSyncOptions = {
1471
+ ...options,
1472
+ onDetachReceipt: (receipt) => {
1473
+ receipt.acceptance = buildPendingAcceptanceLedger(effectiveAcceptance);
1474
+ try {
1475
+ persistResultMetadata(receipt);
1476
+ } catch (error) {
1477
+ receipt.metadataSaveError = error instanceof Error ? error.message : String(error);
1478
+ }
1479
+ const accepted = options.onDetachReceipt?.(receipt) === true;
1480
+ if (accepted) {
1481
+ detachedReason = receipt.detachedReason;
1482
+ if (receipt.detachedReason === "intercom coordination") intercomDetached = true;
1483
+ }
1484
+ return accepted;
1485
+ },
1486
+ };
1458
1487
  let lastResult: SingleResult | undefined;
1459
1488
  const modelsToTry = candidates.length > 0 ? candidates : [undefined];
1460
1489
  modelAttemptsLoop: for (let modelIndex = 0; modelIndex < modelsToTry.length; modelIndex++) {
1461
1490
  const candidate = modelsToTry[modelIndex];
1462
1491
  for (let startupAttemptIndex = 0; ; startupAttemptIndex++) {
1463
1492
  const outputSnapshot = captureSingleOutputSnapshot(options.outputPath);
1464
- const result = await runSingleAttempt(runtimeCwd, agent, taskWithAcceptance, candidate, detachedAwareOptions, {
1493
+ const result = await runSingleAttempt(runtimeCwd, agent, taskWithAcceptance, candidate, attemptOptions, {
1465
1494
  sessionEnabled,
1466
1495
  systemPrompt,
1467
1496
  resolvedSkillNames: resolvedSkills.length > 0 ? resolvedSkills.map((skill) => skill.name) : undefined,
@@ -1493,7 +1522,10 @@ export async function runSync(
1493
1522
  usage: { ...result.usage },
1494
1523
  };
1495
1524
  modelAttempts.push(attempt);
1496
- if (result.detached || result.timedOut || result.turnBudgetExceeded) break modelAttemptsLoop;
1525
+ // Preserve the legacy intercom handoff contract: once this logical run has
1526
+ // been handed to a supervisor, terminating that attempt must not launch a
1527
+ // startup retry or model fallback. Explicit user detach retains fallback.
1528
+ if (intercomDetached || result.timedOut || result.turnBudgetExceeded) break modelAttemptsLoop;
1497
1529
  if (attemptSucceeded) break modelAttemptsLoop;
1498
1530
 
1499
1531
  const startupFailure = isRetryableSubagentStartupFailure({
@@ -1565,6 +1597,7 @@ export async function runSync(
1565
1597
  }
1566
1598
 
1567
1599
  const result = withRunContext(lastResult ?? {
1600
+ index: options.index ?? 0,
1568
1601
  agent: agentName,
1569
1602
  task,
1570
1603
  exitCode: 1,
@@ -1584,25 +1617,30 @@ export async function runSync(
1584
1617
  if (transcriptWriter) result.transcriptPath = artifactPathsResult?.transcriptPath;
1585
1618
  if (transcriptWriter?.getError()) result.transcriptError = transcriptWriter.getError();
1586
1619
 
1587
- if (artifactPathsResult && options.artifactConfig?.enabled !== false) {
1588
- result.artifactPaths = artifactPathsResult;
1589
- if (options.artifactConfig?.includeOutput !== false) {
1590
- writeArtifact(artifactPathsResult.outputPath, formatOutputArtifactContent({
1591
- output: artifactOutputByResult.get(result) ?? result.finalOutput ?? "",
1592
- error: result.error,
1593
- transcriptPath: result.transcriptPath,
1594
- metadataPath: options.artifactConfig?.includeMetadata === false ? undefined : artifactPathsResult.metadataPath,
1595
- }));
1596
- }
1597
- if (options.maxOutput) {
1620
+ try {
1621
+ if (artifactPathsResult && options.artifactConfig?.enabled !== false) {
1622
+ result.artifactPaths = artifactPathsResult;
1623
+ if (options.artifactConfig?.includeOutput !== false) {
1624
+ writeArtifact(artifactPathsResult.outputPath, formatOutputArtifactContent({
1625
+ output: artifactOutputByResult.get(result) ?? result.finalOutput ?? "",
1626
+ error: result.error,
1627
+ transcriptPath: result.transcriptPath,
1628
+ metadataPath: options.artifactConfig?.includeMetadata === false ? undefined : artifactPathsResult.metadataPath,
1629
+ }));
1630
+ }
1631
+ if (options.maxOutput) {
1632
+ const config = { ...DEFAULT_MAX_OUTPUT, ...options.maxOutput };
1633
+ const truncationResult = truncateOutput(result.finalOutput ?? "", config, artifactPathsResult.outputPath);
1634
+ if (truncationResult.truncated) result.truncation = truncationResult;
1635
+ }
1636
+ } else if (options.maxOutput) {
1598
1637
  const config = { ...DEFAULT_MAX_OUTPUT, ...options.maxOutput };
1599
- const truncationResult = truncateOutput(result.finalOutput ?? "", config, artifactPathsResult.outputPath);
1638
+ const truncationResult = truncateOutput(result.finalOutput ?? "", config);
1600
1639
  if (truncationResult.truncated) result.truncation = truncationResult;
1601
1640
  }
1602
- } else if (options.maxOutput) {
1603
- const config = { ...DEFAULT_MAX_OUTPUT, ...options.maxOutput };
1604
- const truncationResult = truncateOutput(result.finalOutput ?? "", config);
1605
- if (truncationResult.truncated) result.truncation = truncationResult;
1641
+ } catch (error) {
1642
+ const message = `Artifact output post-processing failed: ${error instanceof Error ? error.message : String(error)}`;
1643
+ result.outputSaveError = result.outputSaveError ? `${result.outputSaveError}\n${message}` : message;
1606
1644
  }
1607
1645
 
1608
1646
  if (options.sessionFile && (existsSync(options.sessionFile) || result.messages?.length)) {
@@ -1615,28 +1653,36 @@ export async function runSync(
1615
1653
  const childWrittenOutput = options.outputPath
1616
1654
  ? extractChildWrittenOutput(result.messages, options.outputPath, options.cwd ?? runtimeCwd)
1617
1655
  : undefined;
1618
- if (result.detached) {
1619
- result.acceptance = buildPendingAcceptanceLedger(effectiveAcceptance);
1620
- } else if (result.stopped) {
1621
- result.acceptance = buildSkippedAcceptanceLedger(effectiveAcceptance, { id: "stopped", message: "Acceptance was not evaluated because the subagent was stopped." });
1622
- } else if (result.timedOut) {
1623
- result.acceptance = buildSkippedAcceptanceLedger(effectiveAcceptance, { id: "timeout", message: "Acceptance was not evaluated because the subagent timed out." });
1624
- } else if (result.turnBudgetExceeded) {
1625
- result.acceptance = buildSkippedAcceptanceLedger(effectiveAcceptance, { id: "turn-budget", message: "Acceptance was not evaluated because the subagent exceeded its turn budget." });
1626
- } else {
1627
- result.acceptance = await evaluateAcceptance({
1628
- acceptance: effectiveAcceptance,
1629
- output: acceptanceOutputByResult.get(result) ?? result.finalOutput ?? "",
1630
- fileOutput: childWrittenOutput !== undefined && options.outputPath
1631
- ? { content: childWrittenOutput, path: options.outputPath, authoritative: options.outputMode === "file-only" }
1632
- : undefined,
1633
- cwd: options.cwd ?? runtimeCwd,
1634
- reportOptional: isAgentContractV1(options.agentContract),
1635
- });
1656
+ try {
1657
+ if (result.interrupted && detachedReason === "user request") {
1658
+ // Only an accepted user-detach receipt needs a non-rejecting terminal
1659
+ // ledger. Attached and background execution retain their baseline
1660
+ // acceptance behavior.
1661
+ result.acceptance = buildInterruptedAcceptanceLedger(effectiveAcceptance);
1662
+ } else if (result.stopped) {
1663
+ result.acceptance = buildSkippedAcceptanceLedger(effectiveAcceptance, { id: "stopped", message: "Acceptance was not evaluated because the subagent was stopped." });
1664
+ } else if (result.timedOut) {
1665
+ result.acceptance = buildSkippedAcceptanceLedger(effectiveAcceptance, { id: "timeout", message: "Acceptance was not evaluated because the subagent timed out." });
1666
+ } else if (result.turnBudgetExceeded) {
1667
+ result.acceptance = buildSkippedAcceptanceLedger(effectiveAcceptance, { id: "turn-budget", message: "Acceptance was not evaluated because the subagent exceeded its turn budget." });
1668
+ } else {
1669
+ result.acceptance = await evaluateAcceptance({
1670
+ acceptance: effectiveAcceptance,
1671
+ output: acceptanceOutputByResult.get(result) ?? result.finalOutput ?? "",
1672
+ fileOutput: childWrittenOutput !== undefined && options.outputPath
1673
+ ? { content: childWrittenOutput, path: options.outputPath, authoritative: options.outputMode === "file-only" }
1674
+ : undefined,
1675
+ cwd: options.cwd ?? runtimeCwd,
1676
+ reportOptional: isAgentContractV1(options.agentContract),
1677
+ });
1678
+ }
1679
+ } catch (error) {
1680
+ const message = `Acceptance evaluation failed: ${error instanceof Error ? error.message : String(error)}`;
1681
+ result.acceptance = buildSkippedAcceptanceLedger(effectiveAcceptance, { id: "acceptance-evaluation", message });
1636
1682
  }
1637
1683
  const acceptanceFailure = acceptanceFailureMessage(result.acceptance);
1638
1684
  stripAcceptanceReportsFromMessages(result.messages);
1639
- if (acceptanceFailure && result.acceptance.explicit && result.exitCode === 0 && !result.detached && !result.interrupted && !result.timedOut && !isAgentContractV1(options.agentContract)) {
1685
+ if (acceptanceFailure && result.acceptance.explicit && result.exitCode === 0 && !result.interrupted && !result.timedOut && !isAgentContractV1(options.agentContract)) {
1640
1686
  result.exitCode = 1;
1641
1687
  result.error = result.error ? `${result.error}\n${acceptanceFailure}` : acceptanceFailure;
1642
1688
  if (result.progress) {
@@ -1645,7 +1691,143 @@ export async function runSync(
1645
1691
  }
1646
1692
  }
1647
1693
  if (isAgentContractV1(options.agentContract)) attachContractProjections(result);
1648
- persistResultMetadata(result);
1694
+ try {
1695
+ persistResultMetadata(result);
1696
+ } catch (error) {
1697
+ result.metadataSaveError = error instanceof Error ? error.message : String(error);
1698
+ }
1649
1699
 
1650
1700
  return result;
1651
1701
  }
1702
+
1703
+ /**
1704
+ * Runs the authoritative completion pipeline independently from the foreground
1705
+ * receipt. Detachment is same-runtime only: it does not adopt or daemonize work.
1706
+ */
1707
+ export async function runSync(
1708
+ runtimeCwd: string,
1709
+ agents: AgentConfig[],
1710
+ agentName: string,
1711
+ task: string,
1712
+ options: RunSyncOptions,
1713
+ ): Promise<SingleResult> {
1714
+ // Capture the strict contract before consumer-owned objects can be mutated
1715
+ // after a detached receipt is published.
1716
+ const strictContract = isAgentContractV1(options.agentContract);
1717
+ let detachedReason: string | undefined;
1718
+ let publishedReceipt: SingleResult | undefined;
1719
+ let activeDetachAttempt: ((reason?: string) => boolean) | undefined;
1720
+ let detachReadyPublished = false;
1721
+ let resolveReceipt!: (result: SingleResult) => void;
1722
+ const receipt = new Promise<SingleResult>((resolve) => { resolveReceipt = resolve; });
1723
+ const originSignal = options.signal;
1724
+ const originController = new AbortController();
1725
+ const forwardOriginAbort = () => {
1726
+ if (!detachedReason) originController.abort(originSignal?.reason);
1727
+ };
1728
+ if (originSignal?.aborted) forwardOriginAbort();
1729
+ else originSignal?.addEventListener("abort", forwardOriginAbort, { once: true });
1730
+
1731
+ const completion = runSyncCompletion(runtimeCwd, agents, agentName, task, {
1732
+ ...options,
1733
+ signal: originController.signal,
1734
+ onDetachedExit: undefined,
1735
+ onDetachReceipt: (detachedReceipt) => {
1736
+ if (detachedReason || publishedReceipt) return false;
1737
+ // Keep the authoritative result, fallback snapshot, and caller receipt
1738
+ // separately owned while the completion pipeline remains live.
1739
+ publishedReceipt = structuredClone(detachedReceipt);
1740
+ const callerReceipt = structuredClone(detachedReceipt);
1741
+ // A strict contract was already validated before detach; normalize private
1742
+ // and caller snapshots to the only safely known version.
1743
+ publishedReceipt.agentContract = strictContract ? { version: 1 } : undefined;
1744
+ callerReceipt.agentContract = strictContract ? { version: 1 } : undefined;
1745
+ detachedReason = detachedReceipt.detachedReason ?? "user request";
1746
+ resolveReceipt(callerReceipt);
1747
+ return true;
1748
+ },
1749
+ onDetachReady: (detachAttempt) => {
1750
+ activeDetachAttempt = detachAttempt;
1751
+ if (detachReadyPublished) return;
1752
+ detachReadyPublished = true;
1753
+ options.onDetachReady?.((reason = "user request") => {
1754
+ if (detachedReason || originController.signal.aborted) return false;
1755
+ return activeDetachAttempt?.(reason) ?? false;
1756
+ });
1757
+ },
1758
+ });
1759
+
1760
+ const authoritativeCompletion = completion.catch((error: unknown) => {
1761
+ if (!publishedReceipt || !detachedReason) throw error;
1762
+ const message = error instanceof Error ? error.message : String(error);
1763
+ const failureMessage = `Detached completion pipeline failed after receipt: ${message}`;
1764
+ const failedProgress: AgentProgress = publishedReceipt.progress
1765
+ ? { ...publishedReceipt.progress, status: "failed", error: failureMessage }
1766
+ : {
1767
+ index: options.index ?? 0,
1768
+ agent: publishedReceipt.agent,
1769
+ status: "failed",
1770
+ task: publishedReceipt.task,
1771
+ recentTools: [],
1772
+ recentOutput: [],
1773
+ toolCount: publishedReceipt.progressSummary?.toolCount ?? 0,
1774
+ tokens: publishedReceipt.progressSummary?.tokens ?? 0,
1775
+ durationMs: publishedReceipt.progressSummary?.durationMs ?? 0,
1776
+ error: failureMessage,
1777
+ };
1778
+ const failedResult: SingleResult = {
1779
+ ...publishedReceipt,
1780
+ detached: undefined,
1781
+ detachedReason,
1782
+ exitCode: 1,
1783
+ error: failureMessage,
1784
+ finalOutput: failureMessage,
1785
+ progress: failedProgress,
1786
+ progressSummary: {
1787
+ toolCount: failedProgress.toolCount,
1788
+ tokens: failedProgress.tokens,
1789
+ durationMs: failedProgress.durationMs,
1790
+ },
1791
+ acceptance: publishedReceipt.acceptance
1792
+ ? buildSkippedAcceptanceLedger(publishedReceipt.acceptance.effectiveAcceptance, { id: "completion-pipeline", message: failureMessage })
1793
+ : undefined,
1794
+ };
1795
+ if (strictContract) attachContractProjections(failedResult);
1796
+ try {
1797
+ // Replace the provisional detach receipt metadata with the authoritative
1798
+ // terminal failure. Persistence remains best-effort and cannot orphan work.
1799
+ persistSingleResultMetadata({
1800
+ metadataPath: failedResult.artifactPaths?.metadataPath,
1801
+ enabled: options.artifactConfig?.enabled !== false && options.artifactConfig?.includeMetadata !== false,
1802
+ runId: options.runId,
1803
+ agent: failedResult.agent,
1804
+ task: failedResult.task,
1805
+ result: failedResult,
1806
+ });
1807
+ } catch (metadataError) {
1808
+ failedResult.metadataSaveError = metadataError instanceof Error ? metadataError.message : String(metadataError);
1809
+ }
1810
+ return failedResult;
1811
+ });
1812
+
1813
+ let terminalCallbackInvoked = false;
1814
+ void authoritativeCompletion.then((terminalResult) => {
1815
+ if (!detachedReason || terminalCallbackInvoked) return;
1816
+ terminalCallbackInvoked = true;
1817
+ terminalResult.detached = undefined;
1818
+ terminalResult.detachedReason = detachedReason;
1819
+ try {
1820
+ options.onDetachedExit?.(terminalResult);
1821
+ } catch {
1822
+ // The authoritative result has settled. Consumer callback failures are
1823
+ // contained here; each consumer owns cleanup through its own finally block.
1824
+ }
1825
+ }).catch(() => {
1826
+ // Attached completion rejection remains observable through the returned
1827
+ // promise without becoming an unhandled side-channel rejection.
1828
+ }).finally(() => {
1829
+ originSignal?.removeEventListener("abort", forwardOriginAbort);
1830
+ });
1831
+
1832
+ return Promise.race([authoritativeCompletion, receipt]);
1833
+ }