pi-subagents 0.64.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 (89) hide show
  1. package/CHANGELOG.md +33 -0
  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 +1 -1
  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 +2 -2
  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 +43 -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/process-terminal.ts +1 -1
  34. package/src/runs/background/run-child-session.ts +613 -0
  35. package/src/runs/background/run-status.ts +0 -1
  36. package/src/runs/background/runner-aliases.ts +125 -0
  37. package/src/runs/background/runner-child-sessions.ts +31 -0
  38. package/src/runs/background/scheduled-runs.ts +18 -4
  39. package/src/runs/background/subagent-runner.ts +184 -901
  40. package/src/runs/foreground/async-steering-action.ts +1 -17
  41. package/src/runs/foreground/execution.ts +189 -377
  42. package/src/runs/foreground/foreground-control.ts +4 -0
  43. package/src/runs/foreground/subagent-executor.ts +100 -57
  44. package/src/runs/foreground/workflow-foreground-steering.ts +24 -98
  45. package/src/runs/shared/abort-recovery.ts +3 -3
  46. package/src/runs/shared/capability-ceiling.ts +1 -2
  47. package/src/runs/shared/child-hooks.ts +25 -0
  48. package/src/runs/shared/child-identity.ts +13 -2
  49. package/src/runs/shared/child-launch.ts +314 -0
  50. package/src/runs/shared/child-lifecycle.ts +25 -0
  51. package/src/runs/shared/child-runtime-config.ts +126 -0
  52. package/src/runs/shared/child-session.ts +342 -0
  53. package/src/runs/shared/child-tool-plan.ts +530 -0
  54. package/src/runs/shared/claude-code-adapter.ts +5 -1
  55. package/src/runs/shared/completion-guard.ts +1 -1
  56. package/src/runs/shared/external-cli-preflight.ts +16 -0
  57. package/src/runs/shared/mcp-direct-tool-allowlist.ts +5 -4
  58. package/src/runs/shared/model-exclusions.ts +82 -14
  59. package/src/runs/shared/model-fallback.ts +47 -4
  60. package/src/runs/shared/nested-events.ts +29 -45
  61. package/src/runs/shared/nested-path.ts +0 -14
  62. package/src/runs/shared/orca-progress-tabs.ts +12 -7
  63. package/src/runs/shared/parallel-utils.ts +0 -2
  64. package/src/runs/shared/permissions.ts +0 -13
  65. package/src/runs/shared/process-signal.ts +4 -1
  66. package/src/runs/shared/run-fanout-budget.ts +0 -13
  67. package/src/runs/shared/runtime-acknowledged-extensions.ts +0 -27
  68. package/src/runs/shared/structured-output.ts +17 -4
  69. package/src/runs/shared/subagent-control.ts +2 -0
  70. package/src/runs/shared/subagent-prompt-runtime.ts +87 -384
  71. package/src/runs/shared/tool-availability.ts +18 -62
  72. package/src/runs/shared/tool-budget.ts +0 -14
  73. package/src/runs/shared/worktree-cleanup-plan.ts +25 -6
  74. package/src/runs/shared/worktree.ts +117 -30
  75. package/src/shared/child-session-name.ts +1 -1
  76. package/src/shared/jsonl-writer.ts +11 -0
  77. package/src/shared/thinking-ceiling.ts +0 -6
  78. package/src/shared/types.ts +55 -28
  79. package/src/shared/utils.ts +3 -4
  80. package/src/slash/slash-commands.ts +0 -6
  81. package/src/tui/fleet.ts +0 -1
  82. package/src/tui/render.ts +233 -31
  83. package/src/watchdog/child-status.ts +0 -1
  84. package/src/watchdog/register-child.ts +12 -12
  85. package/src/workflows/scripted-workflow.ts +48 -1
  86. package/src/runs/shared/child-protocol.ts +0 -415
  87. package/src/runs/shared/pi-args.ts +0 -1059
  88. package/src/runs/shared/subagent-startup-retry.ts +0 -116
  89. 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,7 +99,7 @@ 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,
118
105
  applyChildWatchdogMessage,
@@ -120,7 +107,10 @@ import {
120
107
  isChildWatchdogStatusEvent,
121
108
  resolveChildWatchdogConfig,
122
109
  type ChildWatchdogStateSnapshot,
110
+ type ChildWatchdogStatusEvent,
123
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";
124
114
 
125
115
  const artifactOutputByResult = new WeakMap<SingleResult, string>();
126
116
  const acceptanceOutputByResult = new WeakMap<SingleResult, string>();
@@ -288,8 +278,8 @@ function snapshotResult(result: SingleResult, progress: AgentProgress): SingleRe
288
278
  /**
289
279
  * Streaming variant of snapshotResult for `onUpdate` progress events: it drops the
290
280
  * unbounded `messages` transcript in favour of compact tool-call summaries so a
291
- * single `tool_execution_update` line stays well under the child-stdout protocol
292
- * 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
293
283
  * foreground detach receipts and terminal consumers use snapshotResult directly.
294
284
  */
295
285
  function snapshotStreamResult(result: SingleResult, progress: AgentProgress): SingleResult {
@@ -351,6 +341,9 @@ function structuredDelegationProgressChanged(
351
341
  const AFTER_COMPACTION_SETTLEMENT = Symbol("afterCompactionSettlement");
352
342
  type AbortRecoverySingleResult = SingleResult & { [AFTER_COMPACTION_SETTLEMENT]?: true };
353
343
 
344
+ const STOPPED_BEFORE_COMPLETION_ERROR = "Subagent stopped before completion.";
345
+
346
+
354
347
  async function runSingleAttempt(
355
348
  runtimeCwd: string,
356
349
  agent: AgentConfig,
@@ -369,7 +362,6 @@ async function runSingleAttempt(
369
362
  attemptNotes: string[];
370
363
  outputSnapshot?: SingleOutputSnapshot;
371
364
  originalTask?: string;
372
- taskDelivery?: SubagentTaskDelivery;
373
365
  orcaProgressTab?: OrcaProgressTab;
374
366
  launchWarnings: { emitted: boolean };
375
367
  verifyModel: boolean;
@@ -380,9 +372,9 @@ async function runSingleAttempt(
380
372
  assertThinkingWithinCeiling({ model: modelArg, configThinking: effectiveThinking, ceiling: options.thinkingCeiling, agent: agent.name, runId: options.runId });
381
373
  const expectedModelForVerification = shared.verifyModel ? modelArg : undefined;
382
374
  const resolvedThinking = resolveEffectiveThinking(modelArg, effectiveThinking);
383
- // Display name for the child session: applied inside the child via
384
- // PI_SUBAGENT_SESSION_NAME and echoed back on the result payload so hosts
385
- // 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.
386
378
  const childSessionName = deriveChildSessionName({ agent: agent.name, task: shared.originalTask ?? task });
387
379
  const watchdogConfig = resolveWatchdogConfig(options.cwd ?? runtimeCwd);
388
380
  const childWatchdog = watchdogConfig.ok
@@ -397,15 +389,12 @@ async function runSingleAttempt(
397
389
  const permissionAuditPath = permissionRules && options.artifactsDir
398
390
  ? path.join(options.artifactsDir, "permission-audit", `${options.runId}-${options.index ?? 0}.jsonl`)
399
391
  : undefined;
400
- const { args, env: sharedEnv, tempDir, toolDiagnosticPath, runtimeAcknowledgedExtensionsPath, capabilityAudit, warnings } = buildPiArgs({
401
- baseArgs: ["--mode", "json", "-p"],
402
- task,
403
- taskDelivery: shared.taskDelivery,
392
+ let onWatchdogStatus: ((event: ChildWatchdogStatusEvent) => void) | undefined;
393
+ const launch = buildInProcessChildLaunch({
404
394
  sessionEnabled: shared.sessionEnabled,
405
395
  sessionDir: options.sessionDir,
406
396
  sessionFile: options.sessionFile,
407
397
  model: modelArg,
408
- thinking: effectiveThinking,
409
398
  systemPromptMode: agent.systemPromptMode,
410
399
  inheritProjectContext: agent.inheritProjectContext,
411
400
  inheritGlobalContext: agent.inheritGlobalContext,
@@ -419,63 +408,40 @@ async function runSingleAttempt(
419
408
  systemPrompt: shared.systemPrompt,
420
409
  mcpDirectTools: agent.mcpDirectTools,
421
410
  cwd: options.cwd ?? runtimeCwd,
422
- promptFileStem: agent.name,
423
411
  intercomSessionName: options.intercomSessionName,
424
412
  sessionName: childSessionName,
425
413
  orchestratorIntercomTarget: options.orchestratorIntercomTarget,
426
414
  runId: options.runId,
427
415
  childAgentName: agent.name,
428
416
  childIndex: options.index ?? 0,
429
- parentEventSink: options.nestedRoute?.eventSink,
430
- parentControlInbox: options.nestedRoute?.controlInbox,
431
- parentRootRunId: options.nestedRoute?.rootRunId,
432
- parentCapabilityToken: options.nestedRoute?.capabilityToken,
417
+ nestedRoute: options.nestedRoute,
433
418
  runFanoutBudget: options.runFanoutBudget,
434
419
  parentSessionId: options.parentSessionId,
435
420
  forkCacheKey: options.context === "fork" ? deriveForkPromptCacheKey(options.parentSessionId) : undefined,
436
- steerInboxDir: options.steerInboxDir,
437
- steerCapabilityPath: options.steerCapabilityPath,
438
- steerAckDir: options.steerAckDir,
439
421
  structuredOutput: options.structuredOutput,
440
422
  fast: options.fast ?? agent.fast,
441
423
  modelCandidates: shared.modelCandidates,
442
424
  toolBudget: options.toolBudget,
443
- allowZeroToolBudget: options.allowZeroToolBudget,
444
425
  permissionRules,
445
426
  permissionAuditPath,
446
427
  childWatchdog,
428
+ watchdogStatus: (event) => onWatchdogStatus?.(event),
447
429
  waitToolEnabled: options.waitToolEnabled,
448
430
  waitToolDefaultTimeoutMs: options.waitToolDefaultTimeoutMs,
449
431
  capabilityCeiling: options.capabilityCeiling,
450
432
  thinkingCeiling: options.thinkingCeiling,
451
- extensionBindings: options.extensionBindings,
433
+ maxSubagentDepth: options.maxSubagentDepth,
452
434
  runtimeSnapshotHost: options.runtimeSnapshotHost,
435
+ inherited: options.childRuntime,
436
+ host: "parent",
453
437
  });
438
+ const { toolPlan, capabilityAudit, warnings, launchResolvedExtensions, capture } = launch;
454
439
  if (!shared.launchWarnings.emitted && warnings.length > 0) {
455
440
  for (const warning of warnings) console.warn(`[pi-subagents] ${warning}`);
456
441
  shared.launchWarnings.emitted = true;
457
442
  }
458
443
 
459
444
  const effectiveSystemPrompt = shared.systemPrompt;
460
- const toolPlan = resolvePiLaunchToolPlan({
461
- tools: agent.tools,
462
- allowNestedSubagents: agent.allowNestedSubagents,
463
- extensions: agent.extensions,
464
- subagentOnlyExtensions: agent.subagentOnlyExtensions,
465
- mcpDirectTools: agent.mcpDirectTools,
466
- cwd: options.cwd ?? runtimeCwd,
467
- requireReadTool: Boolean(shared.resolvedSkillNames?.length),
468
- structuredOutput: Boolean(options.structuredOutput),
469
- excludeTools: agent.excludeTools,
470
- fast: options.fast ?? agent.fast,
471
- model: modelArg,
472
- modelCandidates: shared.modelCandidates,
473
- capabilityCeiling: options.capabilityCeiling,
474
- inheritedCapabilityCeiling: decodeSubagentCapabilityCeiling(process.env[SUBAGENT_CAPABILITY_CEILING_ENV]),
475
- agentName: agent.name,
476
- permissionRules,
477
- runtimeSnapshotHost: options.runtimeSnapshotHost,
478
- });
479
445
  const contractTools = toolPlan.explicitToolAllowlist ? toolPlan.effectiveToolAllowlist : undefined;
480
446
  const contractError = validateImplementationToolContract({
481
447
  agent: agent.name,
@@ -488,7 +454,6 @@ async function runSingleAttempt(
488
454
  completionGuard: agent.completionGuard,
489
455
  });
490
456
  if (contractError) {
491
- cleanupTempDir(tempDir);
492
457
  return {
493
458
  index: options.index ?? 0,
494
459
  agent: agent.name,
@@ -507,7 +472,6 @@ async function runSingleAttempt(
507
472
  ...(toolPlan.capabilityAudit ? { capabilityAudit: toolPlan.capabilityAudit } : {}),
508
473
  };
509
474
  }
510
- const launchResolvedExtensions = projectLaunchResolvedChildExtensions(toolPlan);
511
475
  const launchContractDigest = launchBindingDigest({
512
476
  definitionDigest: agentDefinitionDigest(agent),
513
477
  task: shared.originalTask ?? task,
@@ -554,17 +518,6 @@ async function runSingleAttempt(
554
518
  ...(capabilityAudit ? { capabilityAudit } : {}),
555
519
  }, options.context);
556
520
  const startTime = Date.now();
557
- if (options.structuredOutput) {
558
- const cleanupError = clearStructuredOutputCaptures(options.structuredOutput);
559
- if (cleanupError) {
560
- cleanupTempDir(tempDir);
561
- result.exitCode = 1;
562
- result.error = cleanupError;
563
- result.finalOutput = cleanupError;
564
- result.progressSummary = { toolCount: 0, tokens: 0, durationMs: Date.now() - startTime };
565
- return result;
566
- }
567
- }
568
521
  const controlConfig = options.controlConfig ?? DEFAULT_CONTROL_CONFIG;
569
522
  let interruptedByControl = false;
570
523
  const allControlEvents: ControlEvent[] = [];
@@ -599,7 +552,6 @@ async function runSingleAttempt(
599
552
  result.progress = progress;
600
553
  const attemptTimeout = resolveAttemptTimeout(options);
601
554
  if (attemptTimeout?.remainingMs === 0) {
602
- cleanupTempDir(tempDir);
603
555
  result.exitCode = 1;
604
556
  result.timedOut = true;
605
557
  result.error = attemptTimeout.message;
@@ -613,7 +565,6 @@ async function runSingleAttempt(
613
565
  };
614
566
  return result;
615
567
  }
616
- const spawnEnv = { ...process.env, ...sharedEnv, ...getSubagentDepthEnv(options.maxSubagentDepth) };
617
568
  const mutationSnapshot = snapshotTrackedMutations(options.cwd ?? runtimeCwd);
618
569
  let observedMutationAttempt = false;
619
570
  let structuredOutputToolInvoked = false;
@@ -621,7 +572,6 @@ async function runSingleAttempt(
621
572
  let toolAvailabilityError: string | undefined;
622
573
  let abortedBySignal = options.signal?.aborted === true;
623
574
 
624
- const spawnSpec = getPiSpawnCommand(args);
625
575
  if (options.workflowChildPermitLaunch) {
626
576
  const permitError = consumeWorkflowChildPermit(options.workflowChildPermitLaunch.permit, {
627
577
  workflowRunId: options.workflowChildPermitLaunch.workflowRunId,
@@ -632,7 +582,6 @@ async function runSingleAttempt(
632
582
  runner: "pi",
633
583
  });
634
584
  if (permitError) {
635
- cleanupTempDir(tempDir);
636
585
  result.exitCode = 1;
637
586
  result.error = permitError;
638
587
  result.finalOutput = permitError;
@@ -641,16 +590,13 @@ async function runSingleAttempt(
641
590
  return result;
642
591
  }
643
592
  }
593
+ const childSessions = options.childSessionFactory ?? childSessionFactory();
644
594
  let afterCompactionSettlement = false;
645
595
  const exitCode = await new Promise<number>((resolve) => {
646
- const proc = spawn(spawnSpec.command, spawnSpec.args, {
647
- cwd: options.cwd ?? runtimeCwd,
648
- env: spawnEnv,
649
- stdio: ["ignore", "pipe", "pipe"],
650
- windowsHide: true,
651
- });
652
- const jsonlWriter = createJsonlWriter(shared.jsonlPath, proc.stdout);
653
- 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;
654
600
  let lifecycleFinished = false;
655
601
  let detached = false;
656
602
  let intercomStarted = false;
@@ -659,26 +605,26 @@ async function runSingleAttempt(
659
605
  let removeInterruptListener: (() => void) | undefined;
660
606
  let activityTimer: NodeJS.Timeout | undefined;
661
607
  let timeoutTimer: NodeJS.Timeout | undefined;
662
- let timeoutTerminationTimer: NodeJS.Timeout | undefined;
663
- let timeoutHardKillTimer: NodeJS.Timeout | undefined;
664
- let protocolHardKillTimer: NodeJS.Timeout | undefined;
608
+ let timeoutHardFinishTimer: NodeJS.Timeout | undefined;
665
609
  const clearTimeoutTimers = () => {
666
610
  if (timeoutTimer) {
667
611
  clearTimeout(timeoutTimer);
668
612
  timeoutTimer = undefined;
669
613
  }
670
- if (timeoutTerminationTimer) {
671
- clearTimeout(timeoutTerminationTimer);
672
- timeoutTerminationTimer = undefined;
673
- }
674
- if (timeoutHardKillTimer) {
675
- clearTimeout(timeoutHardKillTimer);
676
- timeoutHardKillTimer = undefined;
614
+ if (timeoutHardFinishTimer) {
615
+ clearTimeout(timeoutHardFinishTimer);
616
+ timeoutHardFinishTimer = undefined;
677
617
  }
678
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
+ };
679
625
 
680
626
  const detachForeground = (reason: string): boolean => {
681
- if (detached || processClosed || lifecycleFinished || options.signal?.aborted) return false;
627
+ if (detached || sessionSettled || lifecycleFinished || options.signal?.aborted) return false;
682
628
  const receiptProgress = snapshotProgress(progress);
683
629
  receiptProgress.status = "detached";
684
630
  receiptProgress.durationMs = Date.now() - startTime;
@@ -713,20 +659,21 @@ async function runSingleAttempt(
713
659
  }
714
660
  if (!accepted) return false;
715
661
  detached = true;
662
+ if (session) session.detached = true;
716
663
  return true;
717
664
  };
718
665
 
719
- // If the child emits a terminal assistant stop but never exits,
720
- // 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.
721
669
  const FINAL_STOP_GRACE_MS = 1000;
722
- const HARD_KILL_MS = 3000;
723
- let childExited = false;
724
- let forcedTerminationSignal = false;
670
+ const HARD_FINISH_MS = 3000;
671
+ let forcedTermination = false;
725
672
  let cleanTerminalAssistantStopReceived = false;
726
673
  let agentSettledReceived = false;
727
674
  let compactionStartedReceived = false;
728
675
  let finalDrainTimer: NodeJS.Timeout | undefined;
729
- let finalHardKillTimer: NodeJS.Timeout | undefined;
676
+ let finalHardFinishTimer: NodeJS.Timeout | undefined;
730
677
  let watchdogTailTimer: NodeJS.Timeout | undefined;
731
678
  let childWatchdogState: ChildWatchdogStateSnapshot | undefined;
732
679
  const updateChildWatchdogState = (snapshot: ChildWatchdogStateSnapshot): void => {
@@ -745,9 +692,9 @@ async function runSingleAttempt(
745
692
  clearTimeout(finalDrainTimer);
746
693
  finalDrainTimer = undefined;
747
694
  }
748
- if (finalHardKillTimer) {
749
- clearTimeout(finalHardKillTimer);
750
- finalHardKillTimer = undefined;
695
+ if (finalHardFinishTimer) {
696
+ clearTimeout(finalHardFinishTimer);
697
+ finalHardFinishTimer = undefined;
751
698
  }
752
699
  };
753
700
  const startFinalDrain = () => {
@@ -755,25 +702,24 @@ async function runSingleAttempt(
755
702
  armWatchdogTail();
756
703
  return;
757
704
  }
758
- if (childExited || finalDrainTimer || lifecycleFinished || processClosed) return;
705
+ if (sessionSettled || finalDrainTimer || lifecycleFinished) return;
759
706
  finalDrainTimer = setTimeout(() => {
760
- if (lifecycleFinished || processClosed) return;
761
- const termSent = trySignalChild(proc, "SIGTERM");
762
- if (!termSent) return;
763
- forcedTerminationSignal = true;
707
+ if (lifecycleFinished || sessionSettled) return;
708
+ forcedTermination = true;
764
709
  if (!cleanTerminalAssistantStopReceived && !agentSettledReceived && !assistantError) {
765
- 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.`;
766
711
  }
767
- finalHardKillTimer = setTimeout(() => {
768
- if (lifecycleFinished || processClosed) return;
769
- forcedTerminationSignal = trySignalChild(proc, "SIGKILL") || forcedTerminationSignal;
770
- }, HARD_KILL_MS);
771
- finalHardKillTimer.unref?.();
712
+ abortChild();
713
+ finalHardFinishTimer = setTimeout(() => {
714
+ if (lifecycleFinished || sessionSettled) return;
715
+ settle(undefined, true);
716
+ }, HARD_FINISH_MS);
717
+ finalHardFinishTimer.unref?.();
772
718
  }, FINAL_STOP_GRACE_MS);
773
719
  finalDrainTimer.unref?.();
774
720
  };
775
721
  function armWatchdogTail(): void {
776
- if ((!cleanTerminalAssistantStopReceived && !agentSettledReceived) || watchdogTailTimer || lifecycleFinished || processClosed) return;
722
+ if ((!cleanTerminalAssistantStopReceived && !agentSettledReceived) || watchdogTailTimer || lifecycleFinished || sessionSettled) return;
777
723
  watchdogTailTimer = setTimeout(() => {
778
724
  watchdogTailTimer = undefined;
779
725
  updateChildWatchdogState({
@@ -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 => {
@@ -996,7 +943,7 @@ async function runSingleAttempt(
996
943
 
997
944
  let lastStructuredDelegationProgressState: StructuredDelegationProgressState | undefined;
998
945
  const emitUpdateSnapshot = (text: string) => {
999
- if (!options.onUpdate || processClosed) return;
946
+ if (!options.onUpdate || sessionSettled) return;
1000
947
  const progressSnapshot = snapshotProgress(progress);
1001
948
  const resultSnapshot = snapshotStreamResult(result, progressSnapshot);
1002
949
  const controlEvents = drainPendingControlEvents();
@@ -1012,7 +959,7 @@ async function runSingleAttempt(
1012
959
  };
1013
960
 
1014
961
  const fireUpdate = () => {
1015
- if (!options.onUpdate || processClosed) return;
962
+ if (!options.onUpdate || sessionSettled) return;
1016
963
  progress.durationMs = Date.now() - startTime;
1017
964
  if (options.suppressUnchangedDelegationUpdates) {
1018
965
  if (lastStructuredDelegationProgressState && !structuredDelegationProgressChanged(lastStructuredDelegationProgressState, progress, result)) return;
@@ -1022,20 +969,9 @@ async function runSingleAttempt(
1022
969
  emitUpdateSnapshot(output || "(running...)");
1023
970
  };
1024
971
 
1025
- const rawStdoutTail = createBoundedByteTail();
1026
- const processLine = (line: string) => {
1027
- if (!line.trim()) return;
1028
- jsonlWriter.writeLine(line);
1029
- let evt: { type?: string; message?: Message; toolName?: string; toolCallId?: string; args?: unknown; willRetry?: unknown };
1030
- try {
1031
- evt = JSON.parse(line) as { type?: string; message?: Message; toolName?: string; toolCallId?: string; args?: unknown; willRetry?: unknown };
1032
- } catch {
1033
- rawStdoutTail.push(`${line}\n`);
1034
- shared.transcriptWriter?.writeStdoutLine(line);
1035
- shared.orcaProgressTab?.append(`${line}\n`);
1036
- // Non-JSON stdout lines are expected; only structured events are parsed.
1037
- return;
1038
- }
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)));
1039
975
  shared.transcriptWriter?.writeChildEvent(evt);
1040
976
  shared.orcaProgressTab?.event(evt);
1041
977
  if (evt.type === "compaction_start") compactionStartedReceived = true;
@@ -1157,7 +1093,7 @@ async function runSingleAttempt(
1157
1093
  if (evt.message.errorMessage) assistantError = evt.message.errorMessage;
1158
1094
  const assistantText = extractTextFromContent(evt.message.content);
1159
1095
  appendRecentOutput(progress, assistantText.split("\n").slice(-10));
1160
- // Final assistant message: start the exit drain window.
1096
+ // Final assistant message: start the settle drain window.
1161
1097
  if (terminalAssistantStop) {
1162
1098
  if (!evt.message.errorMessage && assistantText.trim()) assistantError = undefined;
1163
1099
  cleanTerminalAssistantStopReceived ||= !evt.message.errorMessage;
@@ -1219,11 +1155,12 @@ async function runSingleAttempt(
1219
1155
  fireUpdate();
1220
1156
  }
1221
1157
  };
1158
+ onWatchdogStatus = (event) => processEvent(event as unknown as Parameters<typeof processEvent>[0]);
1222
1159
 
1223
1160
  fireUpdate();
1224
1161
  if (controlConfig.enabled || options.onUpdate) {
1225
1162
  activityTimer = setInterval(() => {
1226
- if (processClosed || lifecycleFinished) {
1163
+ if (sessionSettled || lifecycleFinished) {
1227
1164
  return;
1228
1165
  }
1229
1166
  updateActivityState(Date.now());
@@ -1234,7 +1171,7 @@ async function runSingleAttempt(
1234
1171
 
1235
1172
  if (attemptTimeout) {
1236
1173
  timeoutTimer = setTimeout(() => {
1237
- if (processClosed || lifecycleFinished || interruptedByControl) return;
1174
+ if (sessionSettled || lifecycleFinished || interruptedByControl) return;
1238
1175
  result.timedOut = true;
1239
1176
  clearAllToolTimeouts();
1240
1177
  result.error = attemptTimeout.message;
@@ -1243,17 +1180,12 @@ async function runSingleAttempt(
1243
1180
  progress.error = attemptTimeout.message;
1244
1181
  progress.durationMs = Date.now() - startTime;
1245
1182
  fireUpdate();
1246
- trySignalChild(proc, "SIGINT");
1247
- timeoutTerminationTimer = setTimeout(() => {
1248
- if (processClosed || lifecycleFinished) return;
1249
- trySignalChild(proc, "SIGTERM");
1250
- }, 1000);
1251
- timeoutTerminationTimer.unref?.();
1252
- timeoutHardKillTimer = setTimeout(() => {
1253
- if (processClosed || lifecycleFinished) return;
1254
- trySignalChild(proc, "SIGKILL");
1183
+ abortChild();
1184
+ timeoutHardFinishTimer = setTimeout(() => {
1185
+ if (sessionSettled || lifecycleFinished) return;
1186
+ settle(undefined, true);
1255
1187
  }, 4000);
1256
- timeoutHardKillTimer.unref?.();
1188
+ timeoutHardFinishTimer.unref?.();
1257
1189
  }, attemptTimeout.remainingMs);
1258
1190
  timeoutTimer.unref?.();
1259
1191
  }
@@ -1261,8 +1193,7 @@ async function runSingleAttempt(
1261
1193
  let toolTimeoutSequence = 0;
1262
1194
  const activeToolTimeouts = new Map<string, { toolName: string; timer: ReturnType<typeof setTimeout> }>();
1263
1195
  const activeToolTimeoutKeysByName = new Map<string, string[]>();
1264
- let toolTimeoutTerminationTimer: ReturnType<typeof setTimeout> | undefined;
1265
- let toolTimeoutHardKillTimer: ReturnType<typeof setTimeout> | undefined;
1196
+ let toolTimeoutHardFinishTimer: ReturnType<typeof setTimeout> | undefined;
1266
1197
  const removeToolTimeoutKey = (key: string): void => {
1267
1198
  const active = activeToolTimeouts.get(key);
1268
1199
  if (!active) return;
@@ -1284,17 +1215,13 @@ async function runSingleAttempt(
1284
1215
  };
1285
1216
  const clearAllToolTimeouts = (): void => {
1286
1217
  for (const key of [...activeToolTimeouts.keys()]) removeToolTimeoutKey(key);
1287
- if (toolTimeoutTerminationTimer) {
1288
- clearTimeout(toolTimeoutTerminationTimer);
1289
- toolTimeoutTerminationTimer = undefined;
1290
- }
1291
- if (toolTimeoutHardKillTimer) {
1292
- clearTimeout(toolTimeoutHardKillTimer);
1293
- toolTimeoutHardKillTimer = undefined;
1218
+ if (toolTimeoutHardFinishTimer) {
1219
+ clearTimeout(toolTimeoutHardFinishTimer);
1220
+ toolTimeoutHardFinishTimer = undefined;
1294
1221
  }
1295
1222
  };
1296
1223
  const terminateForToolTimeout = (message: string): void => {
1297
- if (processClosed || lifecycleFinished || interruptedByControl) return;
1224
+ if (sessionSettled || lifecycleFinished || interruptedByControl) return;
1298
1225
  result.timedOut = true;
1299
1226
  result.error = message;
1300
1227
  result.finalOutput = message;
@@ -1302,17 +1229,12 @@ async function runSingleAttempt(
1302
1229
  progress.error = message;
1303
1230
  progress.durationMs = Date.now() - startTime;
1304
1231
  fireUpdate();
1305
- trySignalChild(proc, "SIGINT");
1306
- toolTimeoutTerminationTimer = setTimeout(() => {
1307
- if (processClosed || lifecycleFinished) return;
1308
- trySignalChild(proc, "SIGTERM");
1309
- }, 1000);
1310
- toolTimeoutTerminationTimer.unref?.();
1311
- toolTimeoutHardKillTimer = setTimeout(() => {
1312
- if (processClosed || lifecycleFinished) return;
1313
- trySignalChild(proc, "SIGKILL");
1232
+ abortChild();
1233
+ toolTimeoutHardFinishTimer = setTimeout(() => {
1234
+ if (sessionSettled || lifecycleFinished) return;
1235
+ settle(undefined, true);
1314
1236
  }, 4000);
1315
- toolTimeoutHardKillTimer.unref?.();
1237
+ toolTimeoutHardFinishTimer.unref?.();
1316
1238
  };
1317
1239
  const armToolTimeout = (event: { toolCallId?: unknown; toolName: string }): void => {
1318
1240
  const timeoutForTool = effectiveToolTimeoutMs(event.toolName, options.toolTimeoutMs);
@@ -1333,111 +1255,42 @@ async function runSingleAttempt(
1333
1255
  activeToolTimeoutKeysByName.set(toolName, keys);
1334
1256
  };
1335
1257
 
1336
- const stderrTail = createBoundedByteTail();
1337
- const failProtocol = (limit: ProtocolOutputLimit): void => {
1338
- if (result.protocolError) return;
1339
- result.protocolError = limit;
1340
- result.error = formatProtocolOutputLimit(limit);
1341
- progress.status = "failed";
1342
- progress.error = result.error;
1343
- progress.durationMs = Date.now() - startTime;
1344
- fireUpdate();
1345
- if (!childExited) {
1346
- trySignalChild(proc, "SIGTERM");
1347
- protocolHardKillTimer = setTimeout(() => {
1348
- if (!childExited) trySignalChild(proc, "SIGKILL");
1349
- }, 3000);
1350
- protocolHardKillTimer.unref?.();
1351
- }
1352
- };
1353
- const stdoutReader = createBoundedLineReader({
1354
- oversizedLineProjector: PI_AGGREGATE_EVENT_PROJECTOR,
1355
- onLine: processLine,
1356
- onLimit: failProtocol,
1357
- });
1358
- const stderrReader = createBoundedLineReader({
1359
- stream: "stderr",
1360
- maxPendingLineBytes: MAX_CHILD_STDERR_BYTES,
1361
- onLine: (line) => shared.transcriptWriter?.writeStderrLine(line),
1362
- onLimit: (limit) => shared.transcriptWriter?.writeStderrLine(formatProtocolOutputLimit(limit)),
1363
- });
1364
-
1365
- const clearStdioGuard = attachPostExitStdioGuard(proc, { idleMs: 2000, hardMs: 8000 });
1366
- proc.stdout.on("data", (chunk: Buffer) => stdoutReader.push(chunk));
1367
- proc.stderr.on("data", (chunk: Buffer) => {
1368
- stderrTail.push(chunk);
1369
- stderrReader.push(chunk);
1370
- shared.orcaProgressTab?.append(chunk.toString("utf-8"));
1371
- });
1372
- proc.on("exit", () => {
1373
- childExited = true;
1374
- clearFinalDrainTimers();
1375
- });
1376
- proc.on("close", (code, signal) => {
1377
- if (lifecycleFinished) return;
1378
- 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;
1379
1262
  clearFinalDrainTimers();
1380
- clearStdioGuard();
1381
- void jsonlWriter.close().catch(() => {
1382
- // JSONL artifact flush is best effort.
1383
- });
1384
- const toolDiagnosticError = readChildToolDiagnosticError(toolDiagnosticPath);
1263
+ const diagnostic = capture.toolDiagnostic();
1264
+ const toolDiagnosticError = diagnostic ? formatChildToolDiagnostic(diagnostic, { host: "parent" }) : undefined;
1385
1265
  toolAvailabilityError = toolDiagnosticError;
1386
- result.runtimeAcknowledgedExtensions = readRuntimeAcknowledgedExtensions(runtimeAcknowledgedExtensionsPath);
1387
- cleanupTempDir(tempDir);
1388
- stdoutReader.end();
1389
- stderrReader.end();
1390
- const stderr = stderrTail.text();
1391
- const rawStdout = rawStdoutTail.text();
1266
+ result.runtimeAcknowledgedExtensions = capture.runtimeAcknowledgedExtensions();
1392
1267
  let closeError = result.error ?? toolDiagnosticError ?? assistantError;
1393
- const forcedDrainAfterFinalSuccess = Boolean(forcedTerminationSignal || signal) && (cleanTerminalAssistantStopReceived || agentSettledReceived) && !closeError;
1394
- const forcedDrainAfterEmptyTerminal = forcedDrainAfterFinalSuccess && hasEmptyTerminalAssistantResponse(result.messages ?? []);
1395
- if (signal) result.processSignal = signal;
1396
- if (!closeError && forcedDrainAfterEmptyTerminal && stderr.trim()) {
1397
- closeError = stderr.trim();
1268
+ if (!closeError && promptError !== undefined) {
1269
+ closeError = promptError instanceof Error ? promptError.message : String(promptError);
1398
1270
  }
1399
- if (!closeError && isUnexplainedProcessSignal({
1400
- processSignal: signal,
1401
- interrupted: result.interrupted,
1402
- timedOut: result.timedOut,
1403
- stopped: result.stopped,
1404
- forcedDrainAfterFinalSuccess: forcedDrainAfterFinalSuccess && !forcedDrainAfterEmptyTerminal,
1405
- })) {
1406
- closeError = formatProcessSignalError(signal!);
1407
- }
1408
- if (code !== 0 && rawStdout.trim() && !closeError && !forcedDrainAfterFinalSuccess) {
1409
- 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;
1410
1275
  }
1411
- if (code !== 0 && stderr.trim() && !closeError && !forcedDrainAfterFinalSuccess) {
1412
- closeError = stderr.trim();
1276
+ if (!closeError && forced && !forcedDrainAfterFinalSuccess) {
1277
+ closeError = "Subagent session did not settle after it was aborted.";
1413
1278
  }
1414
- const finalCode = forcedDrainAfterFinalSuccess && !forcedDrainAfterEmptyTerminal ? 0 : forcedTerminationSignal || signal ? (code ?? 1) : (code ?? 0);
1279
+ const finalCode = forcedDrainAfterFinalSuccess && !forcedDrainAfterEmptyTerminal ? 0 : closeError || promptError !== undefined ? 1 : 0;
1415
1280
  if (!result.error && closeError) result.error = closeError;
1416
1281
  finish(finalCode);
1417
- });
1418
- proc.on("error", (error) => {
1419
- if (lifecycleFinished) return;
1420
- processClosed = true;
1421
- clearFinalDrainTimers();
1422
- clearStdioGuard();
1423
- void jsonlWriter.close().catch(() => {
1424
- // JSONL artifact flush is best effort.
1425
- });
1426
- cleanupTempDir(tempDir);
1427
- stdoutReader.end();
1428
- stderrReader.end();
1429
- if (!result.error) {
1430
- result.error = error instanceof Error ? error.message : String(error);
1431
- }
1432
- finish(1);
1433
- });
1282
+ };
1434
1283
 
1435
1284
  if (options.signal) {
1436
1285
  const kill = () => {
1437
- if (processClosed || lifecycleFinished) return;
1286
+ if (sessionSettled || lifecycleFinished) return;
1438
1287
  abortedBySignal = true;
1439
- proc.kill("SIGTERM");
1440
- 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?.();
1441
1294
  };
1442
1295
  if (options.signal.aborted) kill();
1443
1296
  else {
@@ -1448,7 +1301,7 @@ async function runSingleAttempt(
1448
1301
 
1449
1302
  if (options.interruptSignal) {
1450
1303
  const interrupt = () => {
1451
- if (processClosed || lifecycleFinished) return;
1304
+ if (sessionSettled || lifecycleFinished) return;
1452
1305
  if (result.timedOut) return;
1453
1306
  interruptedByControl = true;
1454
1307
  clearTimeoutTimers();
@@ -1459,11 +1312,12 @@ async function runSingleAttempt(
1459
1312
  result.finalOutput = "Interrupted. Waiting for explicit next action.";
1460
1313
  progress.activityState = undefined;
1461
1314
  fireUpdate();
1462
- trySignalChild(proc, "SIGINT");
1463
- setTimeout(() => {
1464
- if (lifecycleFinished || processClosed) return;
1465
- trySignalChild(proc, "SIGTERM");
1466
- }, 1000).unref?.();
1315
+ abortChild();
1316
+ const hardTimer = setTimeout(() => {
1317
+ if (sessionSettled || lifecycleFinished) return;
1318
+ settle(undefined, true);
1319
+ }, 3000);
1320
+ hardTimer.unref?.();
1467
1321
  };
1468
1322
  if (options.interruptSignal.aborted) interrupt();
1469
1323
  else {
@@ -1478,9 +1332,35 @@ async function runSingleAttempt(
1478
1332
  options.onDetachReady?.((reason = "user request") => detachForeground(reason));
1479
1333
  } catch (error) {
1480
1334
  // A consumer callback is advisory. Keep the child attached and observable
1481
- // rather than rejecting this attempt and orphaning its live process.
1335
+ // rather than rejecting this attempt and orphaning its live session.
1482
1336
  appendRecentOutput(progress, [`Foreground detach callback failed: ${error instanceof Error ? error.message : String(error)}`]);
1483
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
+ })();
1484
1364
  });
1485
1365
  result.exitCode = exitCode;
1486
1366
  if (afterCompactionSettlement) {
@@ -1506,20 +1386,10 @@ async function runSingleAttempt(
1506
1386
  && !abortedBySignal
1507
1387
  && !result.timedOut
1508
1388
  && !result.stopped
1509
- && !result.protocolError
1510
1389
  && !toolAvailabilityError) {
1511
- const processExitCode = result.exitCode;
1512
1390
  result.exitCode = 1;
1513
- result.error = formatMidToolExitError({
1514
- toolName: progress.currentTool,
1515
- exitCode: processExitCode,
1516
- processSignal: result.processSignal,
1517
- });
1391
+ result.error = formatMidToolExitError({ toolName: progress.currentTool });
1518
1392
  }
1519
- result.error = formatSubagentExtensionConflictError(result.error, {
1520
- agent: agent.name,
1521
- ambientExtensionsEnabled: !launchResolvedExtensions.disableAmbientExtensions,
1522
- });
1523
1393
  if (result.error && result.exitCode === 0) {
1524
1394
  result.exitCode = 1;
1525
1395
  }
@@ -1527,27 +1397,23 @@ async function runSingleAttempt(
1527
1397
  if (options.structuredOutput && result.exitCode === 0 && !result.error) {
1528
1398
  result.structuredOutputSchemaPath = options.structuredOutput.schemaPath;
1529
1399
  result.structuredOutputPath = options.structuredOutput.outputPath;
1530
- if (!structuredOutputToolInvoked) {
1400
+ const structured = capture.structuredOutput();
1401
+ if (!structuredOutputToolInvoked || !structured.called) {
1531
1402
  result.exitCode = 1;
1532
1403
  result.error = MISSING_STRUCTURED_OUTPUT_CALL_ERROR;
1533
1404
  result.structuredOutputFailed = true;
1534
1405
  } else {
1535
- const structured = await readStructuredOutput({
1536
- schema: options.structuredOutput.schema,
1537
- schemaPath: options.structuredOutput.schemaPath,
1538
- outputPath: options.structuredOutput.outputPath,
1539
- });
1540
- if (structured.error) {
1541
- result.exitCode = 1;
1542
- result.error = structured.error;
1543
- result.structuredOutputFailed = true;
1544
- } else {
1545
- result.structuredOutput = structured.value;
1546
- const acceptanceReport = readStructuredOutputAcceptanceReport(options.structuredOutput);
1547
- (result as SingleResult & { structuredAcceptanceReport?: unknown; structuredAcceptanceReportError?: string }).structuredAcceptanceReport = acceptanceReport.value;
1548
- (result as SingleResult & { structuredAcceptanceReport?: unknown; structuredAcceptanceReportError?: string }).structuredAcceptanceReportError = acceptanceReport.error;
1549
- validatedStructuredOutput = true;
1550
- }
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;
1551
1417
  }
1552
1418
  }
1553
1419
  if (result.exitCode === 0 && !result.error) {
@@ -1600,7 +1466,7 @@ async function runSingleAttempt(
1600
1466
  const outputChanged = hasSingleOutputChangedSinceSnapshot(options.outputPath, shared.outputSnapshot);
1601
1467
  requiredOutputMissing = outputChanged === undefined ? undefined : !outputChanged;
1602
1468
  } else if (options.structuredOutput) {
1603
- requiredOutputMissing = !existsSync(options.structuredOutput.outputPath);
1469
+ requiredOutputMissing = !capture.structuredOutput().called;
1604
1470
  }
1605
1471
  result.timeoutRecovery = buildTimeoutRecoverySummary({
1606
1472
  termination: "timed-out",
@@ -1719,6 +1585,20 @@ async function runSingleAttempt(
1719
1585
  return result;
1720
1586
  }
1721
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
+
1722
1602
  /**
1723
1603
  * Run a subagent synchronously (blocking until complete)
1724
1604
  */
@@ -1744,7 +1624,7 @@ async function runSyncCompletionInner(
1744
1624
  }
1745
1625
  options = {
1746
1626
  ...options,
1747
- 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),
1748
1628
  };
1749
1629
  const childSessionName = deriveChildSessionName({ agent: agentName, task });
1750
1630
  const agent = agents.find((a) => a.name === agentName);
@@ -1766,7 +1646,7 @@ async function runSyncCompletionInner(
1766
1646
  thinkingCeiling: intersectThinkingCeilings(
1767
1647
  options.thinkingCeiling,
1768
1648
  agent.maxThinking,
1769
- decodeThinkingCeiling(process.env[SUBAGENT_THINKING_CEILING_ENV]),
1649
+ options.childRuntime?.thinkingCeiling,
1770
1650
  ),
1771
1651
  };
1772
1652
  try {
@@ -1990,14 +1870,12 @@ async function runSyncCompletionInner(
1990
1870
  };
1991
1871
  let lastResult: SingleResult | undefined;
1992
1872
  const modelsToTry = candidates.length > 0 ? candidates : [undefined];
1993
- // Escalated to "file" after an unexplained zero-activity startup failure so
1994
- // retries keep the task text out of argv (endpoint pre-exec scans may deny it).
1995
- let taskDeliveryOverride: SubagentTaskDelivery | undefined;
1996
1873
  let abortRecoveryAttempted = false;
1997
1874
  let nextAttemptTask = taskWithAcceptance;
1998
1875
  modelAttemptsLoop: for (let modelIndex = 0; modelIndex < modelsToTry.length; modelIndex++) {
1999
1876
  const candidate = modelsToTry[modelIndex];
2000
- for (let startupAttemptIndex = 0; ; startupAttemptIndex++) {
1877
+ // The inner loop re-runs the same candidate at most once, for abort recovery.
1878
+ for (;;) {
2001
1879
  const recoveringAbort = abortRecoveryAttempted;
2002
1880
  const attemptTask = nextAttemptTask;
2003
1881
  const verifyModel = Boolean(candidate) && !(options.modelOverrideFromParent && modelIndex === 0);
@@ -2016,13 +1894,12 @@ async function runSyncCompletionInner(
2016
1894
  .filter((modelCandidate): modelCandidate is string => Boolean(modelCandidate)),
2017
1895
  outputSnapshot,
2018
1896
  originalTask: task,
2019
- taskDelivery: taskDeliveryOverride,
2020
1897
  orcaProgressTab,
2021
1898
  launchWarnings,
2022
1899
  verifyModel,
2023
1900
  });
2024
1901
  lastResult = result;
2025
- if (!recoveringAbort && startupAttemptIndex === 0) {
1902
+ if (!recoveringAbort) {
2026
1903
  if (result.model) attemptedModels.push(result.model);
2027
1904
  else if (candidate) attemptedModels.push(candidate);
2028
1905
  }
@@ -2072,75 +1949,10 @@ async function runSyncCompletionInner(
2072
1949
  if (options.workflowChildPermitLaunch && !attemptSucceeded) break modelAttemptsLoop;
2073
1950
  // Preserve the legacy intercom handoff contract: once this logical run has
2074
1951
  // been handed to a supervisor, terminating that attempt must not launch a
2075
- // startup retry or model fallback. Explicit user detach retains fallback.
1952
+ // model fallback. Explicit user detach retains fallback.
2076
1953
  if (intercomDetached || result.timedOut) break modelAttemptsLoop;
2077
1954
  if (attemptSucceeded) break modelAttemptsLoop;
2078
1955
 
2079
- const startupFailure = isRetryableSubagentStartupFailure({
2080
- exitCode: result.exitCode,
2081
- error: result.error,
2082
- finalOutput: result.finalOutput,
2083
- messageCount: result.messages?.length ?? 0,
2084
- toolCount: result.progressSummary?.toolCount ?? 0,
2085
- usage: result.usage,
2086
- durationMs: result.progressSummary?.durationMs ?? Number.POSITIVE_INFINITY,
2087
- protocolError: result.protocolError,
2088
- processSignal: result.processSignal,
2089
- detached: result.detached,
2090
- interrupted: result.interrupted,
2091
- timedOut: result.timedOut,
2092
- stopped: result.stopped,
2093
- });
2094
- const retryDelayMs = SUBAGENT_STARTUP_RETRY_DELAYS_MS[startupAttemptIndex];
2095
- if (startupFailure && retryDelayMs !== undefined) {
2096
- const retryNote = formatSubagentStartupRetryNote({
2097
- model: attempt.model,
2098
- attempt: startupAttemptIndex + 1,
2099
- maxAttempts: SUBAGENT_STARTUP_RETRY_DELAYS_MS.length + 1,
2100
- delayMs: retryDelayMs,
2101
- });
2102
- const shouldRetry = await waitForSubagentStartupRetry(retryDelayMs, [options.signal, options.interruptSignal]);
2103
- if (!shouldRetry) {
2104
- if (options.interruptSignal?.aborted) {
2105
- result.exitCode = 0;
2106
- result.interrupted = true;
2107
- result.error = undefined;
2108
- result.finalOutput = "Interrupted. Waiting for explicit next action.";
2109
- if (result.progress) {
2110
- result.progress.status = "running";
2111
- result.progress.error = undefined;
2112
- }
2113
- } else {
2114
- const cancellationError = "Subagent startup retry cancelled before relaunch.";
2115
- result.error = cancellationError;
2116
- result.finalOutput = cancellationError;
2117
- attempt.error = cancellationError;
2118
- if (result.progress) result.progress.error = cancellationError;
2119
- }
2120
- break modelAttemptsLoop;
2121
- }
2122
- if (!taskDeliveryOverride && result.processSignal === "SIGKILL") {
2123
- taskDeliveryOverride = "file";
2124
- attemptNotes.push("[startup-retry] retrying with file task delivery to keep the task text out of the child process argv.");
2125
- }
2126
- attempt.error = retryNote;
2127
- attemptNotes.push(retryNote);
2128
- continue;
2129
- }
2130
- if (startupFailure) {
2131
- const startupError = formatSubagentStartupRetryExhaustedError({
2132
- model: attempt.model,
2133
- attempts: startupAttemptIndex + 1,
2134
- });
2135
- result.error = startupError;
2136
- result.finalOutput = startupError;
2137
- if (result.progress) {
2138
- result.progress.error = startupError;
2139
- result.progress.status = "failed";
2140
- }
2141
- attempt.error = startupError;
2142
- break modelAttemptsLoop;
2143
- }
2144
1956
  const retryableModelFailure = isRetryableModelFailureAttempt({ error: result.error, messages: result.messages, toolCount: result.progressSummary?.toolCount });
2145
1957
  if (retryableModelFailure) recordRetryableModelFailure(result.model ?? candidate, result.error);
2146
1958
  if (isContextOverflow(result.error)) {