pi-subagents 0.34.0 → 0.35.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 (98) hide show
  1. package/CHANGELOG.md +78 -9
  2. package/README.md +213 -32
  3. package/index.ts +1 -0
  4. package/install.mjs +1 -1
  5. package/package.json +23 -8
  6. package/prompts/review-loop.md +3 -1
  7. package/skills/pi-subagents/SKILL.md +87 -25
  8. package/src/agents/agent-management.ts +82 -15
  9. package/src/agents/agent-serializer.ts +19 -0
  10. package/src/agents/agents.ts +91 -49
  11. package/src/agents/frontmatter.ts +67 -13
  12. package/src/agents/skills.ts +25 -12
  13. package/src/api/background-work.ts +197 -0
  14. package/src/api/delegation.ts +158 -0
  15. package/src/extension/chain-validation.ts +165 -0
  16. package/src/extension/doctor.ts +15 -0
  17. package/src/extension/fanout-child.ts +3 -1
  18. package/src/extension/index.ts +65 -124
  19. package/src/extension/rpc.ts +10 -2
  20. package/src/extension/schemas.ts +18 -14
  21. package/src/extension/steering-notices.ts +35 -0
  22. package/src/extension/tool-description.ts +20 -9
  23. package/src/intercom/intercom-bridge.ts +3 -2
  24. package/src/intercom/native-supervisor-channel.ts +9 -1
  25. package/src/intercom/result-intercom.ts +4 -0
  26. package/src/runs/background/async-execution.ts +293 -44
  27. package/src/runs/background/async-job-tracker.ts +56 -9
  28. package/src/runs/background/async-resume.ts +159 -52
  29. package/src/runs/background/async-status.ts +25 -18
  30. package/src/runs/background/auto-drain.ts +67 -0
  31. package/src/runs/background/chain-root-attachment.ts +16 -8
  32. package/src/runs/background/control-channel.ts +260 -13
  33. package/src/runs/background/fleet-view.ts +23 -2
  34. package/src/runs/background/notify.ts +79 -10
  35. package/src/runs/background/result-watcher.ts +12 -9
  36. package/src/runs/background/run-id-resolver.ts +14 -2
  37. package/src/runs/background/run-status.ts +23 -15
  38. package/src/runs/background/scheduled-runs.ts +3 -0
  39. package/src/runs/background/stale-run-reconciler.ts +32 -10
  40. package/src/runs/background/steering.ts +237 -0
  41. package/src/runs/background/subagent-runner.ts +898 -236
  42. package/src/runs/background/subagent-wait.ts +484 -0
  43. package/src/runs/background/top-level-async.ts +2 -1
  44. package/src/runs/background/wait-config.ts +36 -0
  45. package/src/runs/background/wait-tool.ts +26 -0
  46. package/src/runs/foreground/async-steering-action.ts +230 -0
  47. package/src/runs/foreground/chain-clarify.ts +22 -6
  48. package/src/runs/foreground/chain-execution.ts +50 -32
  49. package/src/runs/foreground/execution.ts +308 -94
  50. package/src/runs/foreground/subagent-executor.ts +592 -268
  51. package/src/runs/shared/acceptance.ts +355 -97
  52. package/src/runs/shared/child-protocol.ts +121 -0
  53. package/src/runs/shared/completion-guard.ts +8 -127
  54. package/src/runs/shared/dynamic-fanout.ts +6 -4
  55. package/src/runs/shared/model-fallback.ts +36 -0
  56. package/src/runs/shared/nested-events.ts +9 -4
  57. package/src/runs/shared/nested-render.ts +4 -1
  58. package/src/runs/shared/parallel-utils.ts +7 -0
  59. package/src/runs/shared/pi-args.ts +34 -7
  60. package/src/runs/shared/pi-spawn.ts +18 -12
  61. package/src/runs/shared/session-lease.ts +279 -0
  62. package/src/runs/shared/single-output.ts +61 -6
  63. package/src/runs/shared/spawn-budget.ts +128 -0
  64. package/src/runs/shared/subagent-control.ts +10 -6
  65. package/src/runs/shared/subagent-prompt-runtime.ts +127 -26
  66. package/src/runs/shared/task-intent.ts +176 -0
  67. package/src/runs/shared/tool-availability.ts +65 -0
  68. package/src/runs/shared/turn-budget.ts +49 -4
  69. package/src/shared/atomic-json.ts +4 -1
  70. package/src/shared/fork-context.ts +28 -3
  71. package/src/shared/model-info.ts +7 -4
  72. package/src/shared/status-format.ts +7 -1
  73. package/src/shared/types.ts +203 -25
  74. package/src/shared/utils.ts +35 -7
  75. package/src/slash/delegation-adapters.ts +457 -0
  76. package/src/slash/delegation-request.ts +103 -0
  77. package/src/slash/prompt-template-bridge.ts +167 -344
  78. package/src/slash/slash-commands.ts +239 -6
  79. package/src/slash/subagents-admin.ts +428 -0
  80. package/src/slash/subagents-editor.ts +86 -0
  81. package/src/tui/fleet.ts +405 -0
  82. package/src/tui/render.ts +90 -16
  83. package/src/watchdog/change-signature.ts +127 -0
  84. package/src/watchdog/child-status.ts +205 -0
  85. package/src/watchdog/emission-guard.ts +123 -0
  86. package/src/watchdog/lsp-diagnostics.ts +532 -0
  87. package/src/watchdog/model-selection.ts +167 -0
  88. package/src/watchdog/register-child.ts +117 -0
  89. package/src/watchdog/register-main.ts +433 -0
  90. package/src/watchdog/render.ts +54 -0
  91. package/src/watchdog/review.ts +293 -0
  92. package/src/watchdog/runtime.ts +712 -0
  93. package/src/watchdog/settings.ts +528 -0
  94. package/src/watchdog/tool-actions.ts +155 -0
  95. package/src/watchdog/turn-delta.ts +161 -0
  96. package/src/watchdog/types.ts +188 -0
  97. package/src/watchdog/warning-format.ts +73 -0
  98. package/src/runs/background/wait.ts +0 -394
@@ -5,10 +5,10 @@ import { pathToFileURL } from "node:url";
5
5
  import type { Message } from "@earendil-works/pi-ai";
6
6
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
7
7
  import { createChildTranscriptWriter, type ChildTranscriptWriter } from "../../shared/child-transcript.ts";
8
- import { consumeInterruptRequest, deliverInterruptRequest, deliverTimeoutRequest, enqueueStepSteer, stepSteerInboxDir, watchAsyncControlInbox, type SteerRequest } from "./control-channel.ts";
8
+ import { closeSteerInbox, consumeInterruptRequest, consumeSteerRequests, deliverInterruptRequest, deliverStopRequest, deliverTimeoutRequest, enqueueStepSteer, steerAcksDir, steerCapabilityPath, stepSteerInboxDir, watchAsyncControlInbox, type SteerAck, type SteerCapability, type SteerRequest } from "./control-channel.ts";
9
9
  import { appendJsonl as appendRawJsonl, getArtifactPaths } from "../../shared/artifacts.ts";
10
10
  import { PI_CODING_AGENT_PACKAGE, getPiSpawnCommand, resolveInstalledPiPackageRoot } from "../shared/pi-spawn.ts";
11
- import { captureSingleOutputSnapshot, finalizeSingleOutput, formatSavedOutputReference, resolveSingleOutput, type SingleOutputSnapshot } from "../shared/single-output.ts";
11
+ import { captureSingleOutputSnapshot, extractChildWrittenOutput, finalizeSingleOutput, formatSavedOutputReference, injectOutputPathSystemPrompt, injectSingleOutputInstruction, resolveSingleOutput, type SingleOutputSnapshot } from "../shared/single-output.ts";
12
12
  import {
13
13
  type ActivityState,
14
14
  type ArtifactConfig,
@@ -28,6 +28,9 @@ import {
28
28
  type TurnBudgetState,
29
29
  type Usage,
30
30
  type WorkflowGraphSnapshot,
31
+ type SteeringStatus,
32
+ type SteeringTargetState,
33
+ type SteeringTargetStatus,
31
34
  DEFAULT_MAX_OUTPUT,
32
35
  type MaxOutputConfig,
33
36
  SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
@@ -57,9 +60,11 @@ import {
57
60
  import { applyThinkingSuffix, buildPiArgs, cleanupTempDir } from "../shared/pi-args.ts";
58
61
  import { outputEntryFromAsyncResult, resolveOutputReferences } from "../shared/chain-outputs.ts";
59
62
  import { createStructuredOutputRuntime, readStructuredOutput } from "../shared/structured-output.ts";
63
+ import { readChildToolDiagnosticError } from "../shared/tool-availability.ts";
60
64
  import { collectDynamicResults, DynamicFanoutError, materializeDynamicParallelStep, validateDynamicCollection } from "../shared/dynamic-fanout.ts";
61
65
  import { nestedSummaryFromAsyncStatus, projectNestedEvents, resolveNestedAsyncDir, writeNestedEvent } from "../shared/nested-events.ts";
62
66
  import { formatModelAttemptNote, isRetryableModelFailure } from "../shared/model-fallback.ts";
67
+ import { createSteeringStatus, recordSteeringRequest, steeringStatus, terminalSteeringNoticeState, updateSteeringTarget } from "./steering.ts";
63
68
  import { attachPostExitStdioGuard, trySignalChild } from "../../shared/post-exit-stdio-guard.ts";
64
69
  import { detectSubagentError, extractTextFromContent, extractToolArgsPreview, getFinalOutput, readStatus } from "../../shared/utils.ts";
65
70
  import { evaluateCompletionMutationGuard } from "../shared/completion-guard.ts";
@@ -88,11 +93,23 @@ import {
88
93
  import { resolveEffectiveThinking } from "../../shared/model-info.ts";
89
94
  import { writeInitialProgressFile } from "../../shared/settings.ts";
90
95
  import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts";
91
- import { acceptanceFailureMessage, aggregateAcceptanceReport, evaluateAcceptance, formatAcceptancePrompt, stripAcceptanceReport } from "../shared/acceptance.ts";
96
+ import { acceptanceFailureMessage, aggregateAcceptanceReport, buildSkippedAcceptanceLedger, evaluateAcceptance, formatAcceptancePrompt, resolveEffectiveAcceptance, stripAcceptanceReport } from "../shared/acceptance.ts";
92
97
  import { waitForImportedAsyncRoot } from "./chain-root-attachment.ts";
93
98
  import { appendRunnerStepsToStatus, consumeChainAppendRequests, countPendingChainAppendRequests } from "./chain-append.ts";
94
- import { appendTurnBudgetSystemPrompt, formatTurnBudgetOutput, initialTurnBudgetState, shouldAbortForTurnBudget, turnBudgetExceededMessage, turnBudgetSoftNote, turnBudgetState } from "../shared/turn-budget.ts";
99
+ import { appendTurnBudgetSystemPrompt, formatTurnBudgetOutput, initialTurnBudgetState, turnBudgetDecision, turnBudgetDeferredNote, turnBudgetDeferredState, turnBudgetExceededMessage, turnBudgetSoftNote, turnBudgetState } from "../shared/turn-budget.ts";
95
100
  import { initialToolBudgetState, toolBudgetState } from "../shared/tool-budget.ts";
101
+ import { resolveWatchdogConfig } from "../../watchdog/settings.ts";
102
+ import { createBoundedByteTail, createBoundedLineReader, formatProtocolOutputLimit, MAX_CHILD_STDERR_BYTES, projectChildLifecycle, type ChildLifecycleAction, type ProtocolOutputLimit } from "../shared/child-protocol.ts";
103
+ import { acquireSessionLease, type SessionLeaseRequest } from "../shared/session-lease.ts";
104
+ import {
105
+ CHILD_WATCHDOG_CONFIG_ENV,
106
+ acceptChildWatchdogEvent,
107
+ childWatchdogIsActive,
108
+ decodeChildWatchdogConfig,
109
+ isChildWatchdogStatusEvent,
110
+ resolveChildWatchdogConfig,
111
+ type ChildWatchdogStateSnapshot,
112
+ } from "../../watchdog/child-status.ts";
96
113
 
97
114
  interface SubagentRunConfig {
98
115
  id: string;
@@ -126,6 +143,7 @@ interface SubagentRunConfig {
126
143
  deadlineAt?: number;
127
144
  turnBudget?: ResolvedTurnBudget;
128
145
  toolBudget?: ResolvedToolBudget;
146
+ revivalLease?: SessionLeaseRequest;
129
147
  /** Global cap on simultaneously-running subagent tasks within this run. */
130
148
  globalConcurrencyLimit?: number;
131
149
  }
@@ -134,11 +152,13 @@ interface StepResult {
134
152
  agent: string;
135
153
  output: string;
136
154
  error?: string;
155
+ protocolError?: ProtocolOutputLimit;
137
156
  success: boolean;
138
157
  exitCode?: number | null;
139
158
  skipped?: boolean;
140
159
  interrupted?: boolean;
141
160
  timedOut?: boolean;
161
+ stopped?: boolean;
142
162
  turnBudget?: TurnBudgetState;
143
163
  turnBudgetExceeded?: boolean;
144
164
  wrapUpRequested?: boolean;
@@ -158,6 +178,7 @@ interface StepResult {
158
178
  structuredOutputPath?: string;
159
179
  structuredOutputSchemaPath?: string;
160
180
  acceptance?: import("../../shared/types.ts").AcceptanceLedger;
181
+ watchdog?: import("../../shared/types.ts").ChildWatchdogProgress;
161
182
  }
162
183
 
163
184
  const ASYNC_INTERRUPT_SIGNAL: NodeJS.Signals = process.platform === "win32" ? "SIGBREAK" : "SIGUSR2";
@@ -291,11 +312,13 @@ function appendRecentStepOutput(step: RunnerStatusStep, lines: string[]): void {
291
312
  }
292
313
  }
293
314
 
294
- function isTerminalAssistantStop(message: Message): boolean {
295
- const stopReason = (message as { stopReason?: string }).stopReason;
296
- const hasToolCall = Array.isArray(message.content)
315
+ function assistantStartsToolCall(message: Message): boolean {
316
+ return Array.isArray(message.content)
297
317
  && message.content.some((part) => (part as { type?: string }).type === "toolCall");
298
- return stopReason === "stop" && !hasToolCall;
318
+ }
319
+
320
+ function isTerminalAssistantStop(message: Message): boolean {
321
+ return (message as { stopReason?: string }).stopReason === "stop" && !assistantStartsToolCall(message);
299
322
  }
300
323
 
301
324
  function resetStepLiveDetail(step: RunnerStatusStep): void {
@@ -335,6 +358,7 @@ interface ChildEvent {
335
358
  message?: ChildMessage;
336
359
  toolName?: string;
337
360
  args?: Record<string, unknown>;
361
+ willRetry?: unknown;
338
362
  }
339
363
 
340
364
  interface RunPiStreamingResult {
@@ -344,15 +368,18 @@ interface RunPiStreamingResult {
344
368
  usage: Usage;
345
369
  model?: string;
346
370
  error?: string;
371
+ protocolError?: ProtocolOutputLimit;
347
372
  finalOutput: string;
348
373
  interrupted?: boolean;
349
374
  timedOut?: boolean;
375
+ stopped?: boolean;
350
376
  turnBudget?: TurnBudgetState;
351
377
  turnBudgetExceeded?: boolean;
352
378
  wrapUpRequested?: boolean;
353
379
  toolBudget?: ToolBudgetState;
354
380
  toolBudgetBlocked?: boolean;
355
381
  observedMutationAttempt?: boolean;
382
+ watchdog?: ChildWatchdogStateSnapshot;
356
383
  }
357
384
 
358
385
  function runPiStreaming(
@@ -369,9 +396,13 @@ function runPiStreaming(
369
396
  transcriptWriter?: ChildTranscriptWriter,
370
397
  registerTimeout?: (interrupt: (() => void) | undefined) => void,
371
398
  timeoutMessage?: string,
399
+ registerStop?: (stop: (() => void) | undefined) => void,
400
+ stopMessage?: string,
372
401
  registerTurnBudgetAbort?: (abort: ((message: string, state?: TurnBudgetState) => void) | undefined) => void,
402
+ onWriterProcess?: (writer: { state: "none" | "spawning" } | { state: "running"; pid: number }) => void,
373
403
  ): Promise<RunPiStreamingResult> {
374
404
  return new Promise((resolve) => {
405
+ onWriterProcess?.({ state: "spawning" });
375
406
  const outputStream = fs.createWriteStream(outputFile, { flags: "w" });
376
407
  const spawnEnv = { ...process.env, ...(env ?? {}), ...getSubagentDepthEnv(maxSubagentDepth) };
377
408
  const spawnSpec = getPiSpawnCommand(args, {
@@ -384,21 +415,35 @@ function runPiStreaming(
384
415
  env: spawnEnv,
385
416
  windowsHide: true,
386
417
  });
387
- let stderr = "";
388
- let stdoutBuf = "";
389
- let stderrBuf = "";
418
+ const stderrTail = createBoundedByteTail();
419
+ const rawStdoutTail = createBoundedByteTail();
390
420
  const messages: Message[] = [];
391
421
  const usage = emptyUsage();
392
422
  let model: string | undefined;
393
- let error: string | undefined;
423
+ let writerRegistrationError: string | undefined;
424
+ if (typeof child.pid === "number") {
425
+ try {
426
+ onWriterProcess?.({ state: "running", pid: child.pid });
427
+ } catch (writerError) {
428
+ writerRegistrationError = `Failed to record revived Pi writer ownership: ${writerError instanceof Error ? writerError.message : String(writerError)}`;
429
+ trySignalChild(child, "SIGKILL");
430
+ }
431
+ }
432
+ let error: string | undefined = writerRegistrationError;
394
433
  let assistantError: string | undefined;
395
434
  let interrupted = false;
396
435
  let timedOut = false;
436
+ let stopped = false;
397
437
  let turnBudgetExceeded = false;
398
438
  let turnBudgetMessage: string | undefined;
399
439
  let turnBudget: TurnBudgetState | undefined;
400
440
  let observedMutationAttempt = false;
401
- const rawStdoutLines: string[] = [];
441
+ const childWatchdogConfig = decodeChildWatchdogConfig(env?.[CHILD_WATCHDOG_CONFIG_ENV]);
442
+ let childWatchdogState: ChildWatchdogStateSnapshot | undefined;
443
+ let applyChildLifecycle = (_action: ChildLifecycleAction): void => {};
444
+ const updateChildWatchdogState = (snapshot: ChildWatchdogStateSnapshot): void => {
445
+ childWatchdogState = snapshot;
446
+ };
402
447
 
403
448
  const writeOutputLine = (line: string) => {
404
449
  if (!line.trim()) return;
@@ -436,7 +481,7 @@ function runPiStreaming(
436
481
  try {
437
482
  event = JSON.parse(line) as ChildEvent;
438
483
  } catch {
439
- rawStdoutLines.push(line);
484
+ rawStdoutTail.push(`${line}\n`);
440
485
  writeOutputLine(line);
441
486
  appendChildLine("subagent.child.stdout", line);
442
487
  return;
@@ -444,6 +489,38 @@ function runPiStreaming(
444
489
 
445
490
  appendChildEvent(event);
446
491
  transcriptWriter?.writeChildEvent(event);
492
+ if (event.type === "agent_settled") agentSettledReceived = true;
493
+ applyChildLifecycle(projectChildLifecycle(event));
494
+
495
+ if (isChildWatchdogStatusEvent(event)) {
496
+ if (!childWatchdogConfig) return;
497
+ const next = acceptChildWatchdogEvent({
498
+ current: childWatchdogState,
499
+ event,
500
+ runId: childEventContext?.runId,
501
+ agent: childEventContext?.agent,
502
+ childIndex: childEventContext?.stepIndex,
503
+ });
504
+ if (!next) return;
505
+ updateChildWatchdogState(next);
506
+ onChildEvent?.(event);
507
+ if (childWatchdogIsActive(next)) {
508
+ if (finalDrainTimer) {
509
+ clearTimeout(finalDrainTimer);
510
+ finalDrainTimer = undefined;
511
+ }
512
+ if (finalHardKillTimer) {
513
+ clearTimeout(finalHardKillTimer);
514
+ finalHardKillTimer = undefined;
515
+ }
516
+ armWatchdogTail();
517
+ } else {
518
+ clearWatchdogTailTimer();
519
+ if (cleanTerminalAssistantStopReceived || agentSettledReceived) startFinalDrain();
520
+ }
521
+ return;
522
+ }
523
+
447
524
  onChildEvent?.(event);
448
525
 
449
526
  if (event.type === "tool_execution_start" && event.toolName) {
@@ -473,24 +550,11 @@ function runPiStreaming(
473
550
  if (isTerminalAssistantStop(event.message)) {
474
551
  if (!event.message.errorMessage && extractTextFromContent(event.message.content).trim()) assistantError = undefined;
475
552
  cleanTerminalAssistantStopReceived ||= !event.message.errorMessage;
476
- startFinalDrain();
553
+ applyChildLifecycle(projectChildLifecycle(event, true));
477
554
  }
478
555
  }
479
556
  };
480
557
 
481
- const processStderrText = (text: string) => {
482
- stderr += text;
483
- stderrBuf += text;
484
- outputStream.write(text);
485
- if (!childEventContext) return;
486
- const lines = stderrBuf.split("\n");
487
- stderrBuf = lines.pop() || "";
488
- for (const line of lines) {
489
- if (!line.trim()) continue;
490
- appendChildLine("subagent.child.stderr", line);
491
- }
492
- };
493
-
494
558
  // Guard both cases that can leave the parent waiting on `close` forever:
495
559
  // a lingering stdio holder after `exit`, or a child that never exits.
496
560
  const FINAL_STOP_GRACE_MS = 1000;
@@ -499,35 +563,68 @@ function runPiStreaming(
499
563
  let childExited = false;
500
564
  let forcedTerminationSignal = false;
501
565
  let cleanTerminalAssistantStopReceived = false;
566
+ let agentSettledReceived = false;
502
567
  let finalDrainTimer: NodeJS.Timeout | undefined;
503
568
  let finalHardKillTimer: NodeJS.Timeout | undefined;
569
+ let watchdogTailTimer: NodeJS.Timeout | undefined;
504
570
  let timeoutHardKillTimer: NodeJS.Timeout | undefined;
505
571
  let turnBudgetTerminationTimer: NodeJS.Timeout | undefined;
506
572
  let turnBudgetHardKillTimer: NodeJS.Timeout | undefined;
573
+ let protocolHardKillTimer: NodeJS.Timeout | undefined;
574
+ let protocolError: ProtocolOutputLimit | undefined;
507
575
  let settled = false;
508
- const clearStdioGuard = attachPostExitStdioGuard(child, { idleMs: 2000, hardMs: 8000 });
509
- child.stdout.on("data", (chunk: Buffer) => {
510
- const text = chunk.toString();
511
- stdoutBuf += text;
512
- const lines = stdoutBuf.split("\n");
513
- stdoutBuf = lines.pop() || "";
514
- for (const line of lines) processStdoutLine(line);
576
+ applyChildLifecycle = (action: ChildLifecycleAction): void => {
577
+ if (action === "cancel-drain") {
578
+ if (finalDrainTimer) {
579
+ clearTimeout(finalDrainTimer);
580
+ finalDrainTimer = undefined;
581
+ }
582
+ if (finalHardKillTimer) {
583
+ clearTimeout(finalHardKillTimer);
584
+ finalHardKillTimer = undefined;
585
+ }
586
+ clearWatchdogTailTimer();
587
+ return;
588
+ }
589
+ if (action === "start-drain") startFinalDrain();
590
+ };
591
+ const failProtocol = (limit: ProtocolOutputLimit): void => {
592
+ if (protocolError) return;
593
+ protocolError = limit;
594
+ error = formatProtocolOutputLimit(limit);
595
+ if (!childExited) {
596
+ trySignalChild(child, "SIGTERM");
597
+ protocolHardKillTimer = setTimeout(() => {
598
+ if (!settled) trySignalChild(child, "SIGKILL");
599
+ }, 3000);
600
+ protocolHardKillTimer.unref?.();
601
+ }
602
+ };
603
+ const stdoutReader = createBoundedLineReader({ onLine: processStdoutLine, onLimit: failProtocol });
604
+ const stderrReader = createBoundedLineReader({
605
+ stream: "stderr",
606
+ maxPendingLineBytes: MAX_CHILD_STDERR_BYTES,
607
+ onLine: (line) => appendChildLine("subagent.child.stderr", line),
608
+ onLimit: (limit) => appendChildLine("subagent.child.stderr", formatProtocolOutputLimit(limit)),
515
609
  });
516
-
610
+ const clearStdioGuard = attachPostExitStdioGuard(child, { idleMs: 2000, hardMs: 8000 });
611
+ child.stdout.on("data", (chunk: Buffer) => stdoutReader.push(chunk));
517
612
  child.stderr.on("data", (chunk: Buffer) => {
518
- processStderrText(chunk.toString());
613
+ stderrTail.push(chunk);
614
+ stderrReader.push(chunk);
615
+ outputStream.write(chunk);
519
616
  });
520
617
  registerInterrupt?.(() => {
521
- if (settled || timedOut) return;
618
+ if (settled || timedOut || stopped) return;
522
619
  interrupted = true;
523
620
  if (!error) error = "Interrupted. Waiting for explicit next action.";
524
621
  trySignalChild(child, "SIGINT");
525
622
  setTimeout(() => {
526
- if (!settled && !timedOut) trySignalChild(child, "SIGTERM");
623
+ if (!settled && !timedOut && !stopped) trySignalChild(child, "SIGTERM");
527
624
  }, 1000).unref?.();
528
625
  });
529
626
  registerTimeout?.(() => {
530
- if (settled || timedOut) return;
627
+ if (settled || timedOut || stopped) return;
531
628
  timedOut = true;
532
629
  interrupted = false;
533
630
  error = timeoutMessage ?? "Subagent timed out.";
@@ -537,8 +634,19 @@ function runPiStreaming(
537
634
  }, TIMEOUT_HARD_KILL_MS);
538
635
  timeoutHardKillTimer.unref?.();
539
636
  });
637
+ registerStop?.(() => {
638
+ if (settled || timedOut || stopped) return;
639
+ stopped = true;
640
+ interrupted = false;
641
+ error = stopMessage ?? "Subagent stopped by user.";
642
+ trySignalChild(child, "SIGTERM");
643
+ timeoutHardKillTimer = setTimeout(() => {
644
+ if (!settled) trySignalChild(child, "SIGKILL");
645
+ }, TIMEOUT_HARD_KILL_MS);
646
+ timeoutHardKillTimer.unref?.();
647
+ });
540
648
  registerTurnBudgetAbort?.((message, state) => {
541
- if (settled || timedOut || turnBudgetExceeded) return;
649
+ if (settled || timedOut || stopped || turnBudgetExceeded) return;
542
650
  turnBudgetExceeded = true;
543
651
  turnBudgetMessage = message;
544
652
  turnBudget = state;
@@ -546,11 +654,11 @@ function runPiStreaming(
546
654
  error = message;
547
655
  trySignalChild(child, "SIGINT");
548
656
  turnBudgetTerminationTimer = setTimeout(() => {
549
- if (!settled && !timedOut) trySignalChild(child, "SIGTERM");
657
+ if (!settled && !timedOut && !stopped) trySignalChild(child, "SIGTERM");
550
658
  }, 1000);
551
659
  turnBudgetTerminationTimer.unref?.();
552
660
  turnBudgetHardKillTimer = setTimeout(() => {
553
- if (!settled && !timedOut) trySignalChild(child, "SIGKILL");
661
+ if (!settled && !timedOut && !stopped) trySignalChild(child, "SIGKILL");
554
662
  }, 4000);
555
663
  turnBudgetHardKillTimer.unref?.();
556
664
  });
@@ -563,6 +671,7 @@ function runPiStreaming(
563
671
  clearTimeout(finalHardKillTimer);
564
672
  finalHardKillTimer = undefined;
565
673
  }
674
+ clearWatchdogTailTimer();
566
675
  if (timeoutHardKillTimer) {
567
676
  clearTimeout(timeoutHardKillTimer);
568
677
  timeoutHardKillTimer = undefined;
@@ -575,16 +684,24 @@ function runPiStreaming(
575
684
  clearTimeout(turnBudgetHardKillTimer);
576
685
  turnBudgetHardKillTimer = undefined;
577
686
  }
687
+ if (protocolHardKillTimer) {
688
+ clearTimeout(protocolHardKillTimer);
689
+ protocolHardKillTimer = undefined;
690
+ }
578
691
  };
579
692
  function startFinalDrain(): void {
693
+ if (childWatchdogIsActive(childWatchdogState)) {
694
+ armWatchdogTail();
695
+ return;
696
+ }
580
697
  if (childExited || finalDrainTimer || settled) return;
581
698
  finalDrainTimer = setTimeout(() => {
582
699
  if (settled) return;
583
700
  const termSent = trySignalChild(child, "SIGTERM");
584
701
  if (!termSent) return;
585
702
  forcedTerminationSignal = true;
586
- if (!cleanTerminalAssistantStopReceived && !error && !assistantError) {
587
- error = `Subagent process did not exit within ${FINAL_STOP_GRACE_MS}ms after its final message. Forcing termination.`;
703
+ if (!cleanTerminalAssistantStopReceived && !agentSettledReceived && !error && !assistantError) {
704
+ error = `Subagent process did not exit within ${FINAL_STOP_GRACE_MS}ms after its terminal event. Forcing termination.`;
588
705
  }
589
706
  finalHardKillTimer = setTimeout(() => {
590
707
  if (settled) return;
@@ -594,51 +711,92 @@ function runPiStreaming(
594
711
  }, FINAL_STOP_GRACE_MS);
595
712
  finalDrainTimer.unref?.();
596
713
  }
714
+ function clearWatchdogTailTimer(): void {
715
+ if (watchdogTailTimer) {
716
+ clearTimeout(watchdogTailTimer);
717
+ watchdogTailTimer = undefined;
718
+ }
719
+ }
720
+ function armWatchdogTail(): void {
721
+ if ((!cleanTerminalAssistantStopReceived && !agentSettledReceived) || watchdogTailTimer || settled) return;
722
+ watchdogTailTimer = setTimeout(() => {
723
+ watchdogTailTimer = undefined;
724
+ updateChildWatchdogState({
725
+ phase: "stale",
726
+ seq: (childWatchdogState?.seq ?? 0) + 1,
727
+ lastUpdate: Date.now(),
728
+ followUpPending: false,
729
+ reason: "child watchdog tail timeout",
730
+ timedOut: true,
731
+ });
732
+ startFinalDrain();
733
+ }, childWatchdogConfig?.watchdogTailTimeoutMs ?? 120_000);
734
+ watchdogTailTimer.unref?.();
735
+ }
597
736
  child.on("exit", () => {
598
737
  childExited = true;
599
738
  clearDrainTimers();
600
739
  });
601
740
  child.on("close", (exitCode, signal) => {
602
741
  settled = true;
742
+ try {
743
+ onWriterProcess?.({ state: "none" });
744
+ } catch {
745
+ // The runner still owns and releases the lease during finalization.
746
+ }
603
747
  registerInterrupt?.(undefined);
604
748
  registerTimeout?.(undefined);
749
+ registerStop?.(undefined);
605
750
  registerTurnBudgetAbort?.(undefined);
606
751
  clearDrainTimers();
607
752
  clearStdioGuard();
608
- if (stdoutBuf.trim()) processStdoutLine(stdoutBuf);
609
- if (stderrBuf.trim()) appendChildLine("subagent.child.stderr", stderrBuf);
753
+ stdoutReader.end();
754
+ stderrReader.end();
610
755
  outputStream.end();
611
- const finalOutput = getFinalOutput(messages) || rawStdoutLines.join("\n").trim();
756
+ const stderr = stderrTail.text();
757
+ const finalOutput = getFinalOutput(messages) || rawStdoutTail.text().trim();
612
758
  const finalError = error ?? assistantError;
613
- const forcedDrainAfterFinalSuccess = forcedTerminationSignal && cleanTerminalAssistantStopReceived && !finalError;
759
+ const forcedDrainAfterFinalSuccess = forcedTerminationSignal && (cleanTerminalAssistantStopReceived || agentSettledReceived) && !finalError;
614
760
  resolve({
615
761
  stderr,
616
- exitCode: timedOut ? 1 : turnBudgetExceeded ? 1 : interrupted || forcedDrainAfterFinalSuccess ? 0 : forcedTerminationSignal || signal ? (exitCode ?? 1) : exitCode,
762
+ exitCode: timedOut || stopped ? 1 : turnBudgetExceeded ? 1 : interrupted || forcedDrainAfterFinalSuccess ? 0 : forcedTerminationSignal || signal ? (exitCode ?? 1) : exitCode,
617
763
  messages,
618
764
  usage,
619
765
  model,
620
- error: timedOut ? (timeoutMessage ?? "Subagent timed out.") : turnBudgetExceeded ? turnBudgetMessage : interrupted || forcedDrainAfterFinalSuccess ? undefined : finalError,
621
- finalOutput: timedOut && !finalOutput.trim() ? (timeoutMessage ?? "Subagent timed out.") : finalOutput,
766
+ error: stopped ? (stopMessage ?? "Subagent stopped by user.") : timedOut ? (timeoutMessage ?? "Subagent timed out.") : turnBudgetExceeded ? turnBudgetMessage : interrupted || forcedDrainAfterFinalSuccess ? undefined : finalError,
767
+ protocolError,
768
+ finalOutput: (timedOut || stopped) && !finalOutput.trim() ? (stopped ? stopMessage ?? "Subagent stopped by user." : timeoutMessage ?? "Subagent timed out.") : finalOutput,
622
769
  interrupted,
623
770
  timedOut,
771
+ stopped,
624
772
  turnBudget,
625
773
  turnBudgetExceeded,
626
- wrapUpRequested: turnBudget?.outcome === "wrap-up-requested" || turnBudgetExceeded || undefined,
774
+ wrapUpRequested: turnBudget?.outcome === "wrap-up-requested" || turnBudget?.outcome === "termination-deferred" || turnBudgetExceeded || undefined,
627
775
  observedMutationAttempt,
776
+ watchdog: childWatchdogState,
628
777
  });
629
778
  });
630
779
 
631
780
  child.on("error", (spawnError) => {
632
781
  settled = true;
782
+ try {
783
+ onWriterProcess?.({ state: "none" });
784
+ } catch {
785
+ // The runner still owns and releases the lease during finalization.
786
+ }
633
787
  registerInterrupt?.(undefined);
634
788
  registerTimeout?.(undefined);
789
+ registerStop?.(undefined);
635
790
  registerTurnBudgetAbort?.(undefined);
636
791
  clearDrainTimers();
637
792
  clearStdioGuard();
793
+ stdoutReader.end();
794
+ stderrReader.end();
638
795
  outputStream.end();
639
- const finalOutput = getFinalOutput(messages) || rawStdoutLines.join("\n").trim();
796
+ const stderr = stderrTail.text();
797
+ const finalOutput = getFinalOutput(messages) || rawStdoutTail.text().trim();
640
798
  const spawnErrorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
641
- resolve({ stderr, exitCode: 1, messages, usage, model, error: timedOut ? (timeoutMessage ?? "Subagent timed out.") : turnBudgetExceeded ? turnBudgetMessage : error ?? assistantError ?? spawnErrorMessage, finalOutput: timedOut && !finalOutput.trim() ? (timeoutMessage ?? "Subagent timed out.") : finalOutput, timedOut, turnBudget, turnBudgetExceeded, wrapUpRequested: turnBudget?.outcome === "wrap-up-requested" || turnBudgetExceeded || undefined, observedMutationAttempt });
799
+ resolve({ stderr, exitCode: 1, messages, usage, model, error: stopped ? (stopMessage ?? "Subagent stopped by user.") : timedOut ? (timeoutMessage ?? "Subagent timed out.") : turnBudgetExceeded ? turnBudgetMessage : error ?? assistantError ?? spawnErrorMessage, protocolError, finalOutput: (timedOut || stopped) && !finalOutput.trim() ? (stopped ? stopMessage ?? "Subagent stopped by user." : timeoutMessage ?? "Subagent timed out.") : finalOutput, timedOut, stopped, turnBudget, turnBudgetExceeded, wrapUpRequested: turnBudget?.outcome === "wrap-up-requested" || turnBudget?.outcome === "termination-deferred" || turnBudgetExceeded || undefined, observedMutationAttempt, watchdog: childWatchdogState });
642
800
  });
643
801
  });
644
802
  }
@@ -764,20 +922,26 @@ interface SingleStepContext {
764
922
  flatStepCount: number;
765
923
  outputFile: string;
766
924
  steerInboxDir?: string;
925
+ steerCapabilityPath?: string;
926
+ steerAckDir?: string;
767
927
  transcriptPath?: string;
768
928
  piPackageRoot?: string;
769
929
  piArgv1?: string;
770
930
  registerInterrupt?: (interrupt: (() => void) | undefined) => void;
771
931
  registerTimeout?: (interrupt: (() => void) | undefined) => void;
932
+ registerStop?: (stop: (() => void) | undefined) => void;
772
933
  registerTurnBudgetAbort?: (abort: ((message: string, state?: TurnBudgetState) => void) | undefined) => void;
773
934
  timeoutSignal?: AbortSignal;
935
+ stopSignal?: AbortSignal;
774
936
  timeoutMessage?: string;
937
+ stopMessage?: string;
775
938
  turnBudget?: ResolvedTurnBudget;
776
939
  childIntercomTarget?: string;
777
940
  orchestratorIntercomTarget?: string;
778
941
  nestedRoute?: NestedRouteInfo;
779
942
  onAttemptStart?: (attempt: { model?: string; thinking?: string }) => void;
780
943
  onChildEvent?: (event: ChildEvent) => void;
944
+ onWriterProcess?: (writer: { state: "none" | "spawning" } | { state: "running"; pid: number }) => void;
781
945
  skipAcceptance?: () => boolean;
782
946
  }
783
947
 
@@ -798,6 +962,7 @@ async function runSingleStep(
798
962
  transcriptError?: string;
799
963
  interrupted?: boolean;
800
964
  timedOut?: boolean;
965
+ stopped?: boolean;
801
966
  turnBudget?: TurnBudgetState;
802
967
  turnBudgetExceeded?: boolean;
803
968
  wrapUpRequested?: boolean;
@@ -813,6 +978,7 @@ async function runSingleStep(
813
978
  }> {
814
979
  if (step.importAsyncRoot) {
815
980
  let importTimedOut = false;
981
+ let importStopped = false;
816
982
  ctx.registerTimeout?.(() => {
817
983
  importTimedOut = true;
818
984
  let pid: number | undefined;
@@ -827,36 +993,54 @@ async function runSingleStep(
827
993
  // The parent runner's own timeout result is authoritative for the attached step.
828
994
  }
829
995
  });
996
+ ctx.registerStop?.(() => {
997
+ importStopped = true;
998
+ let pid: number | undefined;
999
+ try {
1000
+ pid = readStatus(step.importAsyncRoot!.asyncDir)?.pid;
1001
+ } catch {
1002
+ pid = undefined;
1003
+ }
1004
+ try {
1005
+ deliverStopRequest({ asyncDir: step.importAsyncRoot!.asyncDir, pid, source: "ancestor-stop" });
1006
+ } catch {
1007
+ // The parent runner's own stopped result is authoritative for the attached step.
1008
+ }
1009
+ });
830
1010
  try {
831
1011
  const imported = await waitForImportedAsyncRoot(step.importAsyncRoot, {
832
- shouldAbort: () => importTimedOut || ctx.timeoutSignal?.aborted === true || ctx.skipAcceptance?.() === true,
833
- timeoutMessage: ctx.timeoutMessage,
1012
+ shouldAbort: () => importTimedOut || importStopped || ctx.timeoutSignal?.aborted === true || ctx.stopSignal?.aborted === true || ctx.skipAcceptance?.() === true,
1013
+ timeoutMessage: importStopped || ctx.stopSignal?.aborted === true ? ctx.stopMessage : ctx.timeoutMessage,
834
1014
  });
835
1015
  try {
836
1016
  fs.writeFileSync(ctx.outputFile, imported.output, "utf-8");
837
1017
  } catch {
838
1018
  // Output files are observability only for imported roots.
839
1019
  }
840
- const timedOut = importTimedOut || imported.timedOut === true || ctx.timeoutSignal?.aborted === true || ctx.skipAcceptance?.() === true;
1020
+ const stopped = importStopped || imported.stopped === true || ctx.stopSignal?.aborted === true;
1021
+ const timedOut = !stopped && (importTimedOut || imported.timedOut === true || ctx.timeoutSignal?.aborted === true || ctx.skipAcceptance?.() === true);
1022
+ const message = stopped ? ctx.stopMessage ?? "Subagent stopped by user." : ctx.timeoutMessage ?? "Subagent timed out.";
841
1023
  return {
842
1024
  agent: imported.agent,
843
- output: timedOut ? ctx.timeoutMessage ?? "Subagent timed out." : imported.output,
844
- exitCode: timedOut ? 1 : imported.exitCode,
845
- error: timedOut ? ctx.timeoutMessage ?? "Subagent timed out." : imported.error,
1025
+ output: timedOut || stopped ? message : imported.output,
1026
+ exitCode: timedOut || stopped ? 1 : imported.exitCode,
1027
+ error: timedOut || stopped ? message : imported.error,
846
1028
  timedOut: timedOut ? true : undefined,
1029
+ stopped: stopped ? true : undefined,
847
1030
  sessionFile: imported.sessionFile,
848
1031
  intercomTarget: imported.intercomTarget,
849
1032
  model: imported.model,
850
1033
  attemptedModels: imported.attemptedModels,
851
1034
  modelAttempts: imported.modelAttempts,
852
1035
  totalCost: imported.totalCost,
853
- structuredOutput: timedOut ? undefined : imported.structuredOutput,
854
- structuredOutputPath: timedOut ? undefined : imported.structuredOutputPath,
855
- structuredOutputSchemaPath: timedOut ? undefined : imported.structuredOutputSchemaPath,
856
- acceptance: timedOut ? undefined : imported.acceptance,
1036
+ structuredOutput: timedOut || stopped ? undefined : imported.structuredOutput,
1037
+ structuredOutputPath: timedOut || stopped ? undefined : imported.structuredOutputPath,
1038
+ structuredOutputSchemaPath: timedOut || stopped ? undefined : imported.structuredOutputSchemaPath,
1039
+ acceptance: timedOut || stopped ? undefined : imported.acceptance,
857
1040
  };
858
1041
  } finally {
859
1042
  ctx.registerTimeout?.(undefined);
1043
+ ctx.registerStop?.(undefined);
860
1044
  }
861
1045
  }
862
1046
 
@@ -865,7 +1049,7 @@ async function runSingleStep(
865
1049
  : undefined);
866
1050
  const placeholderRegex = new RegExp(ctx.placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g");
867
1051
  let task = step.task.replace(placeholderRegex, () => ctx.previousOutput);
868
- task = resolveOutputReferences(task, ctx.outputs ?? {});
1052
+ if (ctx.outputs) task = resolveOutputReferences(task, ctx.outputs);
869
1053
  const taskForCompletionGuard = task;
870
1054
  if (step.effectiveAcceptance) {
871
1055
  const acceptancePrompt = formatAcceptancePrompt(step.effectiveAcceptance);
@@ -924,7 +1108,16 @@ async function runSingleStep(
924
1108
  // Missing/stale structured-output files are handled after the child exits.
925
1109
  }
926
1110
  }
927
- const { args, env, tempDir } = buildPiArgs({
1111
+ const watchdogConfig = resolveWatchdogConfig(step.cwd ?? ctx.cwd);
1112
+ const childWatchdog = watchdogConfig.ok
1113
+ ? resolveChildWatchdogConfig({
1114
+ config: watchdogConfig.config,
1115
+ agent: step.agent,
1116
+ runId: ctx.id,
1117
+ childIndex: ctx.flatIndex,
1118
+ })
1119
+ : undefined;
1120
+ const { args, env, tempDir, toolDiagnosticPath } = buildPiArgs({
928
1121
  parentSessionId: step.parentSessionId,
929
1122
  baseArgs: ["--mode", "json", "-p"],
930
1123
  task,
@@ -953,8 +1146,12 @@ async function runSingleStep(
953
1146
  parentRootRunId: ctx.nestedRoute?.rootRunId,
954
1147
  parentCapabilityToken: ctx.nestedRoute?.capabilityToken,
955
1148
  steerInboxDir: ctx.steerInboxDir,
1149
+ steerCapabilityPath: ctx.steerCapabilityPath,
1150
+ steerAckDir: ctx.steerAckDir,
956
1151
  structuredOutput: effectiveStructuredOutput,
957
1152
  toolBudget: step.toolBudget,
1153
+ childWatchdog,
1154
+ waitToolEnabled: step.waitToolEnabled,
958
1155
  });
959
1156
  const run = await runPiStreaming(
960
1157
  args,
@@ -970,7 +1167,10 @@ async function runSingleStep(
970
1167
  transcriptWriter,
971
1168
  ctx.registerTimeout,
972
1169
  ctx.timeoutMessage,
1170
+ ctx.registerStop,
1171
+ ctx.stopMessage,
973
1172
  ctx.registerTurnBudgetAbort,
1173
+ ctx.onWriterProcess,
974
1174
  );
975
1175
  if (run.turnBudget) turnBudget = run.turnBudget;
976
1176
  else if (ctx.turnBudget) {
@@ -980,25 +1180,32 @@ async function runSingleStep(
980
1180
  if (turnCount > 0 && turnCount < ctx.turnBudget.maxTurns) {
981
1181
  turnBudget = { ...ctx.turnBudget, outcome: "within-budget", turnCount };
982
1182
  } else if (turnCount >= ctx.turnBudget.maxTurns) {
983
- turnBudget = turnBudgetState(
1183
+ const decision = turnBudgetDecision(
984
1184
  ctx.turnBudget,
985
1185
  turnCount,
986
- shouldAbortForTurnBudget(ctx.turnBudget, turnCount, lastAssistantMessage ? isTerminalAssistantStop(lastAssistantMessage) : false),
1186
+ lastAssistantMessage ? isTerminalAssistantStop(lastAssistantMessage) : false,
1187
+ lastAssistantMessage ? assistantStartsToolCall(lastAssistantMessage) : false,
987
1188
  );
1189
+ turnBudget = decision === "defer"
1190
+ ? turnBudgetDeferredState(ctx.turnBudget, turnCount)
1191
+ : turnBudgetState(ctx.turnBudget, turnCount, decision === "abort");
988
1192
  }
989
1193
  }
1194
+ const toolAvailabilityError = run.exitCode === 0 && !run.error
1195
+ ? readChildToolDiagnosticError(toolDiagnosticPath)
1196
+ : undefined;
990
1197
  cleanupTempDir(tempDir);
991
1198
 
992
- const hiddenError = run.exitCode === 0 && !run.error ? detectSubagentError(run.messages) : null;
1199
+ const hiddenError = run.exitCode === 0 && !run.error && !toolAvailabilityError ? detectSubagentError(run.messages) : null;
993
1200
  const missingStructuredOutput = effectiveStructuredOutput
994
1201
  ? !fs.existsSync(effectiveStructuredOutput.outputPath)
995
1202
  : false;
996
- const emptyOutputError = run.exitCode === 0 && !run.error && !hiddenError?.hasError && !run.finalOutput.trim() && (!effectiveStructuredOutput || missingStructuredOutput)
1203
+ const emptyOutputError = run.exitCode === 0 && !run.error && !toolAvailabilityError && !hiddenError?.hasError && !run.finalOutput.trim() && (!effectiveStructuredOutput || missingStructuredOutput)
997
1204
  ? "Subagent produced no output (possible model cold-start or empty response)."
998
1205
  : undefined;
999
1206
  let structuredOutput: unknown;
1000
1207
  let structuredError: string | undefined;
1001
- if (effectiveStructuredOutput && run.exitCode === 0 && !run.error && !hiddenError?.hasError && !emptyOutputError) {
1208
+ if (effectiveStructuredOutput && run.exitCode === 0 && !run.error && !toolAvailabilityError && !hiddenError?.hasError && !emptyOutputError) {
1002
1209
  const structured = readStructuredOutput({
1003
1210
  schema: effectiveStructuredOutput.schema,
1004
1211
  schemaPath: effectiveStructuredOutput.schemaPath,
@@ -1007,7 +1214,7 @@ async function runSingleStep(
1007
1214
  if (structured.error) structuredError = structured.error;
1008
1215
  else structuredOutput = structured.value;
1009
1216
  }
1010
- const completionGuard = run.exitCode === 0 && !run.error && !hiddenError?.hasError && !emptyOutputError && step.completionGuard !== false
1217
+ const completionGuard = run.exitCode === 0 && !run.error && !toolAvailabilityError && !hiddenError?.hasError && !emptyOutputError && step.completionGuard !== false
1011
1218
  ? evaluateCompletionMutationGuard({
1012
1219
  agent: step.agent,
1013
1220
  task: taskForCompletionGuard,
@@ -1020,18 +1227,17 @@ async function runSingleStep(
1020
1227
  const completionGuardError = completionGuardTriggered
1021
1228
  ? "Subagent completed without making edits for an implementation task.\nIt appears to have returned planning or scratchpad output instead of applying changes."
1022
1229
  : undefined;
1023
- const effectiveExitCode = completionGuardTriggered
1230
+ const effectiveExitCode = toolAvailabilityError || completionGuardTriggered || structuredError
1024
1231
  ? 1
1025
- : structuredError
1026
- ? 1
1027
- : hiddenError?.hasError
1232
+ : hiddenError?.hasError
1028
1233
  ? (hiddenError.exitCode ?? 1)
1029
1234
  : emptyOutputError
1030
1235
  ? 1
1031
1236
  : run.error && run.exitCode === 0
1032
1237
  ? 1
1033
1238
  : run.exitCode;
1034
- const error = completionGuardError
1239
+ const error = toolAvailabilityError
1240
+ ?? completionGuardError
1035
1241
  ?? structuredError
1036
1242
  ?? (hiddenError?.hasError
1037
1243
  ? hiddenError.details
@@ -1057,7 +1263,7 @@ async function runSingleStep(
1057
1263
  }
1058
1264
  finalResult = { ...run, exitCode: effectiveExitCode, model: candidate ?? run.model, error, structuredOutput } as RunPiStreamingResult & { structuredOutput?: unknown };
1059
1265
  if (run.turnBudgetExceeded) break;
1060
- if (run.timedOut || ctx.timeoutSignal?.aborted || ctx.skipAcceptance?.()) break;
1266
+ if (run.stopped || run.timedOut || ctx.timeoutSignal?.aborted || ctx.stopSignal?.aborted || ctx.skipAcceptance?.()) break;
1061
1267
  if (attempt.success || completionGuardTriggered) break;
1062
1268
  if (!isRetryableModelFailure(error) || index === candidates.length - 1) break;
1063
1269
  attemptNotes.push(formatModelAttemptNote(attempt, candidates[index + 1]));
@@ -1068,21 +1274,29 @@ async function runSingleStep(
1068
1274
  const resolvedOutput = step.outputPath && finalResult?.exitCode === 0
1069
1275
  ? resolveSingleOutput(step.outputPath, outputForPersistence, finalOutputSnapshot)
1070
1276
  : { fullOutput: outputForPersistence };
1071
- const output = resolvedOutput.fullOutput;
1277
+ const output = stripAcceptanceReport(resolvedOutput.fullOutput);
1072
1278
  const outputReference = resolvedOutput.savedPath ? formatSavedOutputReference(resolvedOutput.savedPath, output) : undefined;
1073
1279
  let outputForSummary = output;
1074
- if (attemptNotes.length > 0) {
1075
- outputForSummary = `${attemptNotes.join("\n")}\n\n${outputForSummary}`.trim();
1076
- }
1077
- if (!finalResult?.timedOut && finalResult?.turnBudgetExceeded && turnBudget) {
1280
+ if (attemptNotes.length > 0) {
1281
+ outputForSummary = `${attemptNotes.join("\n")}\n\n${outputForSummary}`.trim();
1282
+ }
1283
+ if (finalResult?.stopped && !outputForSummary.trim()) {
1284
+ outputForSummary = ctx.stopMessage ?? "Subagent stopped by user.";
1285
+ } else if (!finalResult?.timedOut && !finalResult?.stopped && finalResult?.turnBudgetExceeded && turnBudget) {
1078
1286
  outputForSummary = formatTurnBudgetOutput(turnBudgetExceededMessage(turnBudget, turnBudget.turnCount), outputForSummary);
1079
- } else if (!finalResult?.timedOut && turnBudget?.outcome === "wrap-up-requested") {
1287
+ } else if (!finalResult?.timedOut && !finalResult?.stopped && turnBudget?.outcome === "termination-deferred") {
1288
+ const note = turnBudgetDeferredNote(turnBudget, turnBudget.terminationDeferredAtTurn ?? turnBudget.turnCount);
1289
+ outputForSummary = outputForSummary.trim() ? `${note}\n\n${outputForSummary}` : note;
1290
+ } else if (!finalResult?.timedOut && !finalResult?.stopped && turnBudget?.outcome === "wrap-up-requested") {
1080
1291
  const note = turnBudgetSoftNote(turnBudget, turnBudget.wrapUpRequestedAtTurn ?? turnBudget.turnCount);
1081
1292
  outputForSummary = outputForSummary.trim() ? `${note}\n\n${outputForSummary}` : note;
1082
1293
  }
1083
1294
  const outputForAcceptance = rawOutput;
1084
- const finalizedOutput = finalizeSingleOutput({
1085
- fullOutput: outputForSummary,
1295
+ const childWrittenOutput = step.outputPath
1296
+ ? extractChildWrittenOutput(finalResult?.messages, step.outputPath, step.cwd ?? ctx.cwd)
1297
+ : undefined;
1298
+ const finalizedOutput = finalizeSingleOutput({
1299
+ fullOutput: outputForSummary,
1086
1300
  outputPath: step.outputPath,
1087
1301
  outputMode: step.outputMode,
1088
1302
  exitCode: finalResult?.exitCode ?? 1,
@@ -1091,28 +1305,42 @@ async function runSingleStep(
1091
1305
  saveError: resolvedOutput.saveError,
1092
1306
  });
1093
1307
  outputForSummary = finalizedOutput.displayOutput;
1094
- const acceptance = step.effectiveAcceptance && !finalResult?.turnBudgetExceeded && !ctx.timeoutSignal?.aborted && !ctx.skipAcceptance?.()
1095
- ? await evaluateAcceptance({
1096
- acceptance: step.effectiveAcceptance,
1097
- output: outputForAcceptance,
1098
- cwd: step.cwd ?? ctx.cwd,
1099
- signal: ctx.timeoutSignal,
1100
- abortMessage: ctx.timeoutMessage ?? "Subagent timed out.",
1101
- })
1308
+ const acceptance = step.effectiveAcceptance && !finalResult?.stopped && !finalResult?.turnBudgetExceeded && !ctx.timeoutSignal?.aborted && !ctx.stopSignal?.aborted && !ctx.skipAcceptance?.()
1309
+ ? await evaluateAcceptance({
1310
+ acceptance: step.effectiveAcceptance,
1311
+ output: outputForAcceptance,
1312
+ fileOutput: childWrittenOutput !== undefined && step.outputPath
1313
+ ? { content: childWrittenOutput, path: step.outputPath, authoritative: step.outputMode === "file-only" }
1314
+ : undefined,
1315
+ cwd: step.cwd ?? ctx.cwd,
1316
+ signal: combinedAbortSignal([ctx.timeoutSignal, ctx.stopSignal]),
1317
+ abortMessage: ctx.stopSignal?.aborted ? ctx.stopMessage ?? "Subagent stopped by user." : ctx.timeoutMessage ?? "Subagent timed out.",
1318
+ })
1102
1319
  : undefined;
1103
- const timedOutAfterAcceptance = finalResult?.timedOut === true || ctx.timeoutSignal?.aborted === true || ctx.skipAcceptance?.() === true;
1320
+ const stoppedAfterAcceptance = finalResult?.stopped === true || ctx.stopSignal?.aborted === true;
1321
+ const timedOutAfterAcceptance = !stoppedAfterAcceptance && (finalResult?.timedOut === true || ctx.timeoutSignal?.aborted === true);
1104
1322
  const turnBudgetExceeded = finalResult?.turnBudgetExceeded === true;
1105
- const effectiveAcceptance = timedOutAfterAcceptance || turnBudgetExceeded ? undefined : acceptance;
1323
+ const effectiveAcceptance = step.effectiveAcceptance
1324
+ ? stoppedAfterAcceptance
1325
+ ? buildSkippedAcceptanceLedger(step.effectiveAcceptance, { id: "stopped", message: "Acceptance was not evaluated because the subagent was stopped." })
1326
+ : timedOutAfterAcceptance
1327
+ ? buildSkippedAcceptanceLedger(step.effectiveAcceptance, { id: "timeout", message: "Acceptance was not evaluated because the subagent timed out." })
1328
+ : turnBudgetExceeded
1329
+ ? buildSkippedAcceptanceLedger(step.effectiveAcceptance, { id: "turn-budget", message: "Acceptance was not evaluated because the subagent exceeded its turn budget." })
1330
+ : acceptance
1331
+ : undefined;
1106
1332
  const acceptanceFailure = effectiveAcceptance ? acceptanceFailureMessage(effectiveAcceptance) : undefined;
1107
- const acceptanceCanFailRun = acceptanceFailure && effectiveAcceptance?.explicit && (finalResult?.exitCode ?? 1) === 0 && !finalResult?.interrupted && !timedOutAfterAcceptance && !turnBudgetExceeded;
1108
- const effectiveFinalExitCode = timedOutAfterAcceptance || turnBudgetExceeded ? 1 : acceptanceCanFailRun ? 1 : finalResult?.exitCode ?? 1;
1109
- const effectiveFinalError = timedOutAfterAcceptance
1110
- ? ctx.timeoutMessage ?? "Subagent timed out."
1111
- : turnBudgetExceeded
1112
- ? finalResult?.error ?? (turnBudget ? turnBudgetExceededMessage(turnBudget, turnBudget.turnCount) : "Subagent exceeded turn budget.")
1113
- : acceptanceCanFailRun
1114
- ? (finalResult?.error ? `${finalResult.error}\n${acceptanceFailure}` : acceptanceFailure)
1115
- : finalResult?.error;
1333
+ const acceptanceCanFailRun = acceptanceFailure && effectiveAcceptance?.explicit && (finalResult?.exitCode ?? 1) === 0 && !finalResult?.interrupted && !timedOutAfterAcceptance && !stoppedAfterAcceptance && !turnBudgetExceeded;
1334
+ const effectiveFinalExitCode = timedOutAfterAcceptance || stoppedAfterAcceptance || turnBudgetExceeded ? 1 : acceptanceCanFailRun ? 1 : finalResult?.exitCode ?? 1;
1335
+ const effectiveFinalError = stoppedAfterAcceptance
1336
+ ? ctx.stopMessage ?? "Subagent stopped by user."
1337
+ : timedOutAfterAcceptance
1338
+ ? ctx.timeoutMessage ?? "Subagent timed out."
1339
+ : turnBudgetExceeded
1340
+ ? finalResult?.error ?? (turnBudget ? turnBudgetExceededMessage(turnBudget, turnBudget.turnCount) : "Subagent exceeded turn budget.")
1341
+ : acceptanceCanFailRun
1342
+ ? (finalResult?.error ? `${finalResult.error}\n${acceptanceFailure}` : acceptanceFailure)
1343
+ : finalResult?.error;
1116
1344
 
1117
1345
  if (artifactPaths && ctx.artifactConfig?.enabled !== false) {
1118
1346
  if (ctx.artifactConfig?.includeOutput !== false) {
@@ -1129,6 +1357,8 @@ async function runSingleStep(
1129
1357
  model: finalResult?.model,
1130
1358
  attemptedModels: attemptedModels.length > 0 ? attemptedModels : undefined,
1131
1359
  modelAttempts,
1360
+ error: effectiveFinalError,
1361
+ acceptance: effectiveAcceptance,
1132
1362
  ...(transcriptWriter ? { transcriptPath: artifactPaths.transcriptPath } : {}),
1133
1363
  transcriptError: transcriptWriter?.getError(),
1134
1364
  skills: step.skills,
@@ -1144,6 +1374,7 @@ async function runSingleStep(
1144
1374
  output: outputForSummary,
1145
1375
  exitCode: effectiveFinalExitCode,
1146
1376
  error: effectiveFinalError,
1377
+ protocolError: finalResult?.protocolError,
1147
1378
  sessionFile: step.sessionFile,
1148
1379
  intercomTarget: ctx.childIntercomTarget,
1149
1380
  model: finalResult?.model,
@@ -1153,18 +1384,20 @@ async function runSingleStep(
1153
1384
  artifactPaths,
1154
1385
  transcriptPath: transcriptWriter ? artifactPaths?.transcriptPath : undefined,
1155
1386
  transcriptError: transcriptWriter?.getError(),
1156
- interrupted: timedOutAfterAcceptance || turnBudgetExceeded ? false : finalResult?.interrupted,
1387
+ interrupted: timedOutAfterAcceptance || stoppedAfterAcceptance || turnBudgetExceeded ? false : finalResult?.interrupted,
1157
1388
  timedOut: timedOutAfterAcceptance ? true : finalResult?.timedOut,
1389
+ stopped: stoppedAfterAcceptance ? true : finalResult?.stopped,
1158
1390
  turnBudget,
1159
1391
  turnBudgetExceeded: turnBudgetExceeded || undefined,
1160
- wrapUpRequested: finalResult?.wrapUpRequested || turnBudget?.outcome === "wrap-up-requested" || turnBudgetExceeded || undefined,
1392
+ wrapUpRequested: finalResult?.wrapUpRequested || turnBudget?.outcome === "wrap-up-requested" || turnBudget?.outcome === "termination-deferred" || turnBudgetExceeded || undefined,
1161
1393
  toolBudget,
1162
1394
  toolBudgetBlocked: toolBudgetBlocked || undefined,
1163
1395
  completionGuardTriggered: completionGuardTriggeredFinal,
1164
- structuredOutput: timedOutAfterAcceptance || turnBudgetExceeded ? undefined : (finalResult as (RunPiStreamingResult & { structuredOutput?: unknown }) | undefined)?.structuredOutput,
1165
- structuredOutputPath: timedOutAfterAcceptance || turnBudgetExceeded ? undefined : effectiveStructuredOutput?.outputPath,
1166
- structuredOutputSchemaPath: timedOutAfterAcceptance || turnBudgetExceeded ? undefined : effectiveStructuredOutput?.schemaPath,
1396
+ structuredOutput: timedOutAfterAcceptance || stoppedAfterAcceptance || turnBudgetExceeded ? undefined : (finalResult as (RunPiStreamingResult & { structuredOutput?: unknown }) | undefined)?.structuredOutput,
1397
+ structuredOutputPath: timedOutAfterAcceptance || stoppedAfterAcceptance || turnBudgetExceeded ? undefined : effectiveStructuredOutput?.outputPath,
1398
+ structuredOutputSchemaPath: timedOutAfterAcceptance || stoppedAfterAcceptance || turnBudgetExceeded ? undefined : effectiveStructuredOutput?.schemaPath,
1167
1399
  acceptance: effectiveAcceptance,
1400
+ watchdog: finalResult?.watchdog,
1168
1401
  };
1169
1402
  }
1170
1403
 
@@ -1312,7 +1545,26 @@ function resolveAsyncStepTranscriptPath(input: {
1312
1545
 
1313
1546
  type SingleStepResult = Awaited<ReturnType<typeof runSingleStep>>;
1314
1547
 
1315
- async function runSubagent(config: SubagentRunConfig): Promise<void> {
1548
+ function combinedAbortSignal(signals: Array<AbortSignal | undefined>): AbortSignal | undefined {
1549
+ const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal));
1550
+ if (activeSignals.length === 0) return undefined;
1551
+ if (activeSignals.length === 1) return activeSignals[0];
1552
+ const controller = new AbortController();
1553
+ const abort = (): void => controller.abort();
1554
+ for (const signal of activeSignals) {
1555
+ if (signal.aborted) {
1556
+ abort();
1557
+ break;
1558
+ }
1559
+ signal.addEventListener("abort", abort, { once: true });
1560
+ }
1561
+ return controller.signal;
1562
+ }
1563
+
1564
+ async function runSubagent(
1565
+ config: SubagentRunConfig,
1566
+ onWriterProcess?: (writer: { state: "none" | "spawning" } | { state: "running"; pid: number }) => void,
1567
+ ): Promise<void> {
1316
1568
  const { id, steps, resultPath, cwd, placeholder, taskIndex, totalTasks, maxOutput, artifactsDir, artifactConfig } =
1317
1569
  config;
1318
1570
  const globalSemaphore = new Semaphore(config.globalConcurrencyLimit ?? DEFAULT_GLOBAL_CONCURRENCY_LIMIT);
@@ -1328,16 +1580,21 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1328
1580
  const controlConfig = config.controlConfig ?? DEFAULT_CONTROL_CONFIG;
1329
1581
  const activeChildInterrupts = new Map<number, () => void>();
1330
1582
  const activeChildTimeouts = new Map<number, () => void>();
1583
+ const activeChildStops = new Map<number, () => void>();
1331
1584
  const activeChildTurnBudgetAborts = new Map<number, (message: string, state?: TurnBudgetState) => void>();
1332
1585
  const pendingStepSteers: SteerRequest[] = [];
1586
+ const steeringCapabilities = new Map<number, SteerCapability>();
1333
1587
  let interrupted = false;
1334
1588
  let currentActivityState: ActivityState | undefined;
1335
1589
  let activityTimer: NodeJS.Timeout | undefined;
1336
1590
  let timeoutTimer: NodeJS.Timeout | undefined;
1337
1591
  let timedOut = false;
1592
+ let stopped = false;
1338
1593
  let turnBudgetExceeded = false;
1339
1594
  const timeoutMessage = config.timeoutMs !== undefined ? `Subagent timed out after ${config.timeoutMs}ms.` : undefined;
1595
+ const stopMessage = "Subagent stopped by user.";
1340
1596
  const timeoutAbortController = new AbortController();
1597
+ const stopAbortController = new AbortController();
1341
1598
  let previousCumulativeTokens: TokenUsage = { input: 0, output: 0, total: 0 };
1342
1599
  let latestSessionFile: string | undefined;
1343
1600
 
@@ -1417,7 +1674,9 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1417
1674
  runId: id,
1418
1675
  ...(config.sessionId ? { sessionId: config.sessionId } : {}),
1419
1676
  mode: config.resultMode ?? (flatSteps.length > 1 ? "chain" : "single"),
1677
+ ...(config.nestedRoute ? { isNested: true } : {}),
1420
1678
  state: "running",
1679
+ steering: createSteeringStatus(),
1421
1680
  lastActivityAt: overallStartTime,
1422
1681
  startedAt: overallStartTime,
1423
1682
  lastUpdate: overallStartTime,
@@ -1464,9 +1723,9 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1464
1723
  const refreshWorkflowGraph = (): void => {
1465
1724
  if (!config.workflowGraph) return;
1466
1725
  const graph = structuredClone(statusPayload.workflowGraph ?? config.workflowGraph);
1467
- const normalize = (status: RunnerStatusStep["status"]): "pending" | "running" | "completed" | "failed" | "paused" | "detached" => {
1726
+ const normalize = (status: RunnerStatusStep["status"]): "pending" | "running" | "completed" | "failed" | "paused" | "stopped" | "detached" => {
1468
1727
  if (status === "complete" || status === "completed") return "completed";
1469
- if (status === "running" || status === "failed" || status === "paused" || status === "pending") return status;
1728
+ if (status === "running" || status === "failed" || status === "paused" || status === "stopped" || status === "pending") return status;
1470
1729
  return "pending";
1471
1730
  };
1472
1731
  const updateNode = (node: NonNullable<typeof graph.nodes>[number]): void => {
@@ -1483,10 +1742,11 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1483
1742
  if (node.children?.length) {
1484
1743
  if (node.children.every((child) => child.status === "completed")) node.status = "completed";
1485
1744
  else if (node.children.some((child) => child.status === "running")) node.status = "running";
1745
+ else if (node.children.some((child) => child.status === "stopped")) node.status = "stopped";
1486
1746
  else if (node.children.some((child) => child.status === "failed")) node.status = "failed";
1487
1747
  else if (node.children.some((child) => child.status === "paused")) node.status = "paused";
1488
1748
  }
1489
- if (node.error) node.status = "failed";
1749
+ if (node.error && node.status !== "stopped") node.status = "failed";
1490
1750
  };
1491
1751
  for (const node of graph.nodes) updateNode(node);
1492
1752
  statusPayload.workflowGraph = graph;
@@ -1512,6 +1772,14 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1512
1772
  activeChildTimeouts.set(flatIndex, interrupt);
1513
1773
  if (timedOut) interrupt();
1514
1774
  };
1775
+ const registerStepStop = (flatIndex: number, stop: (() => void) | undefined): void => {
1776
+ if (!stop) {
1777
+ activeChildStops.delete(flatIndex);
1778
+ return;
1779
+ }
1780
+ activeChildStops.set(flatIndex, stop);
1781
+ if (stopped) stop();
1782
+ };
1515
1783
  const registerStepTurnBudgetAbort = (flatIndex: number, abort: ((message: string, state?: TurnBudgetState) => void) | undefined): void => {
1516
1784
  if (!abort) {
1517
1785
  activeChildTurnBudgetAborts.delete(flatIndex);
@@ -1525,6 +1793,9 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1525
1793
  const timeoutActiveChildren = (): void => {
1526
1794
  for (const interrupt of [...activeChildTimeouts.values()]) interrupt();
1527
1795
  };
1796
+ const stopActiveChildren = (): void => {
1797
+ for (const stop of [...activeChildStops.values()]) stop();
1798
+ };
1528
1799
  const nestedRuns = function* (children: NestedRunSummary[] | undefined): Generator<NestedRunSummary> {
1529
1800
  for (const child of children ?? []) {
1530
1801
  yield child;
@@ -1563,6 +1834,37 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1563
1834
  }
1564
1835
  }
1565
1836
  };
1837
+ const stopNestedAsyncDescendants = (): void => {
1838
+ if (!config.nestedRoute) return;
1839
+ let registry: ReturnType<typeof projectNestedEvents>;
1840
+ try {
1841
+ registry = projectNestedEvents(config.nestedRoute);
1842
+ } catch (error) {
1843
+ appendJsonl(eventsPath, JSON.stringify({
1844
+ type: "subagent.nested.stop_failed",
1845
+ ts: Date.now(),
1846
+ runId: id,
1847
+ message: error instanceof Error ? error.message : String(error),
1848
+ }));
1849
+ return;
1850
+ }
1851
+ for (const run of nestedRuns(registry.children)) {
1852
+ if (run.state !== "running" && run.state !== "queued") continue;
1853
+ const nestedAsyncDir = run.asyncDir ?? resolveNestedAsyncDir(config.nestedRoute.rootRunId, run);
1854
+ if (!nestedAsyncDir) continue;
1855
+ try {
1856
+ deliverStopRequest({ asyncDir: nestedAsyncDir, pid: run.pid, source: "ancestor-stop" });
1857
+ } catch (error) {
1858
+ appendJsonl(eventsPath, JSON.stringify({
1859
+ type: "subagent.nested.stop_failed",
1860
+ ts: Date.now(),
1861
+ runId: id,
1862
+ targetRunId: run.id,
1863
+ message: error instanceof Error ? error.message : String(error),
1864
+ }));
1865
+ }
1866
+ }
1867
+ };
1566
1868
  const timeoutNestedAsyncDescendants = (): void => {
1567
1869
  if (!config.nestedRoute) return;
1568
1870
  let registry: ReturnType<typeof projectNestedEvents>;
@@ -1607,6 +1909,13 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1607
1909
  exitCode: 1,
1608
1910
  timedOut: true,
1609
1911
  });
1912
+ const stoppedStepResult = (agent: string): SingleStepResult => ({
1913
+ agent,
1914
+ output: stopMessage,
1915
+ error: stopMessage,
1916
+ exitCode: 1,
1917
+ stopped: true,
1918
+ });
1610
1919
  const consumePendingAppendRequests = (): void => {
1611
1920
  if (statusPayload.mode !== "chain" || statusPayload.state !== "running") return;
1612
1921
  const requests = consumeChainAppendRequests(asyncDir);
@@ -1646,7 +1955,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1646
1955
  }));
1647
1956
  }
1648
1957
  };
1649
- const markDynamicGraphGroup = (stepIndex: number, status: "completed" | "failed" | "running", error?: string, acceptance?: import("../../shared/types.ts").AcceptanceLedger): void => {
1958
+ const markDynamicGraphGroup = (stepIndex: number, status: "completed" | "failed" | "running" | "stopped", error?: string, acceptance?: import("../../shared/types.ts").AcceptanceLedger): void => {
1650
1959
  const groupNode = statusPayload.workflowGraph?.nodes.find((node) => node.id === `step-${stepIndex}`);
1651
1960
  if (!groupNode) return;
1652
1961
  groupNode.status = status;
@@ -1737,48 +2046,125 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1737
2046
  appendControlEvent(event);
1738
2047
  return true;
1739
2048
  };
2049
+ const steeringMarkerPath = (requestId: string): string => path.join(asyncDir, "control", "steer-recovery", `${Buffer.from(requestId).toString("base64url")}.json`);
2050
+ const markSteeringAttention = (index: number): void => {
2051
+ const step = statusPayload.steps[index];
2052
+ if (step) step.activityState = "needs_attention";
2053
+ statusPayload.activityState = "needs_attention";
2054
+ };
2055
+ const emitSteeringEvent = (type: string, request: SteerRequest, index?: number, extra: Record<string, unknown> = {}): void => {
2056
+ appendJsonl(eventsPath, JSON.stringify({ type, ts: Date.now(), runId: id, requestId: request.id, ...(index !== undefined ? { index } : {}), ...extra }));
2057
+ };
2058
+ const emitSteeringNotice = (requestId: string, state: "failed" | "partial" | "recovered", message: string): void => {
2059
+ appendJsonl(eventsPath, JSON.stringify({ type: "subagent.steering.notice", ts: Date.now(), runId: id, requestId, state, message, ...(config.sessionId ? { currentSessionId: config.sessionId } : {}) }));
2060
+ };
2061
+ const recordSteeringLifecycle = (request: SteerRequest, targets: Array<{ index: number; state: SteeringTargetState; reason?: string }>): void => {
2062
+ const lifecycle = steeringStatus(statusPayload);
2063
+ recordSteeringRequest(lifecycle, { id: request.id, requestedAt: request.ts, source: request.source, message: request.message, targets });
2064
+ for (const target of targets) {
2065
+ const step = statusPayload.steps[target.index];
2066
+ if (!step) continue;
2067
+ step.steering ??= createSteeringStatus();
2068
+ recordSteeringRequest(step.steering, { id: request.id, requestedAt: request.ts, source: request.source, message: request.message, targets: [target] });
2069
+ }
2070
+ };
2071
+ const updateSteeringLifecycleTarget = (
2072
+ requestId: string,
2073
+ index: number,
2074
+ state: SteeringTargetState,
2075
+ now: number,
2076
+ fields: Pick<SteeringTargetStatus, "reason" | "replacementRunId"> = {},
2077
+ ): SteeringTargetStatus | undefined => {
2078
+ const updated = updateSteeringTarget(steeringStatus(statusPayload), requestId, index, state, now, fields);
2079
+ const step = statusPayload.steps[index];
2080
+ if (step?.steering) updateSteeringTarget(step.steering, requestId, index, state, now, fields);
2081
+ return updated;
2082
+ };
2083
+ const emitTerminalSteeringNotice = (requestId: string, failureMessage: string): void => {
2084
+ const state = terminalSteeringNoticeState(steeringStatus(statusPayload), requestId);
2085
+ if (state === "partial") emitSteeringNotice(requestId, "partial", `Steering partially delivered for run ${id}.`);
2086
+ else if (state === "failed") emitSteeringNotice(requestId, "failed", failureMessage);
2087
+ };
1740
2088
  const deliverSteerRequest = (request: SteerRequest): void => {
1741
- if (statusPayload.state !== "running") return;
2089
+ if (statusPayload.state !== "running") {
2090
+ const reason = `run became ${statusPayload.state} before steering request was consumed`;
2091
+ const indexes = request.targetIndex !== undefined
2092
+ ? [request.targetIndex]
2093
+ : request.targetIndexes?.length
2094
+ ? request.targetIndexes
2095
+ : statusPayload.steps.map((_, index) => index);
2096
+ const targets = indexes.map((index) => ({ index, state: "failed" as const, reason }));
2097
+ recordSteeringLifecycle(request, targets);
2098
+ emitSteeringEvent("subagent.steer.requested", request, undefined, { targets });
2099
+ for (const target of targets) emitSteeringEvent("subagent.steer.failed", request, target.index, { reason });
2100
+ emitTerminalSteeringNotice(request.id, `Steering failed for run ${id}: ${reason}.`);
2101
+ statusPayload.lastUpdate = Date.now();
2102
+ writeStatusPayload();
2103
+ return;
2104
+ }
1742
2105
  const runningIndexes = statusPayload.steps
1743
2106
  .map((step, index) => ({ step, index }))
1744
2107
  .filter(({ step }) => step.status === "running")
1745
2108
  .map(({ index }) => index);
1746
- const targets = request.targetIndex !== undefined ? [request.targetIndex] : runningIndexes;
2109
+ const targets = request.targetIndex !== undefined
2110
+ ? [request.targetIndex]
2111
+ : request.targetIndexes?.length
2112
+ ? request.targetIndexes
2113
+ : runningIndexes.length > 0
2114
+ ? runningIndexes
2115
+ : statusPayload.mode === "single" && statusPayload.steps[0]?.status === "pending"
2116
+ ? [0]
2117
+ : [];
1747
2118
  const now = Date.now();
1748
- const accepted: number[] = [];
1749
- const rejected: Array<{ index: number; reason: string }> = [];
1750
- for (const index of targets) {
2119
+ const targetStates = targets.map((index) => {
1751
2120
  const step = statusPayload.steps[index];
1752
- if (!step) {
1753
- rejected.push({ index, reason: "child index out of range" });
1754
- continue;
1755
- }
1756
- if (step.status !== "running") {
1757
- rejected.push({ index, reason: `child is ${step.status}` });
1758
- continue;
2121
+ if (!step) return { index, state: "failed" as const, reason: "child index out of range" };
2122
+ if (step.status === "pending") return { index, state: "scheduled" as const };
2123
+ if (step.status !== "running") return { index, state: "failed" as const, reason: `child is ${step.status}` };
2124
+ if (steeringCapabilities.get(index)?.supported === false) return { index, state: "failed" as const, reason: "child Pi session does not support steering" };
2125
+ return { index, state: "routed" as const };
2126
+ });
2127
+ recordSteeringLifecycle(request, targetStates);
2128
+ emitSteeringEvent("subagent.steer.requested", request, undefined, { targets: targetStates });
2129
+ for (const target of targetStates) {
2130
+ if (target.state === "routed") {
2131
+ try {
2132
+ enqueueStepSteer(asyncDir, target.index, request);
2133
+ updateSteeringLifecycleTarget(request.id, target.index, "routed", now);
2134
+ emitSteeringEvent("subagent.steer.routed", request, target.index);
2135
+ } catch (error) {
2136
+ markSteeringAttention(target.index);
2137
+ updateSteeringLifecycleTarget(request.id, target.index, "failed", now, { reason: error instanceof Error ? error.message : String(error) });
2138
+ emitSteeringEvent("subagent.steer.failed", request, target.index, { reason: error instanceof Error ? error.message : String(error) });
2139
+ }
2140
+ } else if (target.state === "failed") {
2141
+ markSteeringAttention(target.index);
2142
+ emitSteeringEvent("subagent.steer.failed", request, target.index, { reason: target.reason });
2143
+ } else {
2144
+ emitSteeringEvent("subagent.steer.scheduled", request, target.index);
1759
2145
  }
1760
- enqueueStepSteer(asyncDir, index, request);
1761
- step.steerCount = (step.steerCount ?? 0) + 1;
1762
- step.lastSteerAt = now;
1763
- accepted.push(index);
1764
2146
  }
1765
- if (accepted.length > 0) {
1766
- statusPayload.steerCount = (statusPayload.steerCount ?? 0) + accepted.length;
1767
- statusPayload.lastSteerAt = now;
1768
- statusPayload.lastUpdate = now;
1769
- writeStatusPayload();
2147
+ emitTerminalSteeringNotice(request.id, `Steering failed for run ${id}: no requested child remained steerable.`);
2148
+ statusPayload.lastUpdate = now;
2149
+ writeStatusPayload();
2150
+ };
2151
+ const consumeSteerAck = (ack: SteerAck): void => {
2152
+ const lifecycle = steeringStatus(statusPayload);
2153
+ const request = lifecycle.recent.find((candidate) => candidate.id === ack.requestId);
2154
+ if (!request || !request.targets.some((target) => target.index === ack.index)) return;
2155
+ const late = fs.existsSync(steeringMarkerPath(ack.requestId));
2156
+ const now = Date.now();
2157
+ if (ack.state === "delivered") {
2158
+ updateSteeringLifecycleTarget(ack.requestId, ack.index, late ? "late" : "delivered", now, { reason: late ? "acknowledged after recovery commit" : undefined });
2159
+ emitSteeringEvent("subagent.steer.delivered", { type: "steer", id: ack.requestId, ts: now, message: ack.message }, ack.index, { late, message: ack.message });
2160
+ } else {
2161
+ markSteeringAttention(ack.index);
2162
+ updateSteeringLifecycleTarget(ack.requestId, ack.index, "failed", now, { reason: ack.message });
2163
+ emitSteeringEvent("subagent.steer.failed", { type: "steer", id: ack.requestId, ts: now, message: ack.message }, ack.index, { reason: ack.message });
1770
2164
  }
1771
- appendJsonl(eventsPath, JSON.stringify({
1772
- type: "subagent.steer.requested",
1773
- ts: now,
1774
- runId: id,
1775
- requestId: request.id,
1776
- message: request.message,
1777
- ...(request.source ? { source: request.source } : {}),
1778
- ...(request.targetIndex !== undefined ? { targetIndex: request.targetIndex } : {}),
1779
- acceptedIndexes: accepted,
1780
- ...(rejected.length ? { rejected } : {}),
1781
- }));
2165
+ emitTerminalSteeringNotice(ack.requestId, `Steering failed for run ${id}: ${ack.message}`);
2166
+ statusPayload.lastUpdate = now;
2167
+ writeStatusPayload();
1782
2168
  };
1783
2169
  const flushPendingStepSteers = (flatIndex: number): void => {
1784
2170
  const remaining: SteerRequest[] = [];
@@ -1797,25 +2183,51 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1797
2183
  statusPayload.lastUpdate = now;
1798
2184
  writeStatusPayload();
1799
2185
  };
1800
- const updateStepTurnBudget = (flatIndex: number, turnCount: number, now: number, terminalAssistantStop: boolean): void => {
2186
+ const updateStepTurnBudget = (
2187
+ flatIndex: number,
2188
+ turnCount: number,
2189
+ now: number,
2190
+ terminalAssistantStop: boolean,
2191
+ toolWorkActiveOrStarting: boolean,
2192
+ ): void => {
1801
2193
  const budget = config.turnBudget;
1802
2194
  const step = statusPayload.steps[flatIndex];
1803
- if (!budget || !step || timedOut || turnBudgetExceeded || step.turnBudgetExceeded) return;
2195
+ if (!budget || !step || timedOut || stopped || turnBudgetExceeded || step.turnBudgetExceeded) return;
1804
2196
  if (turnCount < budget.maxTurns) {
1805
2197
  const state: TurnBudgetState = { ...budget, outcome: "within-budget", turnCount };
1806
2198
  step.turnBudget = state;
1807
2199
  statusPayload.turnBudget = state;
1808
2200
  return;
1809
2201
  }
1810
- const state = turnBudgetState(budget, turnCount, false);
1811
- step.turnBudget = state;
1812
- statusPayload.turnBudget = state;
1813
2202
  if (!step.wrapUpRequested) {
1814
2203
  step.wrapUpRequested = true;
1815
2204
  statusPayload.wrapUpRequested = true;
1816
2205
  appendRecentStepOutput(step, [turnBudgetSoftNote(budget, turnCount)]);
1817
2206
  }
1818
- if (!shouldAbortForTurnBudget(budget, turnCount, terminalAssistantStop)) return;
2207
+ const decision = turnBudgetDecision(budget, turnCount, terminalAssistantStop, toolWorkActiveOrStarting);
2208
+ if (decision === "defer") {
2209
+ const firstDeferredTurn = step.turnBudget?.terminationDeferredAtTurn;
2210
+ const deferredState = turnBudgetDeferredState(budget, turnCount, firstDeferredTurn);
2211
+ step.turnBudget = deferredState;
2212
+ statusPayload.turnBudget = deferredState;
2213
+ if (firstDeferredTurn === undefined) {
2214
+ appendJsonl(eventsPath, JSON.stringify({
2215
+ type: "subagent.step.turn_budget_deferred",
2216
+ ts: now,
2217
+ runId: id,
2218
+ stepIndex: flatIndex,
2219
+ agent: step.agent,
2220
+ turnCount,
2221
+ maxTurns: budget.maxTurns,
2222
+ graceTurns: budget.graceTurns,
2223
+ }));
2224
+ }
2225
+ return;
2226
+ }
2227
+ const state = turnBudgetState(budget, turnCount, false);
2228
+ step.turnBudget = state;
2229
+ statusPayload.turnBudget = state;
2230
+ if (decision !== "abort") return;
1819
2231
  const exceededState = turnBudgetState(budget, turnCount, true);
1820
2232
  const message = turnBudgetExceededMessage(budget, turnCount);
1821
2233
  step.turnBudget = exceededState;
@@ -1836,6 +2248,22 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1836
2248
  if (!step) return;
1837
2249
  const now = Date.now();
1838
2250
  statusPayload.currentStep = flatIndex;
2251
+ if (isChildWatchdogStatusEvent(event)) {
2252
+ const next = acceptChildWatchdogEvent({
2253
+ current: step.watchdog,
2254
+ event,
2255
+ runId: id,
2256
+ agent: step.agent,
2257
+ childIndex: flatIndex,
2258
+ });
2259
+ if (!next) return;
2260
+ step.watchdog = next;
2261
+ step.lastActivityAt = now;
2262
+ statusPayload.lastActivityAt = now;
2263
+ statusPayload.lastUpdate = now;
2264
+ writeStatusPayload();
2265
+ return;
2266
+ }
1839
2267
  if (event.type === "tool_execution_start" && event.toolName) {
1840
2268
  const mutates = isMutatingTool(event.toolName, event.args);
1841
2269
  const currentPath = resolveCurrentPath(event.toolName, event.args);
@@ -1925,7 +2353,13 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1925
2353
  statusPayload.totalTokens = { input: totalInput + input, output: totalOutput + output, total: totalInput + totalOutput + input + output };
1926
2354
  }
1927
2355
  statusPayload.turnCount = Math.max(statusPayload.turnCount ?? 0, step.turnCount);
1928
- updateStepTurnBudget(flatIndex, step.turnCount, now, isTerminalAssistantStop(event.message));
2356
+ updateStepTurnBudget(
2357
+ flatIndex,
2358
+ step.turnCount,
2359
+ now,
2360
+ isTerminalAssistantStop(event.message),
2361
+ assistantStartsToolCall(event.message) || Boolean(step.currentTool),
2362
+ );
1929
2363
  }
1930
2364
  syncTopLevelCurrentTool();
1931
2365
  step.lastActivityAt = now;
@@ -1951,6 +2385,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1951
2385
  config: controlConfig,
1952
2386
  startedAt: step.startedAt ?? overallStartTime,
1953
2387
  lastActivityAt,
2388
+ currentTool: step.currentTool,
1954
2389
  now,
1955
2390
  });
1956
2391
  if (idleState === "needs_attention") {
@@ -2026,8 +2461,40 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2026
2461
  interruptNestedAsyncDescendants();
2027
2462
  interruptActiveChildren();
2028
2463
  };
2464
+ const stopRunner = () => {
2465
+ if (stopped || timedOut || interrupted || statusPayload.state !== "running") return;
2466
+ stopped = true;
2467
+ const now = Date.now();
2468
+ statusPayload.state = "stopped";
2469
+ statusPayload.stopped = true;
2470
+ statusPayload.error = stopMessage;
2471
+ currentActivityState = undefined;
2472
+ statusPayload.activityState = undefined;
2473
+ statusPayload.lastUpdate = now;
2474
+ for (const step of statusPayload.steps) {
2475
+ if (step.status !== "running" && step.status !== "pending") continue;
2476
+ step.status = "stopped";
2477
+ step.error = stopMessage;
2478
+ step.exitCode = 1;
2479
+ step.stopped = true;
2480
+ step.activityState = undefined;
2481
+ step.endedAt = now;
2482
+ step.durationMs = step.startedAt ? now - step.startedAt : 0;
2483
+ step.lastActivityAt = now;
2484
+ }
2485
+ writeStatusPayload();
2486
+ appendJsonl(eventsPath, JSON.stringify({
2487
+ type: "subagent.run.stopped",
2488
+ ts: now,
2489
+ runId: id,
2490
+ message: stopMessage,
2491
+ }));
2492
+ stopAbortController.abort();
2493
+ stopNestedAsyncDescendants();
2494
+ stopActiveChildren();
2495
+ };
2029
2496
  const timeoutRunner = () => {
2030
- if (timedOut || interrupted || statusPayload.state !== "running") return;
2497
+ if (timedOut || stopped || interrupted || statusPayload.state !== "running") return;
2031
2498
  timedOut = true;
2032
2499
  const now = Date.now();
2033
2500
  const message = timeoutMessage ?? "Subagent timed out.";
@@ -2068,12 +2535,36 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2068
2535
  const disposeControlInbox = watchAsyncControlInbox(asyncDir, {
2069
2536
  onInterrupt: interruptRunner,
2070
2537
  onTimeout: timeoutRunner,
2538
+ onStop: stopRunner,
2071
2539
  onSteer: (request) => {
2072
2540
  const targetStep = request.targetIndex !== undefined ? statusPayload.steps[request.targetIndex] : undefined;
2073
- if (targetStep?.status === "pending") pendingStepSteers.push(request);
2074
- else if (request.targetIndex !== undefined || statusPayload.steps.some((step) => step.status === "running")) deliverSteerRequest(request);
2075
- else pendingStepSteers.push(request);
2541
+ if (targetStep?.status === "pending") {
2542
+ deliverSteerRequest(request);
2543
+ pendingStepSteers.push(request);
2544
+ } else if (request.targetIndexes !== undefined || request.targetIndex !== undefined || statusPayload.steps.some((step) => step.status === "running")) {
2545
+ deliverSteerRequest(request);
2546
+ } else {
2547
+ deliverSteerRequest(request);
2548
+ pendingStepSteers.push(request);
2549
+ }
2550
+ },
2551
+ onSteerCapability: (capability) => {
2552
+ steeringCapabilities.set(capability.index, capability);
2553
+ if (!capability.supported) {
2554
+ const now = Date.now();
2555
+ const lifecycle = steeringStatus(statusPayload);
2556
+ for (const request of lifecycle.recent) {
2557
+ if (!request.targets.some((target) => target.index === capability.index && (target.state === "routed" || target.state === "scheduled"))) continue;
2558
+ markSteeringAttention(capability.index);
2559
+ updateSteeringLifecycleTarget(request.id, capability.index, "failed", now, { reason: "child Pi session does not support steering" });
2560
+ emitSteeringEvent("subagent.steer.failed", { type: "steer", id: request.id, ts: request.requestedAt, message: "child Pi session does not support steering" }, capability.index, { reason: "child Pi session does not support steering" });
2561
+ emitTerminalSteeringNotice(request.id, `Steering failed for run ${id}: child ${capability.index} does not support steering.`);
2562
+ }
2563
+ statusPayload.lastUpdate = now;
2564
+ writeStatusPayload();
2565
+ }
2076
2566
  },
2567
+ onSteerAck: consumeSteerAck,
2077
2568
  });
2078
2569
  if (config.deadlineAt !== undefined) {
2079
2570
  const remainingMs = Math.max(0, config.deadlineAt - Date.now());
@@ -2097,7 +2588,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2097
2588
  let stepCursor = 0;
2098
2589
 
2099
2590
  while (true) {
2100
- if (interrupted || timedOut || turnBudgetExceeded) break;
2591
+ if (interrupted || timedOut || stopped || turnBudgetExceeded) break;
2101
2592
  consumePendingAppendRequests();
2102
2593
  if (stepCursor >= steps.length) break;
2103
2594
  const stepIndex = stepCursor++;
@@ -2108,6 +2599,9 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2108
2599
  let materialized: ReturnType<typeof materializeDynamicParallelStep>;
2109
2600
  try {
2110
2601
  materialized = materializeDynamicParallelStep(step as Parameters<typeof materializeDynamicParallelStep>[0], outputs, stepIndex, { maxItems: config.dynamicFanoutMaxItems, allowRunnerFields: true });
2602
+ if (materialized.parallel.length > 1 && step.parallel.outputPath && !step.parallel.namespaceOutputPath) {
2603
+ throw new DynamicFanoutError(`Dynamic chain step ${stepIndex + 1} materialized ${materialized.parallel.length} items that resolve output to the same path: ${step.parallel.outputPath}. Remove the explicit output path or use an inherited relative agent output so each item can be isolated.`);
2604
+ }
2111
2605
  if (materialized.collectedOnEmpty) validateDynamicCollection(step.collect.outputSchema, materialized.collectedOnEmpty);
2112
2606
  } catch (error) {
2113
2607
  const now = Date.now();
@@ -2131,6 +2625,16 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2131
2625
  break;
2132
2626
  }
2133
2627
 
2628
+ const effectiveDynamicGroupAcceptance = resolveEffectiveAcceptance({
2629
+ explicit: step.acceptanceInput,
2630
+ agentName: step.parallel.agent,
2631
+ acceptanceRole: step.acceptanceRole,
2632
+ task: materialized.parallel.map((task) => task.task ?? step.parallel.task).join("\n") || step.parallel.task,
2633
+ mode: config.mode,
2634
+ async: true,
2635
+ dynamicGroup: true,
2636
+ });
2637
+
2134
2638
  if (materialized.parallel.length === 0) {
2135
2639
  const now = Date.now();
2136
2640
  const collection = materialized.collectedOnEmpty ?? [];
@@ -2149,37 +2653,40 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2149
2653
  placeholder.durationMs = 0;
2150
2654
  }
2151
2655
  previousOutput = "Dynamic fanout produced 0 results.";
2152
- const groupAcceptance = step.effectiveAcceptance?.explicit && !timedOut
2656
+ const groupAcceptance = effectiveDynamicGroupAcceptance.explicit && !timedOut && !stopped
2153
2657
  ? await evaluateAcceptance({
2154
- acceptance: step.effectiveAcceptance,
2658
+ acceptance: effectiveDynamicGroupAcceptance,
2155
2659
  output: "",
2156
2660
  report: aggregateAcceptanceReport({
2157
2661
  results: [],
2158
2662
  notes: "Dynamic fanout produced 0 results.",
2159
2663
  }),
2160
2664
  cwd,
2161
- signal: timeoutAbortController.signal,
2162
- abortMessage: timeoutMessage ?? "Subagent timed out.",
2665
+ signal: combinedAbortSignal([timeoutAbortController.signal, stopAbortController.signal]),
2666
+ abortMessage: stopAbortController.signal.aborted ? stopMessage : timeoutMessage ?? "Subagent timed out.",
2163
2667
  })
2164
2668
  : undefined;
2165
- const groupTimedOut = timedOut || timeoutAbortController.signal.aborted;
2166
- const effectiveGroupAcceptance = groupTimedOut ? undefined : groupAcceptance;
2669
+ const groupStopped = stopped || stopAbortController.signal.aborted;
2670
+ const groupTimedOut = !groupStopped && (timedOut || timeoutAbortController.signal.aborted);
2671
+ const effectiveGroupAcceptance = groupTimedOut || groupStopped ? undefined : groupAcceptance;
2167
2672
  if (placeholder && effectiveGroupAcceptance) placeholder.acceptance = effectiveGroupAcceptance;
2168
2673
  const groupAcceptanceFailure = effectiveGroupAcceptance ? acceptanceFailureMessage(effectiveGroupAcceptance) : undefined;
2169
- if (groupTimedOut || groupAcceptanceFailure) {
2170
- const errorMessage = groupTimedOut ? timeoutMessage ?? "Subagent timed out." : groupAcceptanceFailure!;
2171
- statusPayload.state = "failed";
2674
+ if (groupTimedOut || groupStopped || groupAcceptanceFailure) {
2675
+ const errorMessage = groupStopped ? stopMessage : groupTimedOut ? timeoutMessage ?? "Subagent timed out." : groupAcceptanceFailure!;
2676
+ statusPayload.state = groupStopped ? "stopped" : "failed";
2172
2677
  statusPayload.error = errorMessage;
2678
+ statusPayload.stopped = groupStopped ? true : statusPayload.stopped;
2173
2679
  if (placeholder) {
2174
- placeholder.status = "failed";
2680
+ placeholder.status = groupStopped ? "stopped" : "failed";
2175
2681
  placeholder.error = errorMessage;
2176
2682
  placeholder.exitCode = 1;
2177
2683
  placeholder.timedOut = groupTimedOut ? true : undefined;
2684
+ placeholder.stopped = groupStopped ? true : undefined;
2178
2685
  }
2179
- markDynamicGraphGroup(stepIndex, "failed", errorMessage, effectiveGroupAcceptance);
2686
+ markDynamicGraphGroup(stepIndex, groupStopped ? "stopped" : "failed", errorMessage, effectiveGroupAcceptance);
2180
2687
  statusPayload.lastUpdate = Date.now();
2181
2688
  writeStatusPayload();
2182
- results.push({ agent: step.parallel.agent, output: errorMessage, error: errorMessage, success: false, exitCode: 1, timedOut: groupTimedOut ? true : undefined, acceptance: effectiveGroupAcceptance });
2689
+ results.push({ agent: step.parallel.agent, output: errorMessage, error: errorMessage, success: false, exitCode: 1, timedOut: groupTimedOut ? true : undefined, stopped: groupStopped ? true : undefined, acceptance: effectiveGroupAcceptance });
2183
2690
  break;
2184
2691
  }
2185
2692
  flatIndex++;
@@ -2193,9 +2700,25 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2193
2700
  const thinkingOverride = step.thinkingOverrides?.[itemIndex];
2194
2701
  const model = thinkingOverride ? applyThinkingSuffix(step.parallel.model, thinkingOverride, true) : step.parallel.model;
2195
2702
  const thinking = thinkingOverride ? resolveEffectiveThinking(model, thinkingOverride) : undefined;
2703
+ const outputPath = step.parallel.namespaceOutputPath && step.parallel.outputPath
2704
+ ? path.join(path.dirname(step.parallel.outputPath), `dynamic-${stepIndex}`, `${itemIndex}-${step.parallel.agent}`, path.basename(step.parallel.outputPath))
2705
+ : step.parallel.outputPath;
2706
+ const taskText = task.task ?? step.parallel.task;
2707
+ const materializedTask = step.parallel.namespaceOutputPath ? injectSingleOutputInstruction(taskText, outputPath, step.parallel) : taskText;
2196
2708
  return {
2197
2709
  ...step.parallel,
2198
- task: task.task ?? step.parallel.task,
2710
+ task: materializedTask,
2711
+ effectiveAcceptance: resolveEffectiveAcceptance({
2712
+ explicit: step.parallel.acceptanceInput,
2713
+ agentName: step.parallel.agent,
2714
+ acceptanceRole: step.parallel.acceptanceRole,
2715
+ task: materializedTask,
2716
+ mode: config.mode,
2717
+ async: true,
2718
+ dynamic: step.parallel.acceptanceInput === undefined,
2719
+ }),
2720
+ systemPrompt: step.parallel.namespaceOutputPath ? injectOutputPathSystemPrompt(step.parallel.systemPrompt ?? "", outputPath, step.parallel) : step.parallel.systemPrompt,
2721
+ outputPath,
2199
2722
  label: task.label ?? step.parallel.label,
2200
2723
  ...(step.sessionFiles?.[itemIndex] ? { sessionFile: step.sessionFiles[itemIndex] } : {}),
2201
2724
  ...(thinkingOverride ? {
@@ -2276,6 +2799,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2276
2799
  const parallelResults = await mapConcurrent(dynamicSteps, concurrency, async (task, taskIdx) => {
2277
2800
  const fi = groupStartFlatIndex + taskIdx;
2278
2801
  if (timedOut) return timedOutStepResult(task.agent);
2802
+ if (stopped) return stoppedStepResult(task.agent);
2279
2803
  if (interrupted) return pausedStepResult(task.agent);
2280
2804
  if (aborted && failFast) {
2281
2805
  const skippedAt = Date.now();
@@ -2311,6 +2835,8 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2311
2835
  flatIndex: fi, flatStepCount: Math.max(statusPayload.steps.length, 1),
2312
2836
  outputFile: path.join(asyncDir, `output-${fi}.log`),
2313
2837
  steerInboxDir: stepSteerInboxDir(asyncDir, fi),
2838
+ steerCapabilityPath: steerCapabilityPath(asyncDir, fi),
2839
+ steerAckDir: steerAcksDir(asyncDir, fi),
2314
2840
  piPackageRoot: config.piPackageRoot,
2315
2841
  piArgv1: config.piArgv1,
2316
2842
  childIntercomTarget: config.childIntercomTargets?.[fi],
@@ -2318,21 +2844,27 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2318
2844
  nestedRoute: config.nestedRoute,
2319
2845
  registerInterrupt: (interrupt) => registerStepInterrupt(fi, interrupt),
2320
2846
  registerTimeout: (interrupt) => registerStepTimeout(fi, interrupt),
2847
+ registerStop: (stop) => registerStepStop(fi, stop),
2321
2848
  registerTurnBudgetAbort: (abort) => registerStepTurnBudgetAbort(fi, abort),
2322
2849
  timeoutSignal: timeoutAbortController.signal,
2850
+ stopSignal: stopAbortController.signal,
2323
2851
  timeoutMessage,
2852
+ stopMessage,
2324
2853
  turnBudget: config.turnBudget,
2325
2854
  onAttemptStart: (attempt) => updateStepModel(fi, attempt.model, attempt.thinking),
2326
2855
  onChildEvent: (event) => updateStepFromChildEvent(fi, event),
2327
- skipAcceptance: () => timedOut,
2856
+ onWriterProcess,
2857
+ skipAcceptance: () => timedOut || stopped,
2328
2858
  });
2329
2859
  const taskEndTime = Date.now();
2330
2860
  const childInterrupted = singleResult.interrupted === true;
2331
- statusPayload.steps[fi].status = timedOut ? "failed" : childInterrupted ? "paused" : singleResult.exitCode === 0 ? "complete" : "failed";
2861
+ const childStopped = singleResult.stopped === true;
2862
+ statusPayload.steps[fi].status = stopped || childStopped ? "stopped" : timedOut ? "failed" : childInterrupted ? "paused" : singleResult.exitCode === 0 ? "complete" : "failed";
2332
2863
  statusPayload.steps[fi].endedAt = taskEndTime;
2333
2864
  statusPayload.steps[fi].durationMs = taskEndTime - taskStartTime;
2334
- statusPayload.steps[fi].exitCode = timedOut ? 1 : childInterrupted ? 0 : singleResult.exitCode;
2865
+ statusPayload.steps[fi].exitCode = stopped || childStopped ? 1 : timedOut ? 1 : childInterrupted ? 0 : singleResult.exitCode;
2335
2866
  statusPayload.steps[fi].timedOut = timedOut || singleResult.timedOut ? true : undefined;
2867
+ statusPayload.steps[fi].stopped = stopped || childStopped ? true : undefined;
2336
2868
  statusPayload.steps[fi].turnBudget = singleResult.turnBudget;
2337
2869
  statusPayload.steps[fi].turnBudgetExceeded = singleResult.turnBudgetExceeded;
2338
2870
  statusPayload.steps[fi].wrapUpRequested = singleResult.wrapUpRequested;
@@ -2348,22 +2880,23 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2348
2880
  statusPayload.steps[fi].attemptedModels = singleResult.attemptedModels;
2349
2881
  statusPayload.steps[fi].modelAttempts = singleResult.modelAttempts;
2350
2882
  statusPayload.steps[fi].totalCost = singleResult.totalCost;
2351
- statusPayload.steps[fi].error = timedOut ? (timeoutMessage ?? "Subagent timed out.") : singleResult.error;
2883
+ statusPayload.steps[fi].error = stopped || childStopped ? stopMessage : timedOut ? (timeoutMessage ?? "Subagent timed out.") : singleResult.error;
2352
2884
  statusPayload.steps[fi].transcriptPath = singleResult.transcriptPath ?? statusPayload.steps[fi].transcriptPath;
2353
2885
  statusPayload.steps[fi].transcriptError = singleResult.transcriptError;
2354
2886
  statusPayload.steps[fi].structuredOutput = singleResult.structuredOutput;
2355
2887
  statusPayload.steps[fi].structuredOutputPath = singleResult.structuredOutputPath;
2356
2888
  statusPayload.steps[fi].structuredOutputSchemaPath = singleResult.structuredOutputSchemaPath;
2357
2889
  statusPayload.steps[fi].acceptance = singleResult.acceptance;
2890
+ statusPayload.steps[fi].watchdog = singleResult.watchdog;
2358
2891
  statusPayload.lastUpdate = taskEndTime;
2359
2892
  writeStatusPayload();
2360
2893
  appendJsonl(eventsPath, JSON.stringify({
2361
- type: timedOut ? "subagent.step.failed" : childInterrupted ? "subagent.step.paused" : singleResult.exitCode === 0 ? "subagent.step.completed" : "subagent.step.failed",
2894
+ type: stopped || childStopped ? "subagent.step.stopped" : timedOut ? "subagent.step.failed" : childInterrupted ? "subagent.step.paused" : singleResult.exitCode === 0 ? "subagent.step.completed" : "subagent.step.failed",
2362
2895
  ts: taskEndTime, runId: id, stepIndex: fi, agent: task.agent,
2363
- exitCode: timedOut ? 1 : childInterrupted ? 0 : singleResult.exitCode, durationMs: taskEndTime - taskStartTime,
2896
+ exitCode: stopped || childStopped ? 1 : timedOut ? 1 : childInterrupted ? 0 : singleResult.exitCode, durationMs: taskEndTime - taskStartTime,
2364
2897
  }));
2365
2898
  if (singleResult.exitCode !== 0 && failFast) aborted = true;
2366
- return timedOut ? { ...singleResult, output: timeoutMessage ?? "Subagent timed out.", error: timeoutMessage ?? "Subagent timed out.", exitCode: 1, interrupted: false, timedOut: true, skipped: false } : { ...singleResult, skipped: false };
2899
+ return stopped || childStopped ? { ...singleResult, output: stopMessage, error: stopMessage, exitCode: 1, interrupted: false, timedOut: false, stopped: true, skipped: false } : timedOut ? { ...singleResult, output: timeoutMessage ?? "Subagent timed out.", error: timeoutMessage ?? "Subagent timed out.", exitCode: 1, interrupted: false, timedOut: true, skipped: false } : { ...singleResult, skipped: false };
2367
2900
  }, globalSemaphore);
2368
2901
 
2369
2902
  flatIndex += dynamicSteps.length;
@@ -2372,11 +2905,13 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2372
2905
  agent: pr.agent,
2373
2906
  output: pr.output,
2374
2907
  error: pr.error,
2375
- success: pr.interrupted !== true && pr.exitCode === 0,
2908
+ protocolError: pr.protocolError,
2909
+ success: pr.stopped !== true && pr.interrupted !== true && pr.exitCode === 0,
2376
2910
  exitCode: pr.interrupted === true ? 0 : pr.exitCode,
2377
2911
  skipped: pr.skipped,
2378
2912
  interrupted: pr.interrupted,
2379
2913
  timedOut: pr.timedOut,
2914
+ stopped: pr.stopped,
2380
2915
  turnBudget: pr.turnBudget,
2381
2916
  turnBudgetExceeded: pr.turnBudgetExceeded,
2382
2917
  wrapUpRequested: pr.wrapUpRequested,
@@ -2395,6 +2930,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2395
2930
  structuredOutputPath: pr.structuredOutputPath,
2396
2931
  structuredOutputSchemaPath: pr.structuredOutputSchemaPath,
2397
2932
  acceptance: pr.acceptance,
2933
+ watchdog: pr.watchdog,
2398
2934
  });
2399
2935
  }
2400
2936
  const collection = collectDynamicResults(step as Parameters<typeof collectDynamicResults>[0], materialized.items, parallelResults);
@@ -2409,24 +2945,25 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2409
2945
  stepIndex,
2410
2946
  };
2411
2947
  statusPayload.outputs = outputs;
2412
- const groupAcceptance = step.effectiveAcceptance && !timedOut
2948
+ const groupAcceptance = !timedOut && !stopped
2413
2949
  ? await evaluateAcceptance({
2414
- acceptance: step.effectiveAcceptance,
2950
+ acceptance: effectiveDynamicGroupAcceptance,
2415
2951
  output: "",
2416
2952
  report: aggregateAcceptanceReport({
2417
2953
  results: parallelResults,
2418
2954
  notes: `Dynamic fanout collected ${collection.length} result(s) into ${step.collect.as}.`,
2419
2955
  }),
2420
2956
  cwd,
2421
- signal: timeoutAbortController.signal,
2422
- abortMessage: timeoutMessage ?? "Subagent timed out.",
2957
+ signal: combinedAbortSignal([timeoutAbortController.signal, stopAbortController.signal]),
2958
+ abortMessage: stopAbortController.signal.aborted ? stopMessage : timeoutMessage ?? "Subagent timed out.",
2423
2959
  })
2424
2960
  : undefined;
2425
- const groupTimedOut = timedOut || timeoutAbortController.signal.aborted;
2426
- const effectiveGroupAcceptance = groupTimedOut ? undefined : groupAcceptance;
2427
- const groupAcceptanceFailure = effectiveGroupAcceptance ? acceptanceFailureMessage(effectiveGroupAcceptance) : undefined;
2428
- const groupError = groupTimedOut ? timeoutMessage ?? "Subagent timed out." : groupAcceptanceFailure;
2429
- markDynamicGraphGroup(stepIndex, groupError ? "failed" : "completed", groupError, effectiveGroupAcceptance);
2961
+ const groupStopped = stopped || stopAbortController.signal.aborted;
2962
+ const groupTimedOut = !groupStopped && (timedOut || timeoutAbortController.signal.aborted);
2963
+ const effectiveGroupAcceptance = groupTimedOut || groupStopped ? undefined : groupAcceptance;
2964
+ const groupAcceptanceFailure = effectiveDynamicGroupAcceptance.explicit && effectiveGroupAcceptance ? acceptanceFailureMessage(effectiveGroupAcceptance) : undefined;
2965
+ const groupError = groupStopped ? stopMessage : groupTimedOut ? timeoutMessage ?? "Subagent timed out." : groupAcceptanceFailure;
2966
+ markDynamicGraphGroup(stepIndex, groupError ? groupStopped ? "stopped" : "failed" : "completed", groupError, effectiveGroupAcceptance);
2430
2967
  if (groupError) {
2431
2968
  results.push({
2432
2969
  agent: step.parallel.agent,
@@ -2435,10 +2972,12 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2435
2972
  success: false,
2436
2973
  exitCode: 1,
2437
2974
  timedOut: groupTimedOut ? true : undefined,
2975
+ stopped: groupStopped ? true : undefined,
2438
2976
  structuredOutput: collection,
2439
2977
  acceptance: effectiveGroupAcceptance,
2440
2978
  });
2441
2979
  statusPayload.error = groupError;
2980
+ statusPayload.stopped = groupStopped ? true : statusPayload.stopped;
2442
2981
  }
2443
2982
  } catch (error) {
2444
2983
  const message = error instanceof DynamicFanoutError ? error.message : error instanceof Error ? error.message : String(error);
@@ -2547,6 +3086,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2547
3086
  async (task, taskIdx) => {
2548
3087
  const fi = groupStartFlatIndex + taskIdx;
2549
3088
  if (timedOut) return timedOutStepResult(task.agent);
3089
+ if (stopped) return stoppedStepResult(task.agent);
2550
3090
  if (interrupted) return pausedStepResult(task.agent);
2551
3091
  if (aborted && failFast) {
2552
3092
  const skippedAt = Date.now();
@@ -2598,6 +3138,8 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2598
3138
  flatIndex: fi, flatStepCount: Math.max(statusPayload.steps.length, 1),
2599
3139
  outputFile: path.join(asyncDir, `output-${fi}.log`),
2600
3140
  steerInboxDir: stepSteerInboxDir(asyncDir, fi),
3141
+ steerCapabilityPath: steerCapabilityPath(asyncDir, fi),
3142
+ steerAckDir: steerAcksDir(asyncDir, fi),
2601
3143
  piPackageRoot: config.piPackageRoot,
2602
3144
  piArgv1: config.piArgv1,
2603
3145
  childIntercomTarget: config.childIntercomTargets?.[fi],
@@ -2605,13 +3147,17 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2605
3147
  nestedRoute: config.nestedRoute,
2606
3148
  registerInterrupt: (interrupt) => registerStepInterrupt(fi, interrupt),
2607
3149
  registerTimeout: (interrupt) => registerStepTimeout(fi, interrupt),
3150
+ registerStop: (stop) => registerStepStop(fi, stop),
2608
3151
  registerTurnBudgetAbort: (abort) => registerStepTurnBudgetAbort(fi, abort),
2609
3152
  timeoutSignal: timeoutAbortController.signal,
3153
+ stopSignal: stopAbortController.signal,
2610
3154
  timeoutMessage,
3155
+ stopMessage,
2611
3156
  turnBudget: config.turnBudget,
2612
3157
  onAttemptStart: (attempt) => updateStepModel(fi, attempt.model, attempt.thinking),
2613
3158
  onChildEvent: (event) => updateStepFromChildEvent(fi, event),
2614
- skipAcceptance: () => timedOut,
3159
+ onWriterProcess,
3160
+ skipAcceptance: () => timedOut || stopped,
2615
3161
  });
2616
3162
  if (task.sessionFile) {
2617
3163
  latestSessionFile = task.sessionFile;
@@ -2620,12 +3166,14 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2620
3166
  const taskEndTime = Date.now();
2621
3167
  const taskDuration = taskEndTime - taskStartTime;
2622
3168
  const childInterrupted = singleResult.interrupted === true;
3169
+ const childStopped = singleResult.stopped === true;
2623
3170
 
2624
- statusPayload.steps[fi].status = timedOut ? "failed" : childInterrupted ? "paused" : singleResult.exitCode === 0 ? "complete" : "failed";
3171
+ statusPayload.steps[fi].status = stopped || childStopped ? "stopped" : timedOut ? "failed" : childInterrupted ? "paused" : singleResult.exitCode === 0 ? "complete" : "failed";
2625
3172
  statusPayload.steps[fi].endedAt = taskEndTime;
2626
3173
  statusPayload.steps[fi].durationMs = taskDuration;
2627
- statusPayload.steps[fi].exitCode = timedOut ? 1 : childInterrupted ? 0 : singleResult.exitCode;
3174
+ statusPayload.steps[fi].exitCode = stopped || childStopped ? 1 : timedOut ? 1 : childInterrupted ? 0 : singleResult.exitCode;
2628
3175
  statusPayload.steps[fi].timedOut = timedOut || singleResult.timedOut ? true : undefined;
3176
+ statusPayload.steps[fi].stopped = stopped || childStopped ? true : undefined;
2629
3177
  statusPayload.steps[fi].turnBudget = singleResult.turnBudget;
2630
3178
  statusPayload.steps[fi].turnBudgetExceeded = singleResult.turnBudgetExceeded;
2631
3179
  statusPayload.steps[fi].wrapUpRequested = singleResult.wrapUpRequested;
@@ -2641,20 +3189,21 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2641
3189
  statusPayload.steps[fi].attemptedModels = singleResult.attemptedModels;
2642
3190
  statusPayload.steps[fi].modelAttempts = singleResult.modelAttempts;
2643
3191
  statusPayload.steps[fi].totalCost = singleResult.totalCost;
2644
- statusPayload.steps[fi].error = timedOut ? (timeoutMessage ?? "Subagent timed out.") : singleResult.error;
3192
+ statusPayload.steps[fi].error = stopped || childStopped ? stopMessage : timedOut ? (timeoutMessage ?? "Subagent timed out.") : singleResult.error;
2645
3193
  statusPayload.steps[fi].transcriptPath = singleResult.transcriptPath ?? statusPayload.steps[fi].transcriptPath;
2646
3194
  statusPayload.steps[fi].transcriptError = singleResult.transcriptError;
2647
3195
  statusPayload.steps[fi].structuredOutput = singleResult.structuredOutput;
2648
3196
  statusPayload.steps[fi].structuredOutputPath = singleResult.structuredOutputPath;
2649
3197
  statusPayload.steps[fi].structuredOutputSchemaPath = singleResult.structuredOutputSchemaPath;
2650
3198
  statusPayload.steps[fi].acceptance = singleResult.acceptance;
3199
+ statusPayload.steps[fi].watchdog = singleResult.watchdog;
2651
3200
  statusPayload.lastUpdate = taskEndTime;
2652
3201
  writeStatusPayload();
2653
3202
 
2654
3203
  appendJsonl(eventsPath, JSON.stringify({
2655
- type: timedOut ? "subagent.step.failed" : childInterrupted ? "subagent.step.paused" : singleResult.exitCode === 0 ? "subagent.step.completed" : "subagent.step.failed",
3204
+ type: stopped || childStopped ? "subagent.step.stopped" : timedOut ? "subagent.step.failed" : childInterrupted ? "subagent.step.paused" : singleResult.exitCode === 0 ? "subagent.step.completed" : "subagent.step.failed",
2656
3205
  ts: taskEndTime, runId: id, stepIndex: fi, agent: task.agent,
2657
- exitCode: timedOut ? 1 : childInterrupted ? 0 : singleResult.exitCode, durationMs: taskDuration,
3206
+ exitCode: stopped || childStopped ? 1 : timedOut ? 1 : childInterrupted ? 0 : singleResult.exitCode, durationMs: taskDuration,
2658
3207
  }));
2659
3208
  if (singleResult.completionGuardTriggered) {
2660
3209
  const event = buildControlEvent({
@@ -2671,7 +3220,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2671
3220
  }
2672
3221
 
2673
3222
  if (singleResult.exitCode !== 0 && failFast) aborted = true;
2674
- return timedOut ? { ...singleResult, output: timeoutMessage ?? "Subagent timed out.", error: timeoutMessage ?? "Subagent timed out.", exitCode: 1, interrupted: false, timedOut: true, skipped: false } : { ...singleResult, skipped: false };
3223
+ return stopped || childStopped ? { ...singleResult, output: stopMessage, error: stopMessage, exitCode: 1, interrupted: false, timedOut: false, stopped: true, skipped: false } : timedOut ? { ...singleResult, output: timeoutMessage ?? "Subagent timed out.", error: timeoutMessage ?? "Subagent timed out.", exitCode: 1, interrupted: false, timedOut: true, skipped: false } : { ...singleResult, skipped: false };
2675
3224
  },
2676
3225
  globalSemaphore,
2677
3226
  );
@@ -2701,11 +3250,13 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2701
3250
  agent: pr.agent,
2702
3251
  output: pr.output,
2703
3252
  error: pr.error,
2704
- success: pr.interrupted !== true && pr.exitCode === 0,
3253
+ protocolError: pr.protocolError,
3254
+ success: pr.stopped !== true && pr.interrupted !== true && pr.exitCode === 0,
2705
3255
  exitCode: pr.interrupted === true ? 0 : pr.exitCode,
2706
3256
  skipped: pr.skipped,
2707
3257
  interrupted: pr.interrupted,
2708
3258
  timedOut: pr.timedOut,
3259
+ stopped: pr.stopped,
2709
3260
  turnBudget: pr.turnBudget,
2710
3261
  turnBudgetExceeded: pr.turnBudgetExceeded,
2711
3262
  wrapUpRequested: pr.wrapUpRequested,
@@ -2720,12 +3271,13 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2720
3271
  artifactPaths: pr.artifactPaths,
2721
3272
  transcriptPath: pr.transcriptPath,
2722
3273
  transcriptError: pr.transcriptError,
2723
- structuredOutput: pr.structuredOutput,
2724
- structuredOutputPath: pr.structuredOutputPath,
2725
- structuredOutputSchemaPath: pr.structuredOutputSchemaPath,
2726
- acceptance: pr.acceptance,
2727
- });
2728
- }
3274
+ structuredOutput: pr.structuredOutput,
3275
+ structuredOutputPath: pr.structuredOutputPath,
3276
+ structuredOutputSchemaPath: pr.structuredOutputSchemaPath,
3277
+ acceptance: pr.acceptance,
3278
+ watchdog: pr.watchdog,
3279
+ });
3280
+ }
2729
3281
  for (let t = 0; t < group.parallel.length; t++) {
2730
3282
  const outputName = group.parallel[t]?.outputName;
2731
3283
  if (outputName) outputs[outputName] = outputEntryFromAsyncResult({
@@ -2738,13 +3290,13 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2738
3290
 
2739
3291
  previousOutput = aggregateParallelOutputs(
2740
3292
  parallelResults.map((r) => ({
2741
- agent: r.agent,
2742
- output: r.output,
2743
- exitCode: r.exitCode,
2744
- error: r.error,
2745
- model: r.model,
2746
- attemptedModels: r.attemptedModels,
2747
- })),
3293
+ agent: r.agent,
3294
+ output: r.output,
3295
+ exitCode: r.exitCode,
3296
+ error: r.error,
3297
+ model: r.model,
3298
+ attemptedModels: r.attemptedModels,
3299
+ })),
2748
3300
  );
2749
3301
  previousOutput = appendParallelWorktreeSummary(previousOutput, worktreeSetup, asyncDir, stepIndex, group);
2750
3302
 
@@ -2789,12 +3341,14 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2789
3341
  flushPendingStepSteers(flatIndex);
2790
3342
  const singleResult = await runSingleStep(seqStep, {
2791
3343
  previousOutput, placeholder, cwd, sessionEnabled,
2792
- outputs,
3344
+ outputs: statusPayload.mode === "single" ? undefined : outputs,
2793
3345
  sessionDir: config.sessionDir,
2794
3346
  artifactsDir, artifactConfig, id,
2795
3347
  flatIndex, flatStepCount: Math.max(statusPayload.steps.length, 1),
2796
3348
  outputFile: path.join(asyncDir, `output-${flatIndex}.log`),
2797
3349
  steerInboxDir: stepSteerInboxDir(asyncDir, flatIndex),
3350
+ steerCapabilityPath: steerCapabilityPath(asyncDir, flatIndex),
3351
+ steerAckDir: steerAcksDir(asyncDir, flatIndex),
2798
3352
  piPackageRoot: config.piPackageRoot,
2799
3353
  piArgv1: config.piArgv1,
2800
3354
  childIntercomTarget: config.childIntercomTargets?.[flatIndex],
@@ -2802,25 +3356,31 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2802
3356
  nestedRoute: config.nestedRoute,
2803
3357
  registerInterrupt: (interrupt) => registerStepInterrupt(flatIndex, interrupt),
2804
3358
  registerTimeout: (interrupt) => registerStepTimeout(flatIndex, interrupt),
3359
+ registerStop: (stop) => registerStepStop(flatIndex, stop),
2805
3360
  registerTurnBudgetAbort: (abort) => registerStepTurnBudgetAbort(flatIndex, abort),
2806
3361
  timeoutSignal: timeoutAbortController.signal,
3362
+ stopSignal: stopAbortController.signal,
2807
3363
  timeoutMessage,
3364
+ stopMessage,
2808
3365
  turnBudget: config.turnBudget,
2809
3366
  onAttemptStart: (attempt) => updateStepModel(flatIndex, attempt.model, attempt.thinking),
2810
3367
  onChildEvent: (event) => updateStepFromChildEvent(flatIndex, event),
2811
- skipAcceptance: () => timedOut,
3368
+ onWriterProcess,
3369
+ skipAcceptance: () => timedOut || stopped,
2812
3370
  });
2813
3371
  if (seqStep.sessionFile) {
2814
3372
  latestSessionFile = seqStep.sessionFile;
2815
3373
  }
2816
3374
 
2817
3375
  previousOutput = singleResult.output;
3376
+ const childStopped = singleResult.stopped === true;
2818
3377
  results.push({
2819
3378
  agent: singleResult.agent,
2820
- output: timedOut ? (timeoutMessage ?? "Subagent timed out.") : singleResult.output,
2821
- error: timedOut ? (timeoutMessage ?? "Subagent timed out.") : singleResult.error,
2822
- success: !timedOut && singleResult.interrupted !== true && singleResult.exitCode === 0,
2823
- exitCode: timedOut ? 1 : singleResult.interrupted === true ? 0 : singleResult.exitCode,
3379
+ output: stopped || childStopped ? stopMessage : timedOut ? (timeoutMessage ?? "Subagent timed out.") : singleResult.output,
3380
+ error: stopped || childStopped ? stopMessage : timedOut ? (timeoutMessage ?? "Subagent timed out.") : singleResult.error,
3381
+ protocolError: singleResult.protocolError,
3382
+ success: !stopped && !childStopped && !timedOut && singleResult.interrupted !== true && singleResult.exitCode === 0,
3383
+ exitCode: stopped || childStopped ? 1 : timedOut ? 1 : singleResult.interrupted === true ? 0 : singleResult.exitCode,
2824
3384
  sessionFile: singleResult.sessionFile,
2825
3385
  intercomTarget: singleResult.intercomTarget,
2826
3386
  model: singleResult.model,
@@ -2834,8 +3394,10 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2834
3394
  structuredOutputPath: singleResult.structuredOutputPath,
2835
3395
  structuredOutputSchemaPath: singleResult.structuredOutputSchemaPath,
2836
3396
  acceptance: singleResult.acceptance,
3397
+ watchdog: singleResult.watchdog,
2837
3398
  interrupted: singleResult.interrupted,
2838
3399
  timedOut: timedOut || singleResult.timedOut ? true : undefined,
3400
+ stopped: stopped || childStopped ? true : undefined,
2839
3401
  turnBudget: singleResult.turnBudget,
2840
3402
  turnBudgetExceeded: singleResult.turnBudgetExceeded,
2841
3403
  wrapUpRequested: singleResult.wrapUpRequested,
@@ -2874,11 +3436,12 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2874
3436
 
2875
3437
  const stepEndTime = Date.now();
2876
3438
  const childInterrupted = singleResult.interrupted === true;
2877
- statusPayload.steps[flatIndex].status = timedOut ? "failed" : childInterrupted ? "paused" : singleResult.exitCode === 0 ? "complete" : "failed";
3439
+ statusPayload.steps[flatIndex].status = stopped || childStopped ? "stopped" : timedOut ? "failed" : childInterrupted ? "paused" : singleResult.exitCode === 0 ? "complete" : "failed";
2878
3440
  statusPayload.steps[flatIndex].endedAt = stepEndTime;
2879
3441
  statusPayload.steps[flatIndex].durationMs = stepEndTime - stepStartTime;
2880
- statusPayload.steps[flatIndex].exitCode = timedOut ? 1 : childInterrupted ? 0 : singleResult.exitCode;
3442
+ statusPayload.steps[flatIndex].exitCode = stopped || childStopped ? 1 : timedOut ? 1 : childInterrupted ? 0 : singleResult.exitCode;
2881
3443
  statusPayload.steps[flatIndex].timedOut = timedOut || singleResult.timedOut ? true : undefined;
3444
+ statusPayload.steps[flatIndex].stopped = stopped || childStopped ? true : undefined;
2882
3445
  statusPayload.steps[flatIndex].turnBudget = singleResult.turnBudget;
2883
3446
  statusPayload.steps[flatIndex].turnBudgetExceeded = singleResult.turnBudgetExceeded;
2884
3447
  statusPayload.steps[flatIndex].wrapUpRequested = singleResult.wrapUpRequested;
@@ -2894,13 +3457,14 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2894
3457
  statusPayload.steps[flatIndex].attemptedModels = singleResult.attemptedModels;
2895
3458
  statusPayload.steps[flatIndex].modelAttempts = singleResult.modelAttempts;
2896
3459
  statusPayload.steps[flatIndex].totalCost = singleResult.totalCost;
2897
- statusPayload.steps[flatIndex].error = timedOut ? (timeoutMessage ?? "Subagent timed out.") : singleResult.error;
3460
+ statusPayload.steps[flatIndex].error = stopped || childStopped ? stopMessage : timedOut ? (timeoutMessage ?? "Subagent timed out.") : singleResult.error;
2898
3461
  statusPayload.steps[flatIndex].transcriptPath = singleResult.transcriptPath ?? statusPayload.steps[flatIndex].transcriptPath;
2899
3462
  statusPayload.steps[flatIndex].transcriptError = singleResult.transcriptError;
2900
3463
  statusPayload.steps[flatIndex].structuredOutput = singleResult.structuredOutput;
2901
3464
  statusPayload.steps[flatIndex].structuredOutputPath = singleResult.structuredOutputPath;
2902
3465
  statusPayload.steps[flatIndex].structuredOutputSchemaPath = singleResult.structuredOutputSchemaPath;
2903
3466
  statusPayload.steps[flatIndex].acceptance = singleResult.acceptance;
3467
+ statusPayload.steps[flatIndex].watchdog = singleResult.watchdog;
2904
3468
  if (stepTokens) {
2905
3469
  statusPayload.steps[flatIndex].tokens = stepTokens;
2906
3470
  statusPayload.totalTokens = { ...previousCumulativeTokens };
@@ -2909,12 +3473,12 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2909
3473
  writeStatusPayload();
2910
3474
 
2911
3475
  appendJsonl(eventsPath, JSON.stringify({
2912
- type: timedOut ? "subagent.step.failed" : childInterrupted ? "subagent.step.paused" : singleResult.exitCode === 0 ? "subagent.step.completed" : "subagent.step.failed",
3476
+ type: stopped || childStopped ? "subagent.step.stopped" : timedOut ? "subagent.step.failed" : childInterrupted ? "subagent.step.paused" : singleResult.exitCode === 0 ? "subagent.step.completed" : "subagent.step.failed",
2913
3477
  ts: stepEndTime,
2914
3478
  runId: id,
2915
3479
  stepIndex: flatIndex,
2916
3480
  agent: seqStep.agent,
2917
- exitCode: timedOut ? 1 : childInterrupted ? 0 : singleResult.exitCode,
3481
+ exitCode: stopped || childStopped ? 1 : timedOut ? 1 : childInterrupted ? 0 : singleResult.exitCode,
2918
3482
  durationMs: stepEndTime - stepStartTime,
2919
3483
  tokens: stepTokens,
2920
3484
  }));
@@ -3003,11 +3567,29 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
3003
3567
  clearTimeout(timeoutTimer);
3004
3568
  timeoutTimer = undefined;
3005
3569
  }
3570
+ statusPayload.state = stopped ? "stopped" : timedOut || turnBudgetExceeded ? "failed" : interrupted ? "paused" : results.every((r) => r.success) ? "complete" : "failed";
3571
+ closeSteerInbox(asyncDir, statusPayload.state);
3006
3572
  disposeControlInbox();
3573
+ for (const request of consumeSteerRequests(asyncDir)) deliverSteerRequest(request);
3007
3574
  const effectiveSessionFile = sessionFile ?? latestSessionFile;
3575
+ const steeringLifecycle = steeringStatus(statusPayload);
3576
+ for (const request of steeringLifecycle.recent) {
3577
+ let changed = false;
3578
+ for (const target of request.targets) {
3579
+ if (target.state !== "scheduled" && target.state !== "routed") continue;
3580
+ changed = true;
3581
+ updateSteeringLifecycleTarget(request.id, target.index, "failed", Date.now(), { reason: "child terminated before steering delivery" });
3582
+ markSteeringAttention(target.index);
3583
+ emitSteeringEvent("subagent.steer.failed", { type: "steer", id: request.id, ts: request.requestedAt, message: "child terminated before steering delivery" }, target.index, { reason: "child terminated before steering delivery" });
3584
+ }
3585
+ if (changed) emitTerminalSteeringNotice(request.id, `Steering failed for run ${id}: child terminated before delivery.`);
3586
+ }
3008
3587
  const runEndedAt = Date.now();
3009
- statusPayload.state = timedOut || turnBudgetExceeded ? "failed" : interrupted ? "paused" : results.every((r) => r.success) ? "complete" : "failed";
3010
3588
  statusPayload.activityState = undefined;
3589
+ if (stopped) {
3590
+ statusPayload.stopped = true;
3591
+ statusPayload.error = stopMessage;
3592
+ }
3011
3593
  if (timedOut) {
3012
3594
  statusPayload.timedOut = true;
3013
3595
  statusPayload.error = timeoutMessage ?? "Subagent timed out.";
@@ -3068,9 +3650,9 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
3068
3650
  id,
3069
3651
  agent: agentName,
3070
3652
  mode: resultMode,
3071
- success: !timedOut && !turnBudgetExceeded && !interrupted && results.every((r) => r.success),
3072
- state: timedOut || turnBudgetExceeded ? "failed" : interrupted ? "paused" : results.every((r) => r.success) ? "complete" : "failed",
3073
- summary: timedOut ? (timeoutMessage ?? "Subagent timed out.") : turnBudgetExceeded ? (statusPayload.error ?? "Subagent exceeded turn budget.") : interrupted ? "Paused after interrupt. Waiting for explicit next action." : summary,
3653
+ success: !stopped && !timedOut && !turnBudgetExceeded && !interrupted && results.every((r) => r.success),
3654
+ state: stopped ? "stopped" : timedOut || turnBudgetExceeded ? "failed" : interrupted ? "paused" : results.every((r) => r.success) ? "complete" : "failed",
3655
+ summary: stopped ? stopMessage : timedOut ? (timeoutMessage ?? "Subagent timed out.") : turnBudgetExceeded ? (statusPayload.error ?? "Subagent exceeded turn budget.") : interrupted ? "Paused after interrupt. Waiting for explicit next action." : summary,
3074
3656
  ...(config.timeoutMs !== undefined ? { timeoutMs: config.timeoutMs } : {}),
3075
3657
  ...(config.deadlineAt !== undefined ? { deadlineAt: config.deadlineAt } : {}),
3076
3658
  ...(statusPayload.turnBudget ? { turnBudget: statusPayload.turnBudget } : {}),
@@ -3078,15 +3660,17 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
3078
3660
  ...(statusPayload.wrapUpRequested ? { wrapUpRequested: true } : {}),
3079
3661
  ...(statusPayload.toolBudget ? { toolBudget: statusPayload.toolBudget } : {}),
3080
3662
  ...(statusPayload.toolBudgetBlocked ? { toolBudgetBlocked: true } : {}),
3081
- ...(timedOut ? { timedOut: true, error: timeoutMessage ?? "Subagent timed out." } : turnBudgetExceeded ? { error: statusPayload.error ?? "Subagent exceeded turn budget." } : {}),
3663
+ ...(stopped ? { stopped: true, error: stopMessage } : timedOut ? { timedOut: true, error: timeoutMessage ?? "Subagent timed out." } : turnBudgetExceeded ? { error: statusPayload.error ?? "Subagent exceeded turn budget." } : {}),
3082
3664
  results: results.map((r) => ({
3083
3665
  agent: r.agent,
3084
3666
  output: r.output,
3085
3667
  error: r.error,
3668
+ protocolError: r.protocolError,
3086
3669
  success: r.success,
3087
3670
  skipped: r.skipped || undefined,
3088
3671
  interrupted: r.interrupted || undefined,
3089
3672
  timedOut: r.timedOut || undefined,
3673
+ stopped: r.stopped || undefined,
3090
3674
  turnBudget: r.turnBudget,
3091
3675
  turnBudgetExceeded: r.turnBudgetExceeded || undefined,
3092
3676
  wrapUpRequested: r.wrapUpRequested || undefined,
@@ -3106,10 +3690,11 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
3106
3690
  structuredOutputPath: r.structuredOutputPath,
3107
3691
  structuredOutputSchemaPath: r.structuredOutputSchemaPath,
3108
3692
  acceptance: r.acceptance,
3693
+ watchdog: r.watchdog,
3109
3694
  })),
3110
3695
  outputs,
3111
3696
  workflowGraph: statusPayload.workflowGraph,
3112
- exitCode: timedOut || turnBudgetExceeded ? 1 : interrupted || results.every((r) => r.success) ? 0 : 1,
3697
+ exitCode: stopped || timedOut || turnBudgetExceeded ? 1 : interrupted || results.every((r) => r.success) ? 0 : 1,
3113
3698
  timestamp: runEndedAt,
3114
3699
  durationMs: runEndedAt - overallStartTime,
3115
3700
  totalTokens: statusPayload.totalTokens,
@@ -3132,6 +3717,89 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
3132
3717
  }
3133
3718
  }
3134
3719
 
3720
+ async function waitForStartupControl(
3721
+ controlPath: string,
3722
+ token: string,
3723
+ action: "ack" | "proceed",
3724
+ timeoutMs = 30_000,
3725
+ ): Promise<void> {
3726
+ const deadline = Date.now() + timeoutMs;
3727
+ while (Date.now() <= deadline) {
3728
+ if (fs.existsSync(controlPath)) {
3729
+ let payload: { action?: unknown; token?: unknown };
3730
+ try {
3731
+ payload = JSON.parse(fs.readFileSync(controlPath, "utf-8")) as { action?: unknown; token?: unknown };
3732
+ } catch (error) {
3733
+ throw new Error(`Failed to read runner startup control '${controlPath}': ${error instanceof Error ? error.message : String(error)}`);
3734
+ }
3735
+ if (payload.token !== token) throw new Error("Runner startup control token does not match the acquired session lease.");
3736
+ if (payload.action === action) return;
3737
+ if (payload.action !== "ack" && payload.action !== "proceed") throw new Error("Runner startup control action is invalid.");
3738
+ }
3739
+ await new Promise((resolve) => setTimeout(resolve, 20));
3740
+ }
3741
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for runner startup control '${action}'.`);
3742
+ }
3743
+
3744
+ async function runConfiguredSubagent(config: SubagentRunConfig): Promise<void> {
3745
+ let lease: ReturnType<typeof acquireSessionLease> | undefined;
3746
+ let startupCommitted = config.revivalLease === undefined;
3747
+ const startupPath = path.join(config.asyncDir, "runner-startup.json");
3748
+ const startupAckPath = path.join(config.asyncDir, "runner-startup-ack.json");
3749
+ const startupProceedPath = path.join(config.asyncDir, "runner-startup-proceed.json");
3750
+ const releaseOnExit = (): void => {
3751
+ try {
3752
+ lease?.release();
3753
+ } catch {
3754
+ // Exit cleanup is best effort; a dead-owner lease is reclaimed on the next revival.
3755
+ }
3756
+ };
3757
+ process.once("exit", releaseOnExit);
3758
+ try {
3759
+ if (config.revivalLease) {
3760
+ lease = acquireSessionLease(config.revivalLease);
3761
+ writeAtomicJson(startupPath, { state: "ready", token: lease.owner.token, pid: process.pid, owner: lease.owner });
3762
+ await waitForStartupControl(startupAckPath, lease.owner.token, "ack");
3763
+ writeAtomicJson(startupPath, { state: "acknowledged", token: lease.owner.token, pid: process.pid });
3764
+ await waitForStartupControl(startupProceedPath, lease.owner.token, "proceed");
3765
+ startupCommitted = true;
3766
+ for (const controlPath of [startupAckPath, startupProceedPath]) {
3767
+ try {
3768
+ fs.rmSync(controlPath, { force: true });
3769
+ } catch {
3770
+ // Startup control cleanup is best effort after the parent commits the run.
3771
+ }
3772
+ }
3773
+ }
3774
+ await runSubagent(config, lease ? (writer) => lease!.updateWriter(writer) : undefined);
3775
+ } catch (error) {
3776
+ if (config.revivalLease && !startupCommitted) {
3777
+ try {
3778
+ writeAtomicJson(startupPath, { state: "error", pid: process.pid, error: error instanceof Error ? error.message : String(error) });
3779
+ } catch {
3780
+ // The parent will time out and terminate this runner if the handshake cannot be written.
3781
+ }
3782
+ }
3783
+ throw error;
3784
+ } finally {
3785
+ process.off("exit", releaseOnExit);
3786
+ if (lease) {
3787
+ try {
3788
+ lease.release();
3789
+ } catch (error) {
3790
+ console.error("Failed to release session revival lease:", error);
3791
+ }
3792
+ }
3793
+ }
3794
+ }
3795
+
3796
+ function startConfiguredSubagent(config: SubagentRunConfig): void {
3797
+ runConfiguredSubagent(config).catch((runErr) => {
3798
+ console.error("Subagent runner error:", runErr);
3799
+ process.exit(1);
3800
+ });
3801
+ }
3802
+
3135
3803
  const configArg = process.argv[2];
3136
3804
  if (configArg) {
3137
3805
  try {
@@ -3142,10 +3810,7 @@ if (configArg) {
3142
3810
  } catch {
3143
3811
  // Temp config cleanup is best effort.
3144
3812
  }
3145
- runSubagent(config).catch((runErr) => {
3146
- console.error("Subagent runner error:", runErr);
3147
- process.exit(1);
3148
- });
3813
+ startConfiguredSubagent(config);
3149
3814
  } catch (err) {
3150
3815
  console.error("Subagent runner error:", err);
3151
3816
  process.exit(1);
@@ -3159,10 +3824,7 @@ if (configArg) {
3159
3824
  process.stdin.on("end", () => {
3160
3825
  try {
3161
3826
  const config = JSON.parse(input) as SubagentRunConfig;
3162
- runSubagent(config).catch((runErr) => {
3163
- console.error("Subagent runner error:", runErr);
3164
- process.exit(1);
3165
- });
3827
+ startConfiguredSubagent(config);
3166
3828
  } catch (err) {
3167
3829
  console.error("Subagent runner error:", err);
3168
3830
  process.exit(1);