pi-subagents 0.63.0 → 0.65.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 (107) hide show
  1. package/CHANGELOG.md +57 -1
  2. package/README.md +2 -2
  3. package/agents/reviewer.md +1 -1
  4. package/agents/scout.md +1 -1
  5. package/docs/agents.md +18 -16
  6. package/docs/configuration.md +7 -15
  7. package/docs/extension-api.md +15 -6
  8. package/docs/missions.md +2 -0
  9. package/docs/observability.md +10 -12
  10. package/docs/tool-reference.md +7 -6
  11. package/docs/watchdog.md +92 -114
  12. package/docs/workflows.md +5 -3
  13. package/package.json +3 -4
  14. package/skills/pi-subagents/references/constraints-and-recipes.md +1 -1
  15. package/skills/pi-subagents/references/execution-controls.md +6 -5
  16. package/src/agents/agent-management.ts +47 -15
  17. package/src/api/capability-ceiling.ts +0 -1
  18. package/src/api/{pi-args.ts → child-tool-plan.ts} +1 -1
  19. package/src/api/preflight.ts +2 -3
  20. package/src/extension/doctor.ts +2 -10
  21. package/src/extension/fanout-child.ts +9 -11
  22. package/src/extension/index.ts +27 -5
  23. package/src/extension/public-execution.ts +14 -0
  24. package/src/extension/rpc.ts +3 -2
  25. package/src/extension/schemas.ts +2 -1
  26. package/src/extension/tool-description.ts +9 -8
  27. package/src/intercom/native-supervisor-channel.ts +138 -60
  28. package/src/intercom/supervisor-ui.ts +243 -0
  29. package/src/runs/background/async-execution.ts +46 -24
  30. package/src/runs/background/async-job-tracker.ts +11 -0
  31. package/src/runs/background/async-resume.ts +11 -2
  32. package/src/runs/background/control-channel.ts +2 -204
  33. package/src/runs/background/notify.ts +41 -2
  34. package/src/runs/background/process-terminal.ts +1 -1
  35. package/src/runs/background/run-child-session.ts +613 -0
  36. package/src/runs/background/run-status.ts +0 -1
  37. package/src/runs/background/runner-aliases.ts +125 -0
  38. package/src/runs/background/runner-child-sessions.ts +31 -0
  39. package/src/runs/background/scheduled-runs.ts +18 -4
  40. package/src/runs/background/subagent-runner.ts +203 -898
  41. package/src/runs/foreground/async-steering-action.ts +1 -17
  42. package/src/runs/foreground/execution.ts +196 -378
  43. package/src/runs/foreground/foreground-control.ts +4 -0
  44. package/src/runs/foreground/subagent-executor.ts +121 -58
  45. package/src/runs/foreground/workflow-foreground-steering.ts +24 -98
  46. package/src/runs/shared/abort-recovery.ts +3 -3
  47. package/src/runs/shared/acceptance.ts +10 -0
  48. package/src/runs/shared/async-status-projection.ts +11 -43
  49. package/src/runs/shared/capability-ceiling.ts +1 -2
  50. package/src/runs/shared/child-hooks.ts +25 -0
  51. package/src/runs/shared/child-identity.ts +13 -2
  52. package/src/runs/shared/child-launch.ts +314 -0
  53. package/src/runs/shared/child-lifecycle.ts +25 -0
  54. package/src/runs/shared/child-runtime-config.ts +126 -0
  55. package/src/runs/shared/child-session.ts +342 -0
  56. package/src/runs/shared/child-tool-plan.ts +530 -0
  57. package/src/runs/shared/claude-code-adapter.ts +5 -1
  58. package/src/runs/shared/completion-guard.ts +1 -1
  59. package/src/runs/shared/external-cli-preflight.ts +16 -0
  60. package/src/runs/shared/mcp-direct-tool-allowlist.ts +5 -4
  61. package/src/runs/shared/model-exclusions.ts +82 -14
  62. package/src/runs/shared/model-fallback.ts +47 -4
  63. package/src/runs/shared/nested-events.ts +29 -45
  64. package/src/runs/shared/nested-path.ts +0 -14
  65. package/src/runs/shared/orca-progress-tabs.ts +12 -7
  66. package/src/runs/shared/parallel-utils.ts +0 -2
  67. package/src/runs/shared/permissions.ts +0 -13
  68. package/src/runs/shared/process-signal.ts +4 -1
  69. package/src/runs/shared/run-fanout-budget.ts +0 -13
  70. package/src/runs/shared/runtime-acknowledged-extensions.ts +0 -27
  71. package/src/runs/shared/structured-output.ts +17 -4
  72. package/src/runs/shared/subagent-control.ts +6 -2
  73. package/src/runs/shared/subagent-prompt-runtime.ts +87 -384
  74. package/src/runs/shared/tool-availability.ts +18 -62
  75. package/src/runs/shared/tool-budget.ts +0 -14
  76. package/src/runs/shared/worktree-cleanup-plan.ts +25 -6
  77. package/src/runs/shared/worktree.ts +117 -30
  78. package/src/shared/child-session-name.ts +1 -1
  79. package/src/shared/jsonl-writer.ts +11 -0
  80. package/src/shared/thinking-ceiling.ts +0 -6
  81. package/src/shared/types.ts +64 -30
  82. package/src/shared/utils.ts +3 -4
  83. package/src/slash/slash-commands.ts +0 -6
  84. package/src/tui/fleet.ts +0 -1
  85. package/src/tui/render.ts +238 -31
  86. package/src/watchdog/child-status.ts +54 -34
  87. package/src/watchdog/diff-tool.ts +77 -0
  88. package/src/watchdog/emission-guard.ts +5 -3
  89. package/src/watchdog/guidance.ts +20 -0
  90. package/src/watchdog/register-child.ts +28 -25
  91. package/src/watchdog/register-main.ts +10 -9
  92. package/src/watchdog/render.ts +4 -5
  93. package/src/watchdog/review.ts +15 -4
  94. package/src/watchdog/rules.ts +70 -0
  95. package/src/watchdog/runtime.ts +75 -92
  96. package/src/watchdog/scope.ts +0 -11
  97. package/src/watchdog/settings.ts +48 -104
  98. package/src/watchdog/types.ts +18 -32
  99. package/src/watchdog/warning-format.ts +0 -1
  100. package/src/workflows/chat-progress.ts +3 -2
  101. package/src/workflows/scripted-workflow.ts +48 -1
  102. package/src/workflows/workflow-checklist.ts +10 -12
  103. package/src/workflows/workflow-preflight.ts +28 -1
  104. package/src/runs/shared/child-protocol.ts +0 -415
  105. package/src/runs/shared/pi-args.ts +0 -1062
  106. package/src/runs/shared/subagent-startup-retry.ts +0 -116
  107. package/src/shared/post-exit-stdio-guard.ts +0 -85
@@ -2,8 +2,7 @@
2
2
  * Core execution logic for running subagents
3
3
  */
4
4
 
5
- import { spawn } from "node:child_process";
6
- import { existsSync } from "node:fs";
5
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
7
6
  import * as path from "node:path";
8
7
  import type { Message } from "@earendil-works/pi-ai";
9
8
  import { discoverAgents, formatUnknownAgentError, unknownAgentDiagnosticContext, type AgentConfig } from "../../agents/agents.ts";
@@ -31,7 +30,6 @@ import {
31
30
  type AcceptanceLedger,
32
31
  type ResolvedAcceptanceConfig,
33
32
  truncateOutput,
34
- getSubagentDepthEnv,
35
33
  } from "../../shared/types.ts";
36
34
  import {
37
35
  DEFAULT_CONTROL_CONFIG,
@@ -61,21 +59,18 @@ import { evaluateCompletionMutationGuard, expectsImplementationMutation, hasMuta
61
59
  import { planCompletionEvidence } from "../shared/completion-evidence.ts";
62
60
  import { planAbortRecovery } from "../shared/abort-recovery.ts";
63
61
  import { arbitrateCompletionGuardRescue } from "../shared/llm-intent-arbiter.ts";
64
- import { getPiSpawnCommand } from "../shared/pi-spawn.ts";
65
62
  import { preflightLaunchCwd } from "../shared/launch-cwd.ts";
66
63
  import { createJsonlWriter } from "../../shared/jsonl-writer.ts";
67
64
  import { createOrcaProgressTab, type OrcaProgressTab } from "../shared/orca-progress-tabs.ts";
68
- import { attachPostExitStdioGuard, trySignalChild } from "../../shared/post-exit-stdio-guard.ts";
69
65
  import { resolvePermissionRules } from "../shared/permissions.ts";
70
- import { applyThinkingSuffix, buildPiArgs, cleanupTempDir, deriveForkPromptCacheKey, projectLaunchResolvedChildExtensions, resolvePiLaunchToolPlan, type SubagentTaskDelivery } from "../shared/pi-args.ts";
66
+ import { applyThinkingSuffix, deriveForkPromptCacheKey } from "../shared/child-tool-plan.ts";
71
67
  import { deriveChildSessionName } from "../../shared/child-session-name.ts";
72
- import { readRuntimeAcknowledgedExtensions } from "../shared/runtime-acknowledged-extensions.ts";
73
- import { assertAgentAllowedByCapabilityCeiling, decodeSubagentCapabilityCeiling, intersectSubagentCapabilityCeilings, resolveCurrentSubagentCapabilityCeiling, SUBAGENT_CAPABILITY_CEILING_ENV } from "../shared/capability-ceiling.ts";
68
+ import { assertAgentAllowedByCapabilityCeiling, intersectSubagentCapabilityCeilings, resolveCurrentSubagentCapabilityCeiling } from "../shared/capability-ceiling.ts";
74
69
  import { resolveEffectiveThinking } from "../../shared/model-info.ts";
75
- import { assertThinkingWithinCeiling, decodeThinkingCeiling, intersectThinkingCeilings, SUBAGENT_THINKING_CEILING_ENV } from "../../shared/thinking-ceiling.ts";
76
- import { clearStructuredOutputCaptures, MISSING_STRUCTURED_OUTPUT_CALL_ERROR, readStructuredOutput, readStructuredOutputAcceptanceReport } from "../shared/structured-output.ts";
77
- import { formatMidToolExitError, formatProcessSignalError, isOrdinaryToolForMidToolExit, isUnexplainedProcessSignal } from "../shared/process-signal.ts";
78
- import { readChildToolDiagnosticError } from "../shared/tool-availability.ts";
70
+ import { assertThinkingWithinCeiling, intersectThinkingCeilings } from "../../shared/thinking-ceiling.ts";
71
+ import { MISSING_STRUCTURED_ACCEPTANCE_REPORT_ERROR, MISSING_STRUCTURED_OUTPUT_CALL_ERROR } from "../shared/structured-output.ts";
72
+ import { formatMidToolExitError, isOrdinaryToolForMidToolExit } from "../shared/process-signal.ts";
73
+ import { formatChildToolDiagnostic } from "../shared/tool-availability.ts";
79
74
  import { buildTimeoutRecoverySummary, collectTrackedMutationEvidence, snapshotTrackedMutations } from "../shared/mutation-evidence.ts";
80
75
  import { captureSingleOutputSnapshot, extractChildWrittenOutput, finalizeSingleOutput, formatSavedOutputReference, hasSingleOutputChangedSinceSnapshot, injectOutputPathSystemPrompt, resolveSingleOutput, validateFileOnlyOutputMode, type SingleOutputSnapshot } from "../shared/single-output.ts";
81
76
  import {
@@ -86,14 +81,6 @@ import {
86
81
  isRetryableModelFailureAttempt,
87
82
  recordRetryableModelFailure,
88
83
  } from "../shared/model-fallback.ts";
89
- import {
90
- SUBAGENT_STARTUP_RETRY_DELAYS_MS,
91
- formatSubagentExtensionConflictError,
92
- formatSubagentStartupRetryExhaustedError,
93
- formatSubagentStartupRetryNote,
94
- isRetryableSubagentStartupFailure,
95
- waitForSubagentStartupRetry,
96
- } from "../shared/subagent-startup-retry.ts";
97
84
  import {
98
85
  createMutatingFailureState,
99
86
  didMutatingToolFail,
@@ -112,14 +99,18 @@ import { initialToolBudgetState, toolBudgetState } from "../shared/tool-budget.t
112
99
  import { resolveWatchdogConfig } from "../../watchdog/settings.ts";
113
100
  import { agentDefinitionDigest, launchBindingDigest } from "../../shared/launch-contract.ts";
114
101
  import { consumeWorkflowChildPermit } from "../../shared/workflow-child-permit.ts";
115
- import { createBoundedByteTail, createBoundedLineReader, formatProtocolOutputLimit, MAX_CHILD_STDERR_BYTES, PI_AGGREGATE_EVENT_PROJECTOR, projectChildLifecycle, type ChildLifecycleAction, type ChildLifecycleState, type ProtocolOutputLimit } from "../shared/child-protocol.ts";
102
+ import { projectChildLifecycle, type ChildLifecycleAction, type ChildLifecycleState } from "../shared/child-lifecycle.ts";
116
103
  import {
117
104
  acceptChildWatchdogEvent,
105
+ applyChildWatchdogMessage,
118
106
  childWatchdogIsActive,
119
107
  isChildWatchdogStatusEvent,
120
108
  resolveChildWatchdogConfig,
121
109
  type ChildWatchdogStateSnapshot,
110
+ type ChildWatchdogStatusEvent,
122
111
  } from "../../watchdog/child-status.ts";
112
+ import { buildInProcessChildLaunch } from "../shared/child-launch.ts";
113
+ import { childSessionFactory, projectChildSessionEventForJson, type ChildSession, type ChildSessionEvent } from "../shared/child-session.ts";
123
114
 
124
115
  const artifactOutputByResult = new WeakMap<SingleResult, string>();
125
116
  const acceptanceOutputByResult = new WeakMap<SingleResult, string>();
@@ -287,8 +278,8 @@ function snapshotResult(result: SingleResult, progress: AgentProgress): SingleRe
287
278
  /**
288
279
  * Streaming variant of snapshotResult for `onUpdate` progress events: it drops the
289
280
  * unbounded `messages` transcript in favour of compact tool-call summaries so a
290
- * single `tool_execution_update` line stays well under the child-stdout protocol
291
- * cap (`MAX_CHILD_PENDING_LINE_BYTES`). Non-streaming consumers such as
281
+ * single `tool_execution_update` event stays small; the parent records every
282
+ * one in its transcript and `events.jsonl`. Non-streaming consumers such as
292
283
  * foreground detach receipts and terminal consumers use snapshotResult directly.
293
284
  */
294
285
  function snapshotStreamResult(result: SingleResult, progress: AgentProgress): SingleResult {
@@ -350,6 +341,9 @@ function structuredDelegationProgressChanged(
350
341
  const AFTER_COMPACTION_SETTLEMENT = Symbol("afterCompactionSettlement");
351
342
  type AbortRecoverySingleResult = SingleResult & { [AFTER_COMPACTION_SETTLEMENT]?: true };
352
343
 
344
+ const STOPPED_BEFORE_COMPLETION_ERROR = "Subagent stopped before completion.";
345
+
346
+
353
347
  async function runSingleAttempt(
354
348
  runtimeCwd: string,
355
349
  agent: AgentConfig,
@@ -368,7 +362,6 @@ async function runSingleAttempt(
368
362
  attemptNotes: string[];
369
363
  outputSnapshot?: SingleOutputSnapshot;
370
364
  originalTask?: string;
371
- taskDelivery?: SubagentTaskDelivery;
372
365
  orcaProgressTab?: OrcaProgressTab;
373
366
  launchWarnings: { emitted: boolean };
374
367
  verifyModel: boolean;
@@ -379,9 +372,9 @@ async function runSingleAttempt(
379
372
  assertThinkingWithinCeiling({ model: modelArg, configThinking: effectiveThinking, ceiling: options.thinkingCeiling, agent: agent.name, runId: options.runId });
380
373
  const expectedModelForVerification = shared.verifyModel ? modelArg : undefined;
381
374
  const resolvedThinking = resolveEffectiveThinking(modelArg, effectiveThinking);
382
- // Display name for the child session: applied inside the child via
383
- // PI_SUBAGENT_SESSION_NAME and echoed back on the result payload so hosts
384
- // can label this run without reading the child's session file.
375
+ // Display name for the child session: applied inside the child through its
376
+ // runtime config and echoed back on the result payload so hosts can label
377
+ // this run without reading the child's session file.
385
378
  const childSessionName = deriveChildSessionName({ agent: agent.name, task: shared.originalTask ?? task });
386
379
  const watchdogConfig = resolveWatchdogConfig(options.cwd ?? runtimeCwd);
387
380
  const childWatchdog = watchdogConfig.ok
@@ -396,15 +389,12 @@ async function runSingleAttempt(
396
389
  const permissionAuditPath = permissionRules && options.artifactsDir
397
390
  ? path.join(options.artifactsDir, "permission-audit", `${options.runId}-${options.index ?? 0}.jsonl`)
398
391
  : undefined;
399
- const { args, env: sharedEnv, tempDir, toolDiagnosticPath, runtimeAcknowledgedExtensionsPath, capabilityAudit, warnings } = buildPiArgs({
400
- baseArgs: ["--mode", "json", "-p"],
401
- task,
402
- taskDelivery: shared.taskDelivery,
392
+ let onWatchdogStatus: ((event: ChildWatchdogStatusEvent) => void) | undefined;
393
+ const launch = buildInProcessChildLaunch({
403
394
  sessionEnabled: shared.sessionEnabled,
404
395
  sessionDir: options.sessionDir,
405
396
  sessionFile: options.sessionFile,
406
397
  model: modelArg,
407
- thinking: effectiveThinking,
408
398
  systemPromptMode: agent.systemPromptMode,
409
399
  inheritProjectContext: agent.inheritProjectContext,
410
400
  inheritGlobalContext: agent.inheritGlobalContext,
@@ -418,63 +408,40 @@ async function runSingleAttempt(
418
408
  systemPrompt: shared.systemPrompt,
419
409
  mcpDirectTools: agent.mcpDirectTools,
420
410
  cwd: options.cwd ?? runtimeCwd,
421
- promptFileStem: agent.name,
422
411
  intercomSessionName: options.intercomSessionName,
423
412
  sessionName: childSessionName,
424
413
  orchestratorIntercomTarget: options.orchestratorIntercomTarget,
425
414
  runId: options.runId,
426
415
  childAgentName: agent.name,
427
416
  childIndex: options.index ?? 0,
428
- parentEventSink: options.nestedRoute?.eventSink,
429
- parentControlInbox: options.nestedRoute?.controlInbox,
430
- parentRootRunId: options.nestedRoute?.rootRunId,
431
- parentCapabilityToken: options.nestedRoute?.capabilityToken,
417
+ nestedRoute: options.nestedRoute,
432
418
  runFanoutBudget: options.runFanoutBudget,
433
419
  parentSessionId: options.parentSessionId,
434
420
  forkCacheKey: options.context === "fork" ? deriveForkPromptCacheKey(options.parentSessionId) : undefined,
435
- steerInboxDir: options.steerInboxDir,
436
- steerCapabilityPath: options.steerCapabilityPath,
437
- steerAckDir: options.steerAckDir,
438
421
  structuredOutput: options.structuredOutput,
439
422
  fast: options.fast ?? agent.fast,
440
423
  modelCandidates: shared.modelCandidates,
441
424
  toolBudget: options.toolBudget,
442
- allowZeroToolBudget: options.allowZeroToolBudget,
443
425
  permissionRules,
444
426
  permissionAuditPath,
445
427
  childWatchdog,
428
+ watchdogStatus: (event) => onWatchdogStatus?.(event),
446
429
  waitToolEnabled: options.waitToolEnabled,
447
430
  waitToolDefaultTimeoutMs: options.waitToolDefaultTimeoutMs,
448
431
  capabilityCeiling: options.capabilityCeiling,
449
432
  thinkingCeiling: options.thinkingCeiling,
450
- extensionBindings: options.extensionBindings,
433
+ maxSubagentDepth: options.maxSubagentDepth,
451
434
  runtimeSnapshotHost: options.runtimeSnapshotHost,
435
+ inherited: options.childRuntime,
436
+ host: "parent",
452
437
  });
438
+ const { toolPlan, capabilityAudit, warnings, launchResolvedExtensions, capture } = launch;
453
439
  if (!shared.launchWarnings.emitted && warnings.length > 0) {
454
440
  for (const warning of warnings) console.warn(`[pi-subagents] ${warning}`);
455
441
  shared.launchWarnings.emitted = true;
456
442
  }
457
443
 
458
444
  const effectiveSystemPrompt = shared.systemPrompt;
459
- const toolPlan = resolvePiLaunchToolPlan({
460
- tools: agent.tools,
461
- allowNestedSubagents: agent.allowNestedSubagents,
462
- extensions: agent.extensions,
463
- subagentOnlyExtensions: agent.subagentOnlyExtensions,
464
- mcpDirectTools: agent.mcpDirectTools,
465
- cwd: options.cwd ?? runtimeCwd,
466
- requireReadTool: Boolean(shared.resolvedSkillNames?.length),
467
- structuredOutput: Boolean(options.structuredOutput),
468
- excludeTools: agent.excludeTools,
469
- fast: options.fast ?? agent.fast,
470
- model: modelArg,
471
- modelCandidates: shared.modelCandidates,
472
- capabilityCeiling: options.capabilityCeiling,
473
- inheritedCapabilityCeiling: decodeSubagentCapabilityCeiling(process.env[SUBAGENT_CAPABILITY_CEILING_ENV]),
474
- agentName: agent.name,
475
- permissionRules,
476
- runtimeSnapshotHost: options.runtimeSnapshotHost,
477
- });
478
445
  const contractTools = toolPlan.explicitToolAllowlist ? toolPlan.effectiveToolAllowlist : undefined;
479
446
  const contractError = validateImplementationToolContract({
480
447
  agent: agent.name,
@@ -487,7 +454,6 @@ async function runSingleAttempt(
487
454
  completionGuard: agent.completionGuard,
488
455
  });
489
456
  if (contractError) {
490
- cleanupTempDir(tempDir);
491
457
  return {
492
458
  index: options.index ?? 0,
493
459
  agent: agent.name,
@@ -506,7 +472,6 @@ async function runSingleAttempt(
506
472
  ...(toolPlan.capabilityAudit ? { capabilityAudit: toolPlan.capabilityAudit } : {}),
507
473
  };
508
474
  }
509
- const launchResolvedExtensions = projectLaunchResolvedChildExtensions(toolPlan);
510
475
  const launchContractDigest = launchBindingDigest({
511
476
  definitionDigest: agentDefinitionDigest(agent),
512
477
  task: shared.originalTask ?? task,
@@ -553,17 +518,6 @@ async function runSingleAttempt(
553
518
  ...(capabilityAudit ? { capabilityAudit } : {}),
554
519
  }, options.context);
555
520
  const startTime = Date.now();
556
- if (options.structuredOutput) {
557
- const cleanupError = clearStructuredOutputCaptures(options.structuredOutput);
558
- if (cleanupError) {
559
- cleanupTempDir(tempDir);
560
- result.exitCode = 1;
561
- result.error = cleanupError;
562
- result.finalOutput = cleanupError;
563
- result.progressSummary = { toolCount: 0, tokens: 0, durationMs: Date.now() - startTime };
564
- return result;
565
- }
566
- }
567
521
  const controlConfig = options.controlConfig ?? DEFAULT_CONTROL_CONFIG;
568
522
  let interruptedByControl = false;
569
523
  const allControlEvents: ControlEvent[] = [];
@@ -598,7 +552,6 @@ async function runSingleAttempt(
598
552
  result.progress = progress;
599
553
  const attemptTimeout = resolveAttemptTimeout(options);
600
554
  if (attemptTimeout?.remainingMs === 0) {
601
- cleanupTempDir(tempDir);
602
555
  result.exitCode = 1;
603
556
  result.timedOut = true;
604
557
  result.error = attemptTimeout.message;
@@ -612,7 +565,6 @@ async function runSingleAttempt(
612
565
  };
613
566
  return result;
614
567
  }
615
- const spawnEnv = { ...process.env, ...sharedEnv, ...getSubagentDepthEnv(options.maxSubagentDepth) };
616
568
  const mutationSnapshot = snapshotTrackedMutations(options.cwd ?? runtimeCwd);
617
569
  let observedMutationAttempt = false;
618
570
  let structuredOutputToolInvoked = false;
@@ -620,7 +572,6 @@ async function runSingleAttempt(
620
572
  let toolAvailabilityError: string | undefined;
621
573
  let abortedBySignal = options.signal?.aborted === true;
622
574
 
623
- const spawnSpec = getPiSpawnCommand(args);
624
575
  if (options.workflowChildPermitLaunch) {
625
576
  const permitError = consumeWorkflowChildPermit(options.workflowChildPermitLaunch.permit, {
626
577
  workflowRunId: options.workflowChildPermitLaunch.workflowRunId,
@@ -631,7 +582,6 @@ async function runSingleAttempt(
631
582
  runner: "pi",
632
583
  });
633
584
  if (permitError) {
634
- cleanupTempDir(tempDir);
635
585
  result.exitCode = 1;
636
586
  result.error = permitError;
637
587
  result.finalOutput = permitError;
@@ -640,16 +590,13 @@ async function runSingleAttempt(
640
590
  return result;
641
591
  }
642
592
  }
593
+ const childSessions = options.childSessionFactory ?? childSessionFactory();
643
594
  let afterCompactionSettlement = false;
644
595
  const exitCode = await new Promise<number>((resolve) => {
645
- const proc = spawn(spawnSpec.command, spawnSpec.args, {
646
- cwd: options.cwd ?? runtimeCwd,
647
- env: spawnEnv,
648
- stdio: ["ignore", "pipe", "pipe"],
649
- windowsHide: true,
650
- });
651
- const jsonlWriter = createJsonlWriter(shared.jsonlPath, proc.stdout);
652
- let processClosed = false;
596
+ const jsonlWriter = createJsonlWriter(shared.jsonlPath, { pause() {}, resume() {} });
597
+ let session: ChildSession | undefined;
598
+ let unsubscribe: (() => void) | undefined;
599
+ let sessionSettled = false;
653
600
  let lifecycleFinished = false;
654
601
  let detached = false;
655
602
  let intercomStarted = false;
@@ -658,26 +605,26 @@ async function runSingleAttempt(
658
605
  let removeInterruptListener: (() => void) | undefined;
659
606
  let activityTimer: NodeJS.Timeout | undefined;
660
607
  let timeoutTimer: NodeJS.Timeout | undefined;
661
- let timeoutTerminationTimer: NodeJS.Timeout | undefined;
662
- let timeoutHardKillTimer: NodeJS.Timeout | undefined;
663
- let protocolHardKillTimer: NodeJS.Timeout | undefined;
608
+ let timeoutHardFinishTimer: NodeJS.Timeout | undefined;
664
609
  const clearTimeoutTimers = () => {
665
610
  if (timeoutTimer) {
666
611
  clearTimeout(timeoutTimer);
667
612
  timeoutTimer = undefined;
668
613
  }
669
- if (timeoutTerminationTimer) {
670
- clearTimeout(timeoutTerminationTimer);
671
- timeoutTerminationTimer = undefined;
672
- }
673
- if (timeoutHardKillTimer) {
674
- clearTimeout(timeoutHardKillTimer);
675
- timeoutHardKillTimer = undefined;
614
+ if (timeoutHardFinishTimer) {
615
+ clearTimeout(timeoutHardFinishTimer);
616
+ timeoutHardFinishTimer = undefined;
676
617
  }
677
618
  };
619
+ const abortChild = (): void => {
620
+ if (!session || sessionSettled || lifecycleFinished) return;
621
+ void session.abort().catch(() => {
622
+ // The session settles through its prompt promise; abort failures are not separately actionable.
623
+ });
624
+ };
678
625
 
679
626
  const detachForeground = (reason: string): boolean => {
680
- if (detached || processClosed || lifecycleFinished || options.signal?.aborted) return false;
627
+ if (detached || sessionSettled || lifecycleFinished || options.signal?.aborted) return false;
681
628
  const receiptProgress = snapshotProgress(progress);
682
629
  receiptProgress.status = "detached";
683
630
  receiptProgress.durationMs = Date.now() - startTime;
@@ -712,20 +659,21 @@ async function runSingleAttempt(
712
659
  }
713
660
  if (!accepted) return false;
714
661
  detached = true;
662
+ if (session) session.detached = true;
715
663
  return true;
716
664
  };
717
665
 
718
- // If the child emits a terminal assistant stop but never exits,
719
- // give it a short grace period to flush naturally, then clean it up.
666
+ // If the child emits a terminal assistant stop but its run never settles
667
+ // (a hook is stuck), abort it after a short grace period and then finish
668
+ // without it.
720
669
  const FINAL_STOP_GRACE_MS = 1000;
721
- const HARD_KILL_MS = 3000;
722
- let childExited = false;
723
- let forcedTerminationSignal = false;
670
+ const HARD_FINISH_MS = 3000;
671
+ let forcedTermination = false;
724
672
  let cleanTerminalAssistantStopReceived = false;
725
673
  let agentSettledReceived = false;
726
674
  let compactionStartedReceived = false;
727
675
  let finalDrainTimer: NodeJS.Timeout | undefined;
728
- let finalHardKillTimer: NodeJS.Timeout | undefined;
676
+ let finalHardFinishTimer: NodeJS.Timeout | undefined;
729
677
  let watchdogTailTimer: NodeJS.Timeout | undefined;
730
678
  let childWatchdogState: ChildWatchdogStateSnapshot | undefined;
731
679
  const updateChildWatchdogState = (snapshot: ChildWatchdogStateSnapshot): void => {
@@ -744,9 +692,9 @@ async function runSingleAttempt(
744
692
  clearTimeout(finalDrainTimer);
745
693
  finalDrainTimer = undefined;
746
694
  }
747
- if (finalHardKillTimer) {
748
- clearTimeout(finalHardKillTimer);
749
- finalHardKillTimer = undefined;
695
+ if (finalHardFinishTimer) {
696
+ clearTimeout(finalHardFinishTimer);
697
+ finalHardFinishTimer = undefined;
750
698
  }
751
699
  };
752
700
  const startFinalDrain = () => {
@@ -754,32 +702,30 @@ async function runSingleAttempt(
754
702
  armWatchdogTail();
755
703
  return;
756
704
  }
757
- if (childExited || finalDrainTimer || lifecycleFinished || processClosed) return;
705
+ if (sessionSettled || finalDrainTimer || lifecycleFinished) return;
758
706
  finalDrainTimer = setTimeout(() => {
759
- if (lifecycleFinished || processClosed) return;
760
- const termSent = trySignalChild(proc, "SIGTERM");
761
- if (!termSent) return;
762
- forcedTerminationSignal = true;
707
+ if (lifecycleFinished || sessionSettled) return;
708
+ forcedTermination = true;
763
709
  if (!cleanTerminalAssistantStopReceived && !agentSettledReceived && !assistantError) {
764
- result.error = result.error ?? `Subagent process did not exit within ${FINAL_STOP_GRACE_MS}ms after its terminal event. Forcing termination.`;
710
+ result.error = result.error ?? `Subagent session did not settle within ${FINAL_STOP_GRACE_MS}ms after its terminal event. Aborting it.`;
765
711
  }
766
- finalHardKillTimer = setTimeout(() => {
767
- if (lifecycleFinished || processClosed) return;
768
- forcedTerminationSignal = trySignalChild(proc, "SIGKILL") || forcedTerminationSignal;
769
- }, HARD_KILL_MS);
770
- finalHardKillTimer.unref?.();
712
+ abortChild();
713
+ finalHardFinishTimer = setTimeout(() => {
714
+ if (lifecycleFinished || sessionSettled) return;
715
+ settle(undefined, true);
716
+ }, HARD_FINISH_MS);
717
+ finalHardFinishTimer.unref?.();
771
718
  }, FINAL_STOP_GRACE_MS);
772
719
  finalDrainTimer.unref?.();
773
720
  };
774
721
  function armWatchdogTail(): void {
775
- if ((!cleanTerminalAssistantStopReceived && !agentSettledReceived) || watchdogTailTimer || lifecycleFinished || processClosed) return;
722
+ if ((!cleanTerminalAssistantStopReceived && !agentSettledReceived) || watchdogTailTimer || lifecycleFinished || sessionSettled) return;
776
723
  watchdogTailTimer = setTimeout(() => {
777
724
  watchdogTailTimer = undefined;
778
725
  updateChildWatchdogState({
779
726
  phase: "stale",
780
727
  seq: (childWatchdogState?.seq ?? 0) + 1,
781
728
  lastUpdate: Date.now(),
782
- followUpPending: false,
783
729
  reason: "child watchdog tail timeout",
784
730
  timedOut: true,
785
731
  });
@@ -799,7 +745,7 @@ async function runSingleAttempt(
799
745
  const childLifecycleState: ChildLifecycleState = { compactionRetryActive: false };
800
746
 
801
747
  const unsubscribeIntercomDetach = options.intercomEvents?.on?.(INTERCOM_DETACH_REQUEST_EVENT, (payload) => {
802
- if (!options.allowIntercomDetach || processClosed) return;
748
+ if (!options.allowIntercomDetach || sessionSettled) return;
803
749
  if (!payload || typeof payload !== "object") return;
804
750
  const event = payload as { requestId?: unknown; runId?: unknown; agent?: unknown; childIndex?: unknown };
805
751
  const requestId = event.requestId;
@@ -819,13 +765,8 @@ async function runSingleAttempt(
819
765
  lifecycleFinished = true;
820
766
  clearFinalDrainTimers();
821
767
  clearWatchdogTailTimer();
822
- clearStdioGuard();
823
768
  clearTimeoutTimers();
824
769
  clearAllToolTimeouts();
825
- if (protocolHardKillTimer) {
826
- clearTimeout(protocolHardKillTimer);
827
- protocolHardKillTimer = undefined;
828
- }
829
770
  if (activityTimer) {
830
771
  clearInterval(activityTimer);
831
772
  activityTimer = undefined;
@@ -833,7 +774,13 @@ async function runSingleAttempt(
833
774
  unsubscribeIntercomDetach?.();
834
775
  removeAbortListener?.();
835
776
  removeInterruptListener?.();
836
- resolve(code);
777
+ unsubscribe?.();
778
+ onWatchdogStatus = undefined;
779
+ void jsonlWriter.close().catch(() => {
780
+ // JSONL artifact flush is best effort.
781
+ });
782
+ // Report the run only after the child's extensions have shut down.
783
+ void Promise.resolve().then(() => session?.dispose()).catch(() => undefined).then(() => resolve(code));
837
784
  };
838
785
 
839
786
  const drainPendingControlEvents = (): ControlEvent[] | undefined => {
@@ -965,6 +912,7 @@ async function runSingleAttempt(
965
912
  config: controlConfig,
966
913
  startedAt: startTime,
967
914
  lastActivityAt: progress.lastActivityAt,
915
+ turnCount: progress.turnCount,
968
916
  currentTool: progress.currentTool,
969
917
  thinking: resolvedThinking,
970
918
  now,
@@ -995,7 +943,7 @@ async function runSingleAttempt(
995
943
 
996
944
  let lastStructuredDelegationProgressState: StructuredDelegationProgressState | undefined;
997
945
  const emitUpdateSnapshot = (text: string) => {
998
- if (!options.onUpdate || processClosed) return;
946
+ if (!options.onUpdate || sessionSettled) return;
999
947
  const progressSnapshot = snapshotProgress(progress);
1000
948
  const resultSnapshot = snapshotStreamResult(result, progressSnapshot);
1001
949
  const controlEvents = drainPendingControlEvents();
@@ -1011,7 +959,7 @@ async function runSingleAttempt(
1011
959
  };
1012
960
 
1013
961
  const fireUpdate = () => {
1014
- if (!options.onUpdate || processClosed) return;
962
+ if (!options.onUpdate || sessionSettled) return;
1015
963
  progress.durationMs = Date.now() - startTime;
1016
964
  if (options.suppressUnchangedDelegationUpdates) {
1017
965
  if (lastStructuredDelegationProgressState && !structuredDelegationProgressChanged(lastStructuredDelegationProgressState, progress, result)) return;
@@ -1021,20 +969,9 @@ async function runSingleAttempt(
1021
969
  emitUpdateSnapshot(output || "(running...)");
1022
970
  };
1023
971
 
1024
- const rawStdoutTail = createBoundedByteTail();
1025
- const processLine = (line: string) => {
1026
- if (!line.trim()) return;
1027
- jsonlWriter.writeLine(line);
1028
- let evt: { type?: string; message?: Message; toolName?: string; toolCallId?: string; args?: unknown; willRetry?: unknown };
1029
- try {
1030
- evt = JSON.parse(line) as { type?: string; message?: Message; toolName?: string; toolCallId?: string; args?: unknown; willRetry?: unknown };
1031
- } catch {
1032
- rawStdoutTail.push(`${line}\n`);
1033
- shared.transcriptWriter?.writeStdoutLine(line);
1034
- shared.orcaProgressTab?.append(`${line}\n`);
1035
- // Non-JSON stdout lines are expected; only structured events are parsed.
1036
- return;
1037
- }
972
+ const processEvent = (evt: ChildSessionEvent & { message?: Message; toolName?: string; toolCallId?: string; args?: unknown; willRetry?: unknown }) => {
973
+ if (lifecycleFinished) return;
974
+ jsonlWriter.writeLine(JSON.stringify(projectChildSessionEventForJson(evt)));
1038
975
  shared.transcriptWriter?.writeChildEvent(evt);
1039
976
  shared.orcaProgressTab?.event(evt);
1040
977
  if (evt.type === "compaction_start") compactionStartedReceived = true;
@@ -1119,6 +1056,10 @@ async function runSingleAttempt(
1119
1056
 
1120
1057
  if (evt.type === "message_end" && evt.message) {
1121
1058
  result.messages!.push(evt.message);
1059
+ if (childWatchdog) {
1060
+ const next = applyChildWatchdogMessage(childWatchdogState, evt.message);
1061
+ if (next) updateChildWatchdogState(next);
1062
+ }
1122
1063
  if (evt.message.role === "assistant") {
1123
1064
  result.usage.turns++;
1124
1065
  progress.turnCount = result.usage.turns;
@@ -1152,7 +1093,7 @@ async function runSingleAttempt(
1152
1093
  if (evt.message.errorMessage) assistantError = evt.message.errorMessage;
1153
1094
  const assistantText = extractTextFromContent(evt.message.content);
1154
1095
  appendRecentOutput(progress, assistantText.split("\n").slice(-10));
1155
- // Final assistant message: start the exit drain window.
1096
+ // Final assistant message: start the settle drain window.
1156
1097
  if (terminalAssistantStop) {
1157
1098
  if (!evt.message.errorMessage && assistantText.trim()) assistantError = undefined;
1158
1099
  cleanTerminalAssistantStopReceived ||= !evt.message.errorMessage;
@@ -1214,11 +1155,12 @@ async function runSingleAttempt(
1214
1155
  fireUpdate();
1215
1156
  }
1216
1157
  };
1158
+ onWatchdogStatus = (event) => processEvent(event as unknown as Parameters<typeof processEvent>[0]);
1217
1159
 
1218
1160
  fireUpdate();
1219
1161
  if (controlConfig.enabled || options.onUpdate) {
1220
1162
  activityTimer = setInterval(() => {
1221
- if (processClosed || lifecycleFinished) {
1163
+ if (sessionSettled || lifecycleFinished) {
1222
1164
  return;
1223
1165
  }
1224
1166
  updateActivityState(Date.now());
@@ -1229,7 +1171,7 @@ async function runSingleAttempt(
1229
1171
 
1230
1172
  if (attemptTimeout) {
1231
1173
  timeoutTimer = setTimeout(() => {
1232
- if (processClosed || lifecycleFinished || interruptedByControl) return;
1174
+ if (sessionSettled || lifecycleFinished || interruptedByControl) return;
1233
1175
  result.timedOut = true;
1234
1176
  clearAllToolTimeouts();
1235
1177
  result.error = attemptTimeout.message;
@@ -1238,17 +1180,12 @@ async function runSingleAttempt(
1238
1180
  progress.error = attemptTimeout.message;
1239
1181
  progress.durationMs = Date.now() - startTime;
1240
1182
  fireUpdate();
1241
- trySignalChild(proc, "SIGINT");
1242
- timeoutTerminationTimer = setTimeout(() => {
1243
- if (processClosed || lifecycleFinished) return;
1244
- trySignalChild(proc, "SIGTERM");
1245
- }, 1000);
1246
- timeoutTerminationTimer.unref?.();
1247
- timeoutHardKillTimer = setTimeout(() => {
1248
- if (processClosed || lifecycleFinished) return;
1249
- trySignalChild(proc, "SIGKILL");
1183
+ abortChild();
1184
+ timeoutHardFinishTimer = setTimeout(() => {
1185
+ if (sessionSettled || lifecycleFinished) return;
1186
+ settle(undefined, true);
1250
1187
  }, 4000);
1251
- timeoutHardKillTimer.unref?.();
1188
+ timeoutHardFinishTimer.unref?.();
1252
1189
  }, attemptTimeout.remainingMs);
1253
1190
  timeoutTimer.unref?.();
1254
1191
  }
@@ -1256,8 +1193,7 @@ async function runSingleAttempt(
1256
1193
  let toolTimeoutSequence = 0;
1257
1194
  const activeToolTimeouts = new Map<string, { toolName: string; timer: ReturnType<typeof setTimeout> }>();
1258
1195
  const activeToolTimeoutKeysByName = new Map<string, string[]>();
1259
- let toolTimeoutTerminationTimer: ReturnType<typeof setTimeout> | undefined;
1260
- let toolTimeoutHardKillTimer: ReturnType<typeof setTimeout> | undefined;
1196
+ let toolTimeoutHardFinishTimer: ReturnType<typeof setTimeout> | undefined;
1261
1197
  const removeToolTimeoutKey = (key: string): void => {
1262
1198
  const active = activeToolTimeouts.get(key);
1263
1199
  if (!active) return;
@@ -1279,17 +1215,13 @@ async function runSingleAttempt(
1279
1215
  };
1280
1216
  const clearAllToolTimeouts = (): void => {
1281
1217
  for (const key of [...activeToolTimeouts.keys()]) removeToolTimeoutKey(key);
1282
- if (toolTimeoutTerminationTimer) {
1283
- clearTimeout(toolTimeoutTerminationTimer);
1284
- toolTimeoutTerminationTimer = undefined;
1285
- }
1286
- if (toolTimeoutHardKillTimer) {
1287
- clearTimeout(toolTimeoutHardKillTimer);
1288
- toolTimeoutHardKillTimer = undefined;
1218
+ if (toolTimeoutHardFinishTimer) {
1219
+ clearTimeout(toolTimeoutHardFinishTimer);
1220
+ toolTimeoutHardFinishTimer = undefined;
1289
1221
  }
1290
1222
  };
1291
1223
  const terminateForToolTimeout = (message: string): void => {
1292
- if (processClosed || lifecycleFinished || interruptedByControl) return;
1224
+ if (sessionSettled || lifecycleFinished || interruptedByControl) return;
1293
1225
  result.timedOut = true;
1294
1226
  result.error = message;
1295
1227
  result.finalOutput = message;
@@ -1297,17 +1229,12 @@ async function runSingleAttempt(
1297
1229
  progress.error = message;
1298
1230
  progress.durationMs = Date.now() - startTime;
1299
1231
  fireUpdate();
1300
- trySignalChild(proc, "SIGINT");
1301
- toolTimeoutTerminationTimer = setTimeout(() => {
1302
- if (processClosed || lifecycleFinished) return;
1303
- trySignalChild(proc, "SIGTERM");
1304
- }, 1000);
1305
- toolTimeoutTerminationTimer.unref?.();
1306
- toolTimeoutHardKillTimer = setTimeout(() => {
1307
- if (processClosed || lifecycleFinished) return;
1308
- trySignalChild(proc, "SIGKILL");
1232
+ abortChild();
1233
+ toolTimeoutHardFinishTimer = setTimeout(() => {
1234
+ if (sessionSettled || lifecycleFinished) return;
1235
+ settle(undefined, true);
1309
1236
  }, 4000);
1310
- toolTimeoutHardKillTimer.unref?.();
1237
+ toolTimeoutHardFinishTimer.unref?.();
1311
1238
  };
1312
1239
  const armToolTimeout = (event: { toolCallId?: unknown; toolName: string }): void => {
1313
1240
  const timeoutForTool = effectiveToolTimeoutMs(event.toolName, options.toolTimeoutMs);
@@ -1328,111 +1255,42 @@ async function runSingleAttempt(
1328
1255
  activeToolTimeoutKeysByName.set(toolName, keys);
1329
1256
  };
1330
1257
 
1331
- const stderrTail = createBoundedByteTail();
1332
- const failProtocol = (limit: ProtocolOutputLimit): void => {
1333
- if (result.protocolError) return;
1334
- result.protocolError = limit;
1335
- result.error = formatProtocolOutputLimit(limit);
1336
- progress.status = "failed";
1337
- progress.error = result.error;
1338
- progress.durationMs = Date.now() - startTime;
1339
- fireUpdate();
1340
- if (!childExited) {
1341
- trySignalChild(proc, "SIGTERM");
1342
- protocolHardKillTimer = setTimeout(() => {
1343
- if (!childExited) trySignalChild(proc, "SIGKILL");
1344
- }, 3000);
1345
- protocolHardKillTimer.unref?.();
1346
- }
1347
- };
1348
- const stdoutReader = createBoundedLineReader({
1349
- oversizedLineProjector: PI_AGGREGATE_EVENT_PROJECTOR,
1350
- onLine: processLine,
1351
- onLimit: failProtocol,
1352
- });
1353
- const stderrReader = createBoundedLineReader({
1354
- stream: "stderr",
1355
- maxPendingLineBytes: MAX_CHILD_STDERR_BYTES,
1356
- onLine: (line) => shared.transcriptWriter?.writeStderrLine(line),
1357
- onLimit: (limit) => shared.transcriptWriter?.writeStderrLine(formatProtocolOutputLimit(limit)),
1358
- });
1359
-
1360
- const clearStdioGuard = attachPostExitStdioGuard(proc, { idleMs: 2000, hardMs: 8000 });
1361
- proc.stdout.on("data", (chunk: Buffer) => stdoutReader.push(chunk));
1362
- proc.stderr.on("data", (chunk: Buffer) => {
1363
- stderrTail.push(chunk);
1364
- stderrReader.push(chunk);
1365
- shared.orcaProgressTab?.append(chunk.toString("utf-8"));
1366
- });
1367
- proc.on("exit", () => {
1368
- childExited = true;
1369
- clearFinalDrainTimers();
1370
- });
1371
- proc.on("close", (code, signal) => {
1372
- if (lifecycleFinished) return;
1373
- processClosed = true;
1258
+ /** The child run ended (or was forced to end); fold in the captures and finish. */
1259
+ const settle = (promptError: unknown, forced = false): void => {
1260
+ if (lifecycleFinished || sessionSettled) return;
1261
+ sessionSettled = true;
1374
1262
  clearFinalDrainTimers();
1375
- clearStdioGuard();
1376
- void jsonlWriter.close().catch(() => {
1377
- // JSONL artifact flush is best effort.
1378
- });
1379
- const toolDiagnosticError = readChildToolDiagnosticError(toolDiagnosticPath);
1263
+ const diagnostic = capture.toolDiagnostic();
1264
+ const toolDiagnosticError = diagnostic ? formatChildToolDiagnostic(diagnostic, { host: "parent" }) : undefined;
1380
1265
  toolAvailabilityError = toolDiagnosticError;
1381
- result.runtimeAcknowledgedExtensions = readRuntimeAcknowledgedExtensions(runtimeAcknowledgedExtensionsPath);
1382
- cleanupTempDir(tempDir);
1383
- stdoutReader.end();
1384
- stderrReader.end();
1385
- const stderr = stderrTail.text();
1386
- const rawStdout = rawStdoutTail.text();
1266
+ result.runtimeAcknowledgedExtensions = capture.runtimeAcknowledgedExtensions();
1387
1267
  let closeError = result.error ?? toolDiagnosticError ?? assistantError;
1388
- const forcedDrainAfterFinalSuccess = Boolean(forcedTerminationSignal || signal) && (cleanTerminalAssistantStopReceived || agentSettledReceived) && !closeError;
1389
- const forcedDrainAfterEmptyTerminal = forcedDrainAfterFinalSuccess && hasEmptyTerminalAssistantResponse(result.messages ?? []);
1390
- if (signal) result.processSignal = signal;
1391
- if (!closeError && forcedDrainAfterEmptyTerminal && stderr.trim()) {
1392
- closeError = stderr.trim();
1393
- }
1394
- if (!closeError && isUnexplainedProcessSignal({
1395
- processSignal: signal,
1396
- interrupted: result.interrupted,
1397
- timedOut: result.timedOut,
1398
- stopped: result.stopped,
1399
- forcedDrainAfterFinalSuccess: forcedDrainAfterFinalSuccess && !forcedDrainAfterEmptyTerminal,
1400
- })) {
1401
- closeError = formatProcessSignalError(signal!);
1268
+ if (!closeError && promptError !== undefined) {
1269
+ closeError = promptError instanceof Error ? promptError.message : String(promptError);
1402
1270
  }
1403
- if (code !== 0 && rawStdout.trim() && !closeError && !forcedDrainAfterFinalSuccess) {
1404
- closeError = rawStdout.trim();
1271
+ const forcedDrainAfterFinalSuccess = (forced || forcedTermination) && (cleanTerminalAssistantStopReceived || agentSettledReceived) && !closeError;
1272
+ const forcedDrainAfterEmptyTerminal = forcedDrainAfterFinalSuccess && hasEmptyTerminalAssistantResponse(result.messages ?? []);
1273
+ if (!closeError && (abortedBySignal || session?.shutDown) && !result.interrupted && !result.timedOut) {
1274
+ closeError = session?.shutDown ? "Subagent stopped because the parent session shut down." : STOPPED_BEFORE_COMPLETION_ERROR;
1405
1275
  }
1406
- if (code !== 0 && stderr.trim() && !closeError && !forcedDrainAfterFinalSuccess) {
1407
- closeError = stderr.trim();
1276
+ if (!closeError && forced && !forcedDrainAfterFinalSuccess) {
1277
+ closeError = "Subagent session did not settle after it was aborted.";
1408
1278
  }
1409
- const finalCode = forcedDrainAfterFinalSuccess && !forcedDrainAfterEmptyTerminal ? 0 : forcedTerminationSignal || signal ? (code ?? 1) : (code ?? 0);
1279
+ const finalCode = forcedDrainAfterFinalSuccess && !forcedDrainAfterEmptyTerminal ? 0 : closeError || promptError !== undefined ? 1 : 0;
1410
1280
  if (!result.error && closeError) result.error = closeError;
1411
1281
  finish(finalCode);
1412
- });
1413
- proc.on("error", (error) => {
1414
- if (lifecycleFinished) return;
1415
- processClosed = true;
1416
- clearFinalDrainTimers();
1417
- clearStdioGuard();
1418
- void jsonlWriter.close().catch(() => {
1419
- // JSONL artifact flush is best effort.
1420
- });
1421
- cleanupTempDir(tempDir);
1422
- stdoutReader.end();
1423
- stderrReader.end();
1424
- if (!result.error) {
1425
- result.error = error instanceof Error ? error.message : String(error);
1426
- }
1427
- finish(1);
1428
- });
1282
+ };
1429
1283
 
1430
1284
  if (options.signal) {
1431
1285
  const kill = () => {
1432
- if (processClosed || lifecycleFinished) return;
1286
+ if (sessionSettled || lifecycleFinished) return;
1433
1287
  abortedBySignal = true;
1434
- proc.kill("SIGTERM");
1435
- setTimeout(() => !proc.killed && proc.kill("SIGKILL"), 3000);
1288
+ abortChild();
1289
+ const hardTimer = setTimeout(() => {
1290
+ if (sessionSettled || lifecycleFinished) return;
1291
+ settle(undefined, true);
1292
+ }, 3000);
1293
+ hardTimer.unref?.();
1436
1294
  };
1437
1295
  if (options.signal.aborted) kill();
1438
1296
  else {
@@ -1443,7 +1301,7 @@ async function runSingleAttempt(
1443
1301
 
1444
1302
  if (options.interruptSignal) {
1445
1303
  const interrupt = () => {
1446
- if (processClosed || lifecycleFinished) return;
1304
+ if (sessionSettled || lifecycleFinished) return;
1447
1305
  if (result.timedOut) return;
1448
1306
  interruptedByControl = true;
1449
1307
  clearTimeoutTimers();
@@ -1454,11 +1312,12 @@ async function runSingleAttempt(
1454
1312
  result.finalOutput = "Interrupted. Waiting for explicit next action.";
1455
1313
  progress.activityState = undefined;
1456
1314
  fireUpdate();
1457
- trySignalChild(proc, "SIGINT");
1458
- setTimeout(() => {
1459
- if (lifecycleFinished || processClosed) return;
1460
- trySignalChild(proc, "SIGTERM");
1461
- }, 1000).unref?.();
1315
+ abortChild();
1316
+ const hardTimer = setTimeout(() => {
1317
+ if (sessionSettled || lifecycleFinished) return;
1318
+ settle(undefined, true);
1319
+ }, 3000);
1320
+ hardTimer.unref?.();
1462
1321
  };
1463
1322
  if (options.interruptSignal.aborted) interrupt();
1464
1323
  else {
@@ -1473,9 +1332,35 @@ async function runSingleAttempt(
1473
1332
  options.onDetachReady?.((reason = "user request") => detachForeground(reason));
1474
1333
  } catch (error) {
1475
1334
  // A consumer callback is advisory. Keep the child attached and observable
1476
- // rather than rejecting this attempt and orphaning its live process.
1335
+ // rather than rejecting this attempt and orphaning its live session.
1477
1336
  appendRecentOutput(progress, [`Foreground detach callback failed: ${error instanceof Error ? error.message : String(error)}`]);
1478
1337
  }
1338
+
1339
+ void (async () => {
1340
+ try {
1341
+ const created = await childSessions.create({
1342
+ ...launch.session,
1343
+ onExtensionError: (error) => {
1344
+ shared.transcriptWriter?.writeStderrLine(`Extension error (${error.extensionPath}, ${error.event}): ${error.error instanceof Error ? error.error.message : String(error.error)}`);
1345
+ },
1346
+ });
1347
+ if (lifecycleFinished) {
1348
+ void created.dispose();
1349
+ return;
1350
+ }
1351
+ session = created;
1352
+ created.detached = detached;
1353
+ unsubscribe = created.subscribe((event) => processEvent(event as Parameters<typeof processEvent>[0]));
1354
+ if (abortedBySignal || interruptedByControl || result.timedOut) {
1355
+ abortChild();
1356
+ }
1357
+ options.onChildSession?.({ steer: (text) => created.steer(text), followUp: (text) => created.followUp(text) });
1358
+ await created.prompt(`Task: ${task}`);
1359
+ settle(undefined);
1360
+ } catch (error) {
1361
+ settle(error ?? new Error("Child session failed."));
1362
+ }
1363
+ })();
1479
1364
  });
1480
1365
  result.exitCode = exitCode;
1481
1366
  if (afterCompactionSettlement) {
@@ -1501,20 +1386,10 @@ async function runSingleAttempt(
1501
1386
  && !abortedBySignal
1502
1387
  && !result.timedOut
1503
1388
  && !result.stopped
1504
- && !result.protocolError
1505
1389
  && !toolAvailabilityError) {
1506
- const processExitCode = result.exitCode;
1507
1390
  result.exitCode = 1;
1508
- result.error = formatMidToolExitError({
1509
- toolName: progress.currentTool,
1510
- exitCode: processExitCode,
1511
- processSignal: result.processSignal,
1512
- });
1391
+ result.error = formatMidToolExitError({ toolName: progress.currentTool });
1513
1392
  }
1514
- result.error = formatSubagentExtensionConflictError(result.error, {
1515
- agent: agent.name,
1516
- ambientExtensionsEnabled: !launchResolvedExtensions.disableAmbientExtensions,
1517
- });
1518
1393
  if (result.error && result.exitCode === 0) {
1519
1394
  result.exitCode = 1;
1520
1395
  }
@@ -1522,27 +1397,23 @@ async function runSingleAttempt(
1522
1397
  if (options.structuredOutput && result.exitCode === 0 && !result.error) {
1523
1398
  result.structuredOutputSchemaPath = options.structuredOutput.schemaPath;
1524
1399
  result.structuredOutputPath = options.structuredOutput.outputPath;
1525
- if (!structuredOutputToolInvoked) {
1400
+ const structured = capture.structuredOutput();
1401
+ if (!structuredOutputToolInvoked || !structured.called) {
1526
1402
  result.exitCode = 1;
1527
1403
  result.error = MISSING_STRUCTURED_OUTPUT_CALL_ERROR;
1528
1404
  result.structuredOutputFailed = true;
1529
1405
  } else {
1530
- const structured = await readStructuredOutput({
1531
- schema: options.structuredOutput.schema,
1532
- schemaPath: options.structuredOutput.schemaPath,
1533
- outputPath: options.structuredOutput.outputPath,
1534
- });
1535
- if (structured.error) {
1536
- result.exitCode = 1;
1537
- result.error = structured.error;
1538
- result.structuredOutputFailed = true;
1539
- } else {
1540
- result.structuredOutput = structured.value;
1541
- const acceptanceReport = readStructuredOutputAcceptanceReport(options.structuredOutput);
1542
- (result as SingleResult & { structuredAcceptanceReport?: unknown; structuredAcceptanceReportError?: string }).structuredAcceptanceReport = acceptanceReport.value;
1543
- (result as SingleResult & { structuredAcceptanceReport?: unknown; structuredAcceptanceReportError?: string }).structuredAcceptanceReportError = acceptanceReport.error;
1544
- validatedStructuredOutput = true;
1545
- }
1406
+ result.structuredOutput = structured.value;
1407
+ const acceptanceMode = options.structuredOutput.acceptanceReportPath
1408
+ ? options.structuredOutput.acceptanceReportRequired ? "required" : "optional"
1409
+ : undefined;
1410
+ const acceptanceReportError = acceptanceMode === "required" && !structured.acceptanceReportProvided
1411
+ ? MISSING_STRUCTURED_ACCEPTANCE_REPORT_ERROR
1412
+ : undefined;
1413
+ (result as SingleResult & { structuredAcceptanceReport?: unknown; structuredAcceptanceReportError?: string }).structuredAcceptanceReport = acceptanceMode ? structured.acceptanceReport : undefined;
1414
+ (result as SingleResult & { structuredAcceptanceReport?: unknown; structuredAcceptanceReportError?: string }).structuredAcceptanceReportError = acceptanceReportError;
1415
+ writeStructuredOutputArtifacts(options.structuredOutput, structured.value, acceptanceMode ? structured.acceptanceReport : undefined);
1416
+ validatedStructuredOutput = true;
1546
1417
  }
1547
1418
  }
1548
1419
  if (result.exitCode === 0 && !result.error) {
@@ -1595,7 +1466,7 @@ async function runSingleAttempt(
1595
1466
  const outputChanged = hasSingleOutputChangedSinceSnapshot(options.outputPath, shared.outputSnapshot);
1596
1467
  requiredOutputMissing = outputChanged === undefined ? undefined : !outputChanged;
1597
1468
  } else if (options.structuredOutput) {
1598
- requiredOutputMissing = !existsSync(options.structuredOutput.outputPath);
1469
+ requiredOutputMissing = !capture.structuredOutput().called;
1599
1470
  }
1600
1471
  result.timeoutRecovery = buildTimeoutRecoverySummary({
1601
1472
  termination: "timed-out",
@@ -1714,6 +1585,20 @@ async function runSingleAttempt(
1714
1585
  return result;
1715
1586
  }
1716
1587
 
1588
+ /** Keep the structured output artifact files other consumers read by path. */
1589
+ function writeStructuredOutputArtifacts(runtime: NonNullable<RunSyncOptions["structuredOutput"]>, value: unknown, acceptanceReport: unknown): void {
1590
+ try {
1591
+ mkdirSync(path.dirname(runtime.outputPath), { recursive: true });
1592
+ writeFileSync(runtime.outputPath, JSON.stringify(value), { mode: 0o600 });
1593
+ if (runtime.acceptanceReportPath) {
1594
+ if (acceptanceReport !== undefined) writeFileSync(runtime.acceptanceReportPath, JSON.stringify(acceptanceReport), { mode: 0o600 });
1595
+ else rmSync(runtime.acceptanceReportPath, { force: true });
1596
+ }
1597
+ } catch {
1598
+ // The captured value is already on the result; the file is observability only.
1599
+ }
1600
+ }
1601
+
1717
1602
  /**
1718
1603
  * Run a subagent synchronously (blocking until complete)
1719
1604
  */
@@ -1739,7 +1624,7 @@ async function runSyncCompletionInner(
1739
1624
  }
1740
1625
  options = {
1741
1626
  ...options,
1742
- capabilityCeiling: intersectSubagentCapabilityCeilings(options.capabilityCeiling ?? resolveCurrentSubagentCapabilityCeiling(options.parentSessionId), decodeSubagentCapabilityCeiling(process.env[SUBAGENT_CAPABILITY_CEILING_ENV])),
1627
+ capabilityCeiling: intersectSubagentCapabilityCeilings(options.capabilityCeiling ?? resolveCurrentSubagentCapabilityCeiling(options.parentSessionId), options.childRuntime?.capabilityCeiling),
1743
1628
  };
1744
1629
  const childSessionName = deriveChildSessionName({ agent: agentName, task });
1745
1630
  const agent = agents.find((a) => a.name === agentName);
@@ -1761,7 +1646,7 @@ async function runSyncCompletionInner(
1761
1646
  thinkingCeiling: intersectThinkingCeilings(
1762
1647
  options.thinkingCeiling,
1763
1648
  agent.maxThinking,
1764
- decodeThinkingCeiling(process.env[SUBAGENT_THINKING_CEILING_ENV]),
1649
+ options.childRuntime?.thinkingCeiling,
1765
1650
  ),
1766
1651
  };
1767
1652
  try {
@@ -1985,14 +1870,12 @@ async function runSyncCompletionInner(
1985
1870
  };
1986
1871
  let lastResult: SingleResult | undefined;
1987
1872
  const modelsToTry = candidates.length > 0 ? candidates : [undefined];
1988
- // Escalated to "file" after an unexplained zero-activity startup failure so
1989
- // retries keep the task text out of argv (endpoint pre-exec scans may deny it).
1990
- let taskDeliveryOverride: SubagentTaskDelivery | undefined;
1991
1873
  let abortRecoveryAttempted = false;
1992
1874
  let nextAttemptTask = taskWithAcceptance;
1993
1875
  modelAttemptsLoop: for (let modelIndex = 0; modelIndex < modelsToTry.length; modelIndex++) {
1994
1876
  const candidate = modelsToTry[modelIndex];
1995
- for (let startupAttemptIndex = 0; ; startupAttemptIndex++) {
1877
+ // The inner loop re-runs the same candidate at most once, for abort recovery.
1878
+ for (;;) {
1996
1879
  const recoveringAbort = abortRecoveryAttempted;
1997
1880
  const attemptTask = nextAttemptTask;
1998
1881
  const verifyModel = Boolean(candidate) && !(options.modelOverrideFromParent && modelIndex === 0);
@@ -2011,13 +1894,12 @@ async function runSyncCompletionInner(
2011
1894
  .filter((modelCandidate): modelCandidate is string => Boolean(modelCandidate)),
2012
1895
  outputSnapshot,
2013
1896
  originalTask: task,
2014
- taskDelivery: taskDeliveryOverride,
2015
1897
  orcaProgressTab,
2016
1898
  launchWarnings,
2017
1899
  verifyModel,
2018
1900
  });
2019
1901
  lastResult = result;
2020
- if (!recoveringAbort && startupAttemptIndex === 0) {
1902
+ if (!recoveringAbort) {
2021
1903
  if (result.model) attemptedModels.push(result.model);
2022
1904
  else if (candidate) attemptedModels.push(candidate);
2023
1905
  }
@@ -2067,75 +1949,10 @@ async function runSyncCompletionInner(
2067
1949
  if (options.workflowChildPermitLaunch && !attemptSucceeded) break modelAttemptsLoop;
2068
1950
  // Preserve the legacy intercom handoff contract: once this logical run has
2069
1951
  // been handed to a supervisor, terminating that attempt must not launch a
2070
- // startup retry or model fallback. Explicit user detach retains fallback.
1952
+ // model fallback. Explicit user detach retains fallback.
2071
1953
  if (intercomDetached || result.timedOut) break modelAttemptsLoop;
2072
1954
  if (attemptSucceeded) break modelAttemptsLoop;
2073
1955
 
2074
- const startupFailure = isRetryableSubagentStartupFailure({
2075
- exitCode: result.exitCode,
2076
- error: result.error,
2077
- finalOutput: result.finalOutput,
2078
- messageCount: result.messages?.length ?? 0,
2079
- toolCount: result.progressSummary?.toolCount ?? 0,
2080
- usage: result.usage,
2081
- durationMs: result.progressSummary?.durationMs ?? Number.POSITIVE_INFINITY,
2082
- protocolError: result.protocolError,
2083
- processSignal: result.processSignal,
2084
- detached: result.detached,
2085
- interrupted: result.interrupted,
2086
- timedOut: result.timedOut,
2087
- stopped: result.stopped,
2088
- });
2089
- const retryDelayMs = SUBAGENT_STARTUP_RETRY_DELAYS_MS[startupAttemptIndex];
2090
- if (startupFailure && retryDelayMs !== undefined) {
2091
- const retryNote = formatSubagentStartupRetryNote({
2092
- model: attempt.model,
2093
- attempt: startupAttemptIndex + 1,
2094
- maxAttempts: SUBAGENT_STARTUP_RETRY_DELAYS_MS.length + 1,
2095
- delayMs: retryDelayMs,
2096
- });
2097
- const shouldRetry = await waitForSubagentStartupRetry(retryDelayMs, [options.signal, options.interruptSignal]);
2098
- if (!shouldRetry) {
2099
- if (options.interruptSignal?.aborted) {
2100
- result.exitCode = 0;
2101
- result.interrupted = true;
2102
- result.error = undefined;
2103
- result.finalOutput = "Interrupted. Waiting for explicit next action.";
2104
- if (result.progress) {
2105
- result.progress.status = "running";
2106
- result.progress.error = undefined;
2107
- }
2108
- } else {
2109
- const cancellationError = "Subagent startup retry cancelled before relaunch.";
2110
- result.error = cancellationError;
2111
- result.finalOutput = cancellationError;
2112
- attempt.error = cancellationError;
2113
- if (result.progress) result.progress.error = cancellationError;
2114
- }
2115
- break modelAttemptsLoop;
2116
- }
2117
- if (!taskDeliveryOverride && result.processSignal === "SIGKILL") {
2118
- taskDeliveryOverride = "file";
2119
- attemptNotes.push("[startup-retry] retrying with file task delivery to keep the task text out of the child process argv.");
2120
- }
2121
- attempt.error = retryNote;
2122
- attemptNotes.push(retryNote);
2123
- continue;
2124
- }
2125
- if (startupFailure) {
2126
- const startupError = formatSubagentStartupRetryExhaustedError({
2127
- model: attempt.model,
2128
- attempts: startupAttemptIndex + 1,
2129
- });
2130
- result.error = startupError;
2131
- result.finalOutput = startupError;
2132
- if (result.progress) {
2133
- result.progress.error = startupError;
2134
- result.progress.status = "failed";
2135
- }
2136
- attempt.error = startupError;
2137
- break modelAttemptsLoop;
2138
- }
2139
1956
  const retryableModelFailure = isRetryableModelFailureAttempt({ error: result.error, messages: result.messages, toolCount: result.progressSummary?.toolCount });
2140
1957
  if (retryableModelFailure) recordRetryableModelFailure(result.model ?? candidate, result.error);
2141
1958
  if (isContextOverflow(result.error)) {
@@ -2231,6 +2048,7 @@ async function runSyncCompletionInner(
2231
2048
  reportOptional: isAgentContractV1(options.agentContract),
2232
2049
  artifactsDir: options.artifactsDir,
2233
2050
  runId: options.runId,
2051
+ watchdog: result.watchdog,
2234
2052
  });
2235
2053
  }
2236
2054
  } catch (error) {