pi-subagents 0.31.1 → 0.33.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 (60) hide show
  1. package/CHANGELOG.md +67 -4
  2. package/README.md +219 -40
  3. package/install.mjs +2 -1
  4. package/package.json +3 -2
  5. package/skills/pi-subagents/SKILL.md +71 -10
  6. package/src/agents/agent-management.ts +179 -2
  7. package/src/agents/agent-memory.ts +254 -0
  8. package/src/agents/agent-serializer.ts +11 -0
  9. package/src/agents/agents.ts +193 -19
  10. package/src/agents/chain-serializer.ts +27 -2
  11. package/src/extension/config.ts +27 -4
  12. package/src/extension/doctor.ts +1 -7
  13. package/src/extension/fanout-child.ts +3 -2
  14. package/src/extension/index.ts +70 -41
  15. package/src/extension/rpc.ts +369 -0
  16. package/src/extension/schemas.ts +54 -10
  17. package/src/extension/tool-description.ts +200 -0
  18. package/src/intercom/intercom-bridge.ts +21 -253
  19. package/src/intercom/native-supervisor-channel.ts +510 -0
  20. package/src/runs/background/async-execution.ts +187 -38
  21. package/src/runs/background/async-job-tracker.ts +88 -2
  22. package/src/runs/background/async-status.ts +67 -10
  23. package/src/runs/background/chain-root-attachment.ts +34 -4
  24. package/src/runs/background/completion-batcher.ts +166 -0
  25. package/src/runs/background/control-channel.ts +156 -1
  26. package/src/runs/background/fleet-view.ts +515 -0
  27. package/src/runs/background/notify.ts +161 -44
  28. package/src/runs/background/result-watcher.ts +1 -2
  29. package/src/runs/background/run-id-resolver.ts +3 -2
  30. package/src/runs/background/run-status.ts +167 -6
  31. package/src/runs/background/scheduled-runs.ts +514 -0
  32. package/src/runs/background/stale-run-reconciler.ts +28 -1
  33. package/src/runs/background/subagent-runner.ts +840 -127
  34. package/src/runs/background/wait.ts +353 -0
  35. package/src/runs/foreground/chain-execution.ts +123 -27
  36. package/src/runs/foreground/execution.ts +174 -27
  37. package/src/runs/foreground/subagent-executor.ts +569 -81
  38. package/src/runs/shared/acceptance.ts +45 -22
  39. package/src/runs/shared/dynamic-fanout.ts +2 -2
  40. package/src/runs/shared/model-fallback.ts +171 -20
  41. package/src/runs/shared/model-scope.ts +128 -0
  42. package/src/runs/shared/nested-events.ts +89 -0
  43. package/src/runs/shared/parallel-utils.ts +50 -1
  44. package/src/runs/shared/pi-args.ts +35 -4
  45. package/src/runs/shared/pi-spawn.ts +52 -20
  46. package/src/runs/shared/single-output.ts +2 -0
  47. package/src/runs/shared/subagent-prompt-runtime.ts +110 -4
  48. package/src/runs/shared/tool-budget.ts +74 -0
  49. package/src/runs/shared/turn-budget.ts +52 -0
  50. package/src/runs/shared/worktree.ts +28 -5
  51. package/src/shared/artifacts.ts +16 -1
  52. package/src/shared/atomic-json.ts +15 -2
  53. package/src/shared/child-transcript.ts +212 -0
  54. package/src/shared/fork-context.ts +133 -22
  55. package/src/shared/settings.ts +3 -1
  56. package/src/shared/types.ts +197 -4
  57. package/src/shared/utils.ts +99 -14
  58. package/src/slash/prompt-workflows.ts +330 -0
  59. package/src/slash/slash-commands.ts +133 -2
  60. package/src/tui/render.ts +32 -12
@@ -4,7 +4,8 @@ import * as path from "node:path";
4
4
  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
- import { consumeInterruptRequest, watchAsyncControlInbox } from "./control-channel.ts";
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
9
  import { appendJsonl as appendRawJsonl, getArtifactPaths } from "../../shared/artifacts.ts";
9
10
  import { PI_CODING_AGENT_PACKAGE, getPiSpawnCommand, resolveInstalledPiPackageRoot } from "../shared/pi-spawn.ts";
10
11
  import { captureSingleOutputSnapshot, finalizeSingleOutput, formatSavedOutputReference, resolveSingleOutput, type SingleOutputSnapshot } from "../shared/single-output.ts";
@@ -15,14 +16,21 @@ import {
15
16
  type AsyncParallelGroupStatus,
16
17
  type AsyncStatus,
17
18
  type ChainOutputMap,
19
+ type CostSummary,
18
20
  type ModelAttempt,
19
21
  type NestedRouteInfo,
22
+ type NestedRunSummary,
20
23
  type ResolvedControlConfig,
24
+ type ResolvedTurnBudget,
25
+ type ResolvedToolBudget,
21
26
  type SubagentRunMode,
27
+ type ToolBudgetState,
28
+ type TurnBudgetState,
22
29
  type Usage,
23
30
  type WorkflowGraphSnapshot,
24
31
  DEFAULT_MAX_OUTPUT,
25
32
  type MaxOutputConfig,
33
+ SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
26
34
  truncateOutput,
27
35
  getSubagentDepthEnv,
28
36
  } from "../../shared/types.ts";
@@ -43,15 +51,17 @@ import {
43
51
  mapConcurrent,
44
52
  aggregateParallelOutputs,
45
53
  MAX_PARALLEL_CONCURRENCY,
54
+ DEFAULT_GLOBAL_CONCURRENCY_LIMIT,
55
+ Semaphore,
46
56
  } from "../shared/parallel-utils.ts";
47
- import { buildPiArgs, cleanupTempDir } from "../shared/pi-args.ts";
57
+ import { applyThinkingSuffix, buildPiArgs, cleanupTempDir } from "../shared/pi-args.ts";
48
58
  import { outputEntryFromAsyncResult, resolveOutputReferences } from "../shared/chain-outputs.ts";
49
59
  import { createStructuredOutputRuntime, readStructuredOutput } from "../shared/structured-output.ts";
50
60
  import { collectDynamicResults, DynamicFanoutError, materializeDynamicParallelStep, validateDynamicCollection } from "../shared/dynamic-fanout.ts";
51
- import { nestedSummaryFromAsyncStatus, writeNestedEvent } from "../shared/nested-events.ts";
61
+ import { nestedSummaryFromAsyncStatus, projectNestedEvents, resolveNestedAsyncDir, writeNestedEvent } from "../shared/nested-events.ts";
52
62
  import { formatModelAttemptNote, isRetryableModelFailure } from "../shared/model-fallback.ts";
53
63
  import { attachPostExitStdioGuard, trySignalChild } from "../../shared/post-exit-stdio-guard.ts";
54
- import { detectSubagentError, extractTextFromContent, extractToolArgsPreview, getFinalOutput } from "../../shared/utils.ts";
64
+ import { detectSubagentError, extractTextFromContent, extractToolArgsPreview, getFinalOutput, readStatus } from "../../shared/utils.ts";
55
65
  import { evaluateCompletionMutationGuard } from "../shared/completion-guard.ts";
56
66
  import {
57
67
  createMutatingFailureState,
@@ -81,6 +91,8 @@ import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts
81
91
  import { acceptanceFailureMessage, aggregateAcceptanceReport, evaluateAcceptance, formatAcceptancePrompt, stripAcceptanceReport } from "../shared/acceptance.ts";
82
92
  import { waitForImportedAsyncRoot } from "./chain-root-attachment.ts";
83
93
  import { appendRunnerStepsToStatus, consumeChainAppendRequests, countPendingChainAppendRequests } from "./chain-append.ts";
94
+ import { appendTurnBudgetSystemPrompt, formatTurnBudgetOutput, initialTurnBudgetState, shouldAbortForTurnBudget, turnBudgetExceededMessage, turnBudgetSoftNote, turnBudgetState } from "../shared/turn-budget.ts";
95
+ import { initialToolBudgetState, toolBudgetState } from "../shared/tool-budget.ts";
84
96
 
85
97
  interface SubagentRunConfig {
86
98
  id: string;
@@ -101,6 +113,7 @@ interface SubagentRunConfig {
101
113
  piArgv1?: string;
102
114
  worktreeSetupHook?: string;
103
115
  worktreeSetupHookTimeoutMs?: number;
116
+ worktreeBaseDir?: string;
104
117
  controlConfig?: ResolvedControlConfig;
105
118
  controlIntercomTarget?: string;
106
119
  childIntercomTargets?: Array<string | undefined>;
@@ -109,6 +122,12 @@ interface SubagentRunConfig {
109
122
  workflowGraph?: WorkflowGraphSnapshot;
110
123
  nestedRoute?: NestedRouteInfo;
111
124
  nestedSelf?: { parentRunId: string; parentStepIndex?: number; depth: number; path?: Array<{ runId: string; stepIndex?: number; agent?: string }> };
125
+ timeoutMs?: number;
126
+ deadlineAt?: number;
127
+ turnBudget?: ResolvedTurnBudget;
128
+ toolBudget?: ResolvedToolBudget;
129
+ /** Global cap on simultaneously-running subagent tasks within this run. */
130
+ globalConcurrencyLimit?: number;
112
131
  }
113
132
 
114
133
  interface StepResult {
@@ -118,13 +137,23 @@ interface StepResult {
118
137
  success: boolean;
119
138
  exitCode?: number | null;
120
139
  skipped?: boolean;
140
+ interrupted?: boolean;
141
+ timedOut?: boolean;
142
+ turnBudget?: TurnBudgetState;
143
+ turnBudgetExceeded?: boolean;
144
+ wrapUpRequested?: boolean;
145
+ toolBudget?: ToolBudgetState;
146
+ toolBudgetBlocked?: boolean;
121
147
  sessionFile?: string;
122
148
  intercomTarget?: string;
123
149
  model?: string;
124
150
  attemptedModels?: string[];
125
151
  modelAttempts?: ModelAttempt[];
152
+ totalCost?: CostSummary;
126
153
  artifactPaths?: ArtifactPaths;
127
154
  truncated?: boolean;
155
+ transcriptPath?: string;
156
+ transcriptError?: string;
128
157
  structuredOutput?: unknown;
129
158
  structuredOutputPath?: string;
130
159
  structuredOutputSchemaPath?: string;
@@ -237,6 +266,21 @@ function tokenUsageFromAttempts(attempts: ModelAttempt[] | undefined): TokenUsag
237
266
  return total > 0 ? { input, output, total } : null;
238
267
  }
239
268
 
269
+ function costSummaryFromAttempts(attempts: ModelAttempt[] | undefined): CostSummary | undefined {
270
+ if (!attempts || attempts.length === 0) return undefined;
271
+ let inputTokens = 0;
272
+ let outputTokens = 0;
273
+ let costUsd = 0;
274
+ for (const attempt of attempts) {
275
+ inputTokens += attempt.usage?.input ?? 0;
276
+ outputTokens += attempt.usage?.output ?? 0;
277
+ costUsd += attempt.usage?.cost ?? 0;
278
+ }
279
+ return inputTokens > 0 || outputTokens > 0 || costUsd > 0
280
+ ? { inputTokens, outputTokens, costUsd }
281
+ : undefined;
282
+ }
283
+
240
284
  function appendRecentStepOutput(step: RunnerStatusStep, lines: string[]): void {
241
285
  const nonEmpty = lines.filter((line) => line.trim());
242
286
  if (nonEmpty.length === 0) return;
@@ -247,6 +291,13 @@ function appendRecentStepOutput(step: RunnerStatusStep, lines: string[]): void {
247
291
  }
248
292
  }
249
293
 
294
+ function isTerminalAssistantStop(message: Message): boolean {
295
+ const stopReason = (message as { stopReason?: string }).stopReason;
296
+ const hasToolCall = Array.isArray(message.content)
297
+ && message.content.some((part) => (part as { type?: string }).type === "toolCall");
298
+ return stopReason === "stop" && !hasToolCall;
299
+ }
300
+
250
301
  function resetStepLiveDetail(step: RunnerStatusStep): void {
251
302
  step.currentTool = undefined;
252
303
  step.currentToolArgs = undefined;
@@ -295,6 +346,12 @@ interface RunPiStreamingResult {
295
346
  error?: string;
296
347
  finalOutput: string;
297
348
  interrupted?: boolean;
349
+ timedOut?: boolean;
350
+ turnBudget?: TurnBudgetState;
351
+ turnBudgetExceeded?: boolean;
352
+ wrapUpRequested?: boolean;
353
+ toolBudget?: ToolBudgetState;
354
+ toolBudgetBlocked?: boolean;
298
355
  observedMutationAttempt?: boolean;
299
356
  }
300
357
 
@@ -309,6 +366,10 @@ function runPiStreaming(
309
366
  childEventContext?: ChildEventContext,
310
367
  registerInterrupt?: (interrupt: (() => void) | undefined) => void,
311
368
  onChildEvent?: (event: ChildEvent) => void,
369
+ transcriptWriter?: ChildTranscriptWriter,
370
+ registerTimeout?: (interrupt: (() => void) | undefined) => void,
371
+ timeoutMessage?: string,
372
+ registerTurnBudgetAbort?: (abort: ((message: string, state?: TurnBudgetState) => void) | undefined) => void,
312
373
  ): Promise<RunPiStreamingResult> {
313
374
  return new Promise((resolve) => {
314
375
  const outputStream = fs.createWriteStream(outputFile, { flags: "w" });
@@ -332,6 +393,10 @@ function runPiStreaming(
332
393
  let error: string | undefined;
333
394
  let assistantError: string | undefined;
334
395
  let interrupted = false;
396
+ let timedOut = false;
397
+ let turnBudgetExceeded = false;
398
+ let turnBudgetMessage: string | undefined;
399
+ let turnBudget: TurnBudgetState | undefined;
335
400
  let observedMutationAttempt = false;
336
401
  const rawStdoutLines: string[] = [];
337
402
 
@@ -361,6 +426,8 @@ function runPiStreaming(
361
426
 
362
427
  const appendChildLine = (type: "subagent.child.stdout" | "subagent.child.stderr", line: string) => {
363
428
  appendChildEvent({ type, line });
429
+ if (type === "subagent.child.stdout") transcriptWriter?.writeStdoutLine(line);
430
+ else transcriptWriter?.writeStderrLine(line);
364
431
  };
365
432
 
366
433
  const processStdoutLine = (line: string) => {
@@ -376,6 +443,7 @@ function runPiStreaming(
376
443
  }
377
444
 
378
445
  appendChildEvent(event);
446
+ transcriptWriter?.writeChildEvent(event);
379
447
  onChildEvent?.(event);
380
448
 
381
449
  if (event.type === "tool_execution_start" && event.toolName) {
@@ -402,10 +470,7 @@ function runPiStreaming(
402
470
  usage.cacheWrite += eventUsage.cacheWrite ?? 0;
403
471
  usage.cost += eventUsage.cost?.total ?? 0;
404
472
  }
405
- const stopReason = (event.message as { stopReason?: string }).stopReason;
406
- const hasToolCall = Array.isArray(event.message.content)
407
- && event.message.content.some((part) => (part as { type?: string }).type === "toolCall");
408
- if (stopReason === "stop" && !hasToolCall) {
473
+ if (isTerminalAssistantStop(event.message)) {
409
474
  if (!event.message.errorMessage && extractTextFromContent(event.message.content).trim()) assistantError = undefined;
410
475
  cleanTerminalAssistantStopReceived ||= !event.message.errorMessage;
411
476
  startFinalDrain();
@@ -430,11 +495,15 @@ function runPiStreaming(
430
495
  // a lingering stdio holder after `exit`, or a child that never exits.
431
496
  const FINAL_STOP_GRACE_MS = 1000;
432
497
  const HARD_KILL_MS = 3000;
498
+ const TIMEOUT_HARD_KILL_MS = 3000;
433
499
  let childExited = false;
434
500
  let forcedTerminationSignal = false;
435
501
  let cleanTerminalAssistantStopReceived = false;
436
502
  let finalDrainTimer: NodeJS.Timeout | undefined;
437
503
  let finalHardKillTimer: NodeJS.Timeout | undefined;
504
+ let timeoutHardKillTimer: NodeJS.Timeout | undefined;
505
+ let turnBudgetTerminationTimer: NodeJS.Timeout | undefined;
506
+ let turnBudgetHardKillTimer: NodeJS.Timeout | undefined;
438
507
  let settled = false;
439
508
  const clearStdioGuard = attachPostExitStdioGuard(child, { idleMs: 2000, hardMs: 8000 });
440
509
  child.stdout.on("data", (chunk: Buffer) => {
@@ -449,14 +518,42 @@ function runPiStreaming(
449
518
  processStderrText(chunk.toString());
450
519
  });
451
520
  registerInterrupt?.(() => {
452
- if (settled) return;
521
+ if (settled || timedOut) return;
453
522
  interrupted = true;
454
523
  if (!error) error = "Interrupted. Waiting for explicit next action.";
455
524
  trySignalChild(child, "SIGINT");
456
525
  setTimeout(() => {
457
- if (!settled) trySignalChild(child, "SIGTERM");
526
+ if (!settled && !timedOut) trySignalChild(child, "SIGTERM");
458
527
  }, 1000).unref?.();
459
528
  });
529
+ registerTimeout?.(() => {
530
+ if (settled || timedOut) return;
531
+ timedOut = true;
532
+ interrupted = false;
533
+ error = timeoutMessage ?? "Subagent timed out.";
534
+ trySignalChild(child, "SIGTERM");
535
+ timeoutHardKillTimer = setTimeout(() => {
536
+ if (!settled) trySignalChild(child, "SIGKILL");
537
+ }, TIMEOUT_HARD_KILL_MS);
538
+ timeoutHardKillTimer.unref?.();
539
+ });
540
+ registerTurnBudgetAbort?.((message, state) => {
541
+ if (settled || timedOut || turnBudgetExceeded) return;
542
+ turnBudgetExceeded = true;
543
+ turnBudgetMessage = message;
544
+ turnBudget = state;
545
+ interrupted = false;
546
+ error = message;
547
+ trySignalChild(child, "SIGINT");
548
+ turnBudgetTerminationTimer = setTimeout(() => {
549
+ if (!settled && !timedOut) trySignalChild(child, "SIGTERM");
550
+ }, 1000);
551
+ turnBudgetTerminationTimer.unref?.();
552
+ turnBudgetHardKillTimer = setTimeout(() => {
553
+ if (!settled && !timedOut) trySignalChild(child, "SIGKILL");
554
+ }, 4000);
555
+ turnBudgetHardKillTimer.unref?.();
556
+ });
460
557
  const clearDrainTimers = () => {
461
558
  if (finalDrainTimer) {
462
559
  clearTimeout(finalDrainTimer);
@@ -466,6 +563,18 @@ function runPiStreaming(
466
563
  clearTimeout(finalHardKillTimer);
467
564
  finalHardKillTimer = undefined;
468
565
  }
566
+ if (timeoutHardKillTimer) {
567
+ clearTimeout(timeoutHardKillTimer);
568
+ timeoutHardKillTimer = undefined;
569
+ }
570
+ if (turnBudgetTerminationTimer) {
571
+ clearTimeout(turnBudgetTerminationTimer);
572
+ turnBudgetTerminationTimer = undefined;
573
+ }
574
+ if (turnBudgetHardKillTimer) {
575
+ clearTimeout(turnBudgetHardKillTimer);
576
+ turnBudgetHardKillTimer = undefined;
577
+ }
469
578
  };
470
579
  function startFinalDrain(): void {
471
580
  if (childExited || finalDrainTimer || settled) return;
@@ -492,6 +601,8 @@ function runPiStreaming(
492
601
  child.on("close", (exitCode, signal) => {
493
602
  settled = true;
494
603
  registerInterrupt?.(undefined);
604
+ registerTimeout?.(undefined);
605
+ registerTurnBudgetAbort?.(undefined);
495
606
  clearDrainTimers();
496
607
  clearStdioGuard();
497
608
  if (stdoutBuf.trim()) processStdoutLine(stdoutBuf);
@@ -502,13 +613,17 @@ function runPiStreaming(
502
613
  const forcedDrainAfterFinalSuccess = forcedTerminationSignal && cleanTerminalAssistantStopReceived && !finalError;
503
614
  resolve({
504
615
  stderr,
505
- exitCode: interrupted || forcedDrainAfterFinalSuccess ? 0 : forcedTerminationSignal || signal ? (exitCode ?? 1) : exitCode,
616
+ exitCode: timedOut ? 1 : turnBudgetExceeded ? 1 : interrupted || forcedDrainAfterFinalSuccess ? 0 : forcedTerminationSignal || signal ? (exitCode ?? 1) : exitCode,
506
617
  messages,
507
618
  usage,
508
619
  model,
509
- error: interrupted || forcedDrainAfterFinalSuccess ? undefined : finalError,
510
- finalOutput,
620
+ error: timedOut ? (timeoutMessage ?? "Subagent timed out.") : turnBudgetExceeded ? turnBudgetMessage : interrupted || forcedDrainAfterFinalSuccess ? undefined : finalError,
621
+ finalOutput: timedOut && !finalOutput.trim() ? (timeoutMessage ?? "Subagent timed out.") : finalOutput,
511
622
  interrupted,
623
+ timedOut,
624
+ turnBudget,
625
+ turnBudgetExceeded,
626
+ wrapUpRequested: turnBudget?.outcome === "wrap-up-requested" || turnBudgetExceeded || undefined,
512
627
  observedMutationAttempt,
513
628
  });
514
629
  });
@@ -516,12 +631,14 @@ function runPiStreaming(
516
631
  child.on("error", (spawnError) => {
517
632
  settled = true;
518
633
  registerInterrupt?.(undefined);
634
+ registerTimeout?.(undefined);
635
+ registerTurnBudgetAbort?.(undefined);
519
636
  clearDrainTimers();
520
637
  clearStdioGuard();
521
638
  outputStream.end();
522
639
  const finalOutput = getFinalOutput(messages) || rawStdoutLines.join("\n").trim();
523
640
  const spawnErrorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
524
- resolve({ stderr, exitCode: 1, messages, usage, model, error: error ?? assistantError ?? spawnErrorMessage, finalOutput, observedMutationAttempt });
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 });
525
642
  });
526
643
  });
527
644
  }
@@ -646,14 +763,22 @@ interface SingleStepContext {
646
763
  flatIndex: number;
647
764
  flatStepCount: number;
648
765
  outputFile: string;
766
+ steerInboxDir?: string;
767
+ transcriptPath?: string;
649
768
  piPackageRoot?: string;
650
769
  piArgv1?: string;
651
770
  registerInterrupt?: (interrupt: (() => void) | undefined) => void;
771
+ registerTimeout?: (interrupt: (() => void) | undefined) => void;
772
+ registerTurnBudgetAbort?: (abort: ((message: string, state?: TurnBudgetState) => void) | undefined) => void;
773
+ timeoutSignal?: AbortSignal;
774
+ timeoutMessage?: string;
775
+ turnBudget?: ResolvedTurnBudget;
652
776
  childIntercomTarget?: string;
653
777
  orchestratorIntercomTarget?: string;
654
778
  nestedRoute?: NestedRouteInfo;
655
779
  onAttemptStart?: (attempt: { model?: string; thinking?: string }) => void;
656
780
  onChildEvent?: (event: ChildEvent) => void;
781
+ skipAcceptance?: () => boolean;
657
782
  }
658
783
 
659
784
  /** Run a single pi agent step, returning output and metadata */
@@ -669,7 +794,15 @@ async function runSingleStep(
669
794
  attemptedModels?: string[];
670
795
  modelAttempts?: ModelAttempt[];
671
796
  artifactPaths?: ArtifactPaths;
797
+ transcriptPath?: string;
798
+ transcriptError?: string;
672
799
  interrupted?: boolean;
800
+ timedOut?: boolean;
801
+ turnBudget?: TurnBudgetState;
802
+ turnBudgetExceeded?: boolean;
803
+ wrapUpRequested?: boolean;
804
+ toolBudget?: ToolBudgetState;
805
+ toolBudgetBlocked?: boolean;
673
806
  sessionFile?: string;
674
807
  intercomTarget?: string;
675
808
  completionGuardTriggered?: boolean;
@@ -679,27 +812,52 @@ async function runSingleStep(
679
812
  acceptance?: import("../../shared/types.ts").AcceptanceLedger;
680
813
  }> {
681
814
  if (step.importAsyncRoot) {
682
- const imported = await waitForImportedAsyncRoot(step.importAsyncRoot);
815
+ let importTimedOut = false;
816
+ ctx.registerTimeout?.(() => {
817
+ importTimedOut = true;
818
+ let pid: number | undefined;
819
+ try {
820
+ pid = readStatus(step.importAsyncRoot!.asyncDir)?.pid;
821
+ } catch {
822
+ pid = undefined;
823
+ }
824
+ try {
825
+ deliverTimeoutRequest({ asyncDir: step.importAsyncRoot!.asyncDir, pid, source: "ancestor-timeout" });
826
+ } catch {
827
+ // The parent runner's own timeout result is authoritative for the attached step.
828
+ }
829
+ });
683
830
  try {
684
- fs.writeFileSync(ctx.outputFile, imported.output, "utf-8");
685
- } catch {
686
- // Output files are observability only for imported roots.
831
+ const imported = await waitForImportedAsyncRoot(step.importAsyncRoot, {
832
+ shouldAbort: () => importTimedOut || ctx.timeoutSignal?.aborted === true || ctx.skipAcceptance?.() === true,
833
+ timeoutMessage: ctx.timeoutMessage,
834
+ });
835
+ try {
836
+ fs.writeFileSync(ctx.outputFile, imported.output, "utf-8");
837
+ } catch {
838
+ // Output files are observability only for imported roots.
839
+ }
840
+ const timedOut = importTimedOut || imported.timedOut === true || ctx.timeoutSignal?.aborted === true || ctx.skipAcceptance?.() === true;
841
+ return {
842
+ 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,
846
+ timedOut: timedOut ? true : undefined,
847
+ sessionFile: imported.sessionFile,
848
+ intercomTarget: imported.intercomTarget,
849
+ model: imported.model,
850
+ attemptedModels: imported.attemptedModels,
851
+ modelAttempts: imported.modelAttempts,
852
+ 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,
857
+ };
858
+ } finally {
859
+ ctx.registerTimeout?.(undefined);
687
860
  }
688
- return {
689
- agent: imported.agent,
690
- output: imported.output,
691
- exitCode: imported.exitCode,
692
- error: imported.error,
693
- sessionFile: imported.sessionFile,
694
- intercomTarget: imported.intercomTarget,
695
- model: imported.model,
696
- attemptedModels: imported.attemptedModels,
697
- modelAttempts: imported.modelAttempts,
698
- structuredOutput: imported.structuredOutput,
699
- structuredOutputPath: imported.structuredOutputPath,
700
- structuredOutputSchemaPath: imported.structuredOutputSchemaPath,
701
- acceptance: imported.acceptance,
702
- };
703
861
  }
704
862
 
705
863
  const effectiveStructuredOutput = step.structuredOutput ?? (step.structuredOutputSchema
@@ -717,6 +875,7 @@ async function runSingleStep(
717
875
  const sessionDir = step.sessionFile ? undefined : ctx.sessionDir;
718
876
 
719
877
  let artifactPaths: ArtifactPaths | undefined;
878
+ let transcriptWriter: ChildTranscriptWriter | undefined;
720
879
  if (ctx.artifactsDir && ctx.artifactConfig?.enabled !== false) {
721
880
  const index = ctx.flatStepCount > 1 ? ctx.flatIndex : undefined;
722
881
  artifactPaths = getArtifactPaths(ctx.artifactsDir, ctx.id, step.agent, index);
@@ -724,7 +883,18 @@ async function runSingleStep(
724
883
  if (ctx.artifactConfig?.includeInput !== false) {
725
884
  fs.writeFileSync(artifactPaths.inputPath, `# Task for ${step.agent}\n\n${task}`, "utf-8");
726
885
  }
886
+ if (ctx.artifactConfig?.includeTranscript !== false) {
887
+ transcriptWriter = createChildTranscriptWriter({
888
+ transcriptPath: artifactPaths.transcriptPath,
889
+ source: "async",
890
+ runId: ctx.id,
891
+ agent: step.agent,
892
+ childIndex: ctx.flatIndex,
893
+ cwd: step.cwd ?? ctx.cwd,
894
+ });
895
+ }
727
896
  }
897
+ transcriptWriter?.writeInitialUserMessage(task);
728
898
 
729
899
  const candidates = step.modelCandidates && step.modelCandidates.length > 0
730
900
  ? step.modelCandidates
@@ -738,8 +908,12 @@ async function runSingleStep(
738
908
  let finalResult: RunPiStreamingResult | undefined;
739
909
  let finalOutputSnapshot: SingleOutputSnapshot | undefined;
740
910
  let completionGuardTriggeredFinal = false;
911
+ let turnBudget = ctx.turnBudget ? initialTurnBudgetState(ctx.turnBudget) : undefined;
912
+ let toolBudget = step.toolBudget ? initialToolBudgetState(step.toolBudget) : undefined;
913
+ let toolBudgetBlocked = false;
741
914
 
742
915
  for (let index = 0; index < candidates.length; index++) {
916
+ if (ctx.timeoutSignal?.aborted || ctx.skipAcceptance?.()) break;
743
917
  const candidate = candidates[index];
744
918
  ctx.onAttemptStart?.({ model: candidate, thinking: resolveEffectiveThinking(candidate, step.thinking) });
745
919
  const outputSnapshot = captureSingleOutputSnapshot(step.outputPath);
@@ -764,7 +938,7 @@ async function runSingleStep(
764
938
  tools: step.tools,
765
939
  extensions: step.extensions,
766
940
  subagentOnlyExtensions: step.subagentOnlyExtensions,
767
- systemPrompt: step.systemPrompt,
941
+ systemPrompt: appendTurnBudgetSystemPrompt(step.systemPrompt ?? "", ctx.turnBudget),
768
942
  systemPromptMode: step.systemPromptMode,
769
943
  mcpDirectTools: step.mcpDirectTools,
770
944
  cwd: step.cwd ?? ctx.cwd,
@@ -778,7 +952,9 @@ async function runSingleStep(
778
952
  parentControlInbox: ctx.nestedRoute?.controlInbox,
779
953
  parentRootRunId: ctx.nestedRoute?.rootRunId,
780
954
  parentCapabilityToken: ctx.nestedRoute?.capabilityToken,
955
+ steerInboxDir: ctx.steerInboxDir,
781
956
  structuredOutput: effectiveStructuredOutput,
957
+ toolBudget: step.toolBudget,
782
958
  });
783
959
  const run = await runPiStreaming(
784
960
  args,
@@ -791,13 +967,38 @@ async function runSingleStep(
791
967
  { eventsPath, runId: ctx.id, stepIndex: ctx.flatIndex, agent: step.agent },
792
968
  ctx.registerInterrupt,
793
969
  ctx.onChildEvent,
970
+ transcriptWriter,
971
+ ctx.registerTimeout,
972
+ ctx.timeoutMessage,
973
+ ctx.registerTurnBudgetAbort,
794
974
  );
975
+ if (run.turnBudget) turnBudget = run.turnBudget;
976
+ else if (ctx.turnBudget) {
977
+ const assistantMessages = run.messages.filter((message) => message.role === "assistant");
978
+ const turnCount = assistantMessages.length;
979
+ const lastAssistantMessage = assistantMessages.at(-1);
980
+ if (turnCount > 0 && turnCount < ctx.turnBudget.maxTurns) {
981
+ turnBudget = { ...ctx.turnBudget, outcome: "within-budget", turnCount };
982
+ } else if (turnCount >= ctx.turnBudget.maxTurns) {
983
+ turnBudget = turnBudgetState(
984
+ ctx.turnBudget,
985
+ turnCount,
986
+ shouldAbortForTurnBudget(ctx.turnBudget, turnCount, lastAssistantMessage ? isTerminalAssistantStop(lastAssistantMessage) : false),
987
+ );
988
+ }
989
+ }
795
990
  cleanupTempDir(tempDir);
796
991
 
797
992
  const hiddenError = run.exitCode === 0 && !run.error ? detectSubagentError(run.messages) : null;
993
+ const missingStructuredOutput = effectiveStructuredOutput
994
+ ? !fs.existsSync(effectiveStructuredOutput.outputPath)
995
+ : false;
996
+ const emptyOutputError = run.exitCode === 0 && !run.error && !hiddenError?.hasError && !run.finalOutput.trim() && (!effectiveStructuredOutput || missingStructuredOutput)
997
+ ? "Subagent produced no output (possible model cold-start or empty response)."
998
+ : undefined;
798
999
  let structuredOutput: unknown;
799
1000
  let structuredError: string | undefined;
800
- if (effectiveStructuredOutput && run.exitCode === 0 && !run.error && !hiddenError?.hasError) {
1001
+ if (effectiveStructuredOutput && run.exitCode === 0 && !run.error && !hiddenError?.hasError && !emptyOutputError) {
801
1002
  const structured = readStructuredOutput({
802
1003
  schema: effectiveStructuredOutput.schema,
803
1004
  schemaPath: effectiveStructuredOutput.schemaPath,
@@ -806,7 +1007,7 @@ async function runSingleStep(
806
1007
  if (structured.error) structuredError = structured.error;
807
1008
  else structuredOutput = structured.value;
808
1009
  }
809
- const completionGuard = run.exitCode === 0 && !run.error && !hiddenError?.hasError && step.completionGuard !== false
1010
+ const completionGuard = run.exitCode === 0 && !run.error && !hiddenError?.hasError && !emptyOutputError && step.completionGuard !== false
810
1011
  ? evaluateCompletionMutationGuard({
811
1012
  agent: step.agent,
812
1013
  task: taskForCompletionGuard,
@@ -825,16 +1026,18 @@ async function runSingleStep(
825
1026
  ? 1
826
1027
  : hiddenError?.hasError
827
1028
  ? (hiddenError.exitCode ?? 1)
828
- : run.error && run.exitCode === 0
1029
+ : emptyOutputError
829
1030
  ? 1
830
- : run.exitCode;
1031
+ : run.error && run.exitCode === 0
1032
+ ? 1
1033
+ : run.exitCode;
831
1034
  const error = completionGuardError
832
1035
  ?? structuredError
833
1036
  ?? (hiddenError?.hasError
834
1037
  ? hiddenError.details
835
1038
  ? `${hiddenError.errorType} failed (exit ${effectiveExitCode}): ${hiddenError.details}`
836
1039
  : `${hiddenError.errorType} failed with exit code ${effectiveExitCode}`
837
- : run.error || (run.exitCode !== 0 && run.stderr.trim() ? run.stderr.trim() : undefined));
1040
+ : emptyOutputError ?? (run.error || (run.exitCode !== 0 && run.stderr.trim() ? run.stderr.trim() : undefined)));
838
1041
  const attempt: ModelAttempt = {
839
1042
  model: candidate ?? run.model ?? step.model ?? "default",
840
1043
  success: effectiveExitCode === 0 && !error,
@@ -846,7 +1049,15 @@ async function runSingleStep(
846
1049
  if (candidate) attemptedModels.push(candidate);
847
1050
  completionGuardTriggeredFinal = completionGuardTriggered;
848
1051
  finalOutputSnapshot = outputSnapshot;
1052
+ if (step.toolBudget) {
1053
+ const toolMessages = run.messages.filter((message) => message.role === "toolResult");
1054
+ const blockedMessage = toolMessages.find((message) => extractTextFromContent(message.content).includes("Tool budget hard limit reached"));
1055
+ toolBudgetBlocked = Boolean(blockedMessage);
1056
+ toolBudget = toolBudgetState(step.toolBudget, toolMessages.length, blockedMessage ? (blockedMessage as { toolName?: string }).toolName : undefined);
1057
+ }
849
1058
  finalResult = { ...run, exitCode: effectiveExitCode, model: candidate ?? run.model, error, structuredOutput } as RunPiStreamingResult & { structuredOutput?: unknown };
1059
+ if (run.turnBudgetExceeded) break;
1060
+ if (run.timedOut || ctx.timeoutSignal?.aborted || ctx.skipAcceptance?.()) break;
850
1061
  if (attempt.success || completionGuardTriggered) break;
851
1062
  if (!isRetryableModelFailure(error) || index === candidates.length - 1) break;
852
1063
  attemptNotes.push(formatModelAttemptNote(attempt, candidates[index + 1]));
@@ -863,6 +1074,12 @@ async function runSingleStep(
863
1074
  if (attemptNotes.length > 0) {
864
1075
  outputForSummary = `${attemptNotes.join("\n")}\n\n${outputForSummary}`.trim();
865
1076
  }
1077
+ if (!finalResult?.timedOut && finalResult?.turnBudgetExceeded && turnBudget) {
1078
+ outputForSummary = formatTurnBudgetOutput(turnBudgetExceededMessage(turnBudget, turnBudget.turnCount), outputForSummary);
1079
+ } else if (!finalResult?.timedOut && turnBudget?.outcome === "wrap-up-requested") {
1080
+ const note = turnBudgetSoftNote(turnBudget, turnBudget.wrapUpRequestedAtTurn ?? turnBudget.turnCount);
1081
+ outputForSummary = outputForSummary.trim() ? `${note}\n\n${outputForSummary}` : note;
1082
+ }
866
1083
  const outputForAcceptance = rawOutput;
867
1084
  const finalizedOutput = finalizeSingleOutput({
868
1085
  fullOutput: outputForSummary,
@@ -874,19 +1091,28 @@ async function runSingleStep(
874
1091
  saveError: resolvedOutput.saveError,
875
1092
  });
876
1093
  outputForSummary = finalizedOutput.displayOutput;
877
- const acceptance = step.effectiveAcceptance
1094
+ const acceptance = step.effectiveAcceptance && !finalResult?.turnBudgetExceeded && !ctx.timeoutSignal?.aborted && !ctx.skipAcceptance?.()
878
1095
  ? await evaluateAcceptance({
879
1096
  acceptance: step.effectiveAcceptance,
880
1097
  output: outputForAcceptance,
881
1098
  cwd: step.cwd ?? ctx.cwd,
1099
+ signal: ctx.timeoutSignal,
1100
+ abortMessage: ctx.timeoutMessage ?? "Subagent timed out.",
882
1101
  })
883
1102
  : undefined;
884
- const acceptanceFailure = acceptance ? acceptanceFailureMessage(acceptance) : undefined;
885
- const acceptanceCanFailRun = acceptanceFailure && acceptance?.explicit && (finalResult?.exitCode ?? 1) === 0 && !finalResult?.interrupted;
886
- const effectiveFinalExitCode = acceptanceCanFailRun ? 1 : finalResult?.exitCode ?? 1;
887
- const effectiveFinalError = acceptanceCanFailRun
888
- ? (finalResult?.error ? `${finalResult.error}\n${acceptanceFailure}` : acceptanceFailure)
889
- : finalResult?.error;
1103
+ const timedOutAfterAcceptance = finalResult?.timedOut === true || ctx.timeoutSignal?.aborted === true || ctx.skipAcceptance?.() === true;
1104
+ const turnBudgetExceeded = finalResult?.turnBudgetExceeded === true;
1105
+ const effectiveAcceptance = timedOutAfterAcceptance || turnBudgetExceeded ? undefined : acceptance;
1106
+ 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;
890
1116
 
891
1117
  if (artifactPaths && ctx.artifactConfig?.enabled !== false) {
892
1118
  if (ctx.artifactConfig?.includeOutput !== false) {
@@ -903,6 +1129,8 @@ async function runSingleStep(
903
1129
  model: finalResult?.model,
904
1130
  attemptedModels: attemptedModels.length > 0 ? attemptedModels : undefined,
905
1131
  modelAttempts,
1132
+ ...(transcriptWriter ? { transcriptPath: artifactPaths.transcriptPath } : {}),
1133
+ transcriptError: transcriptWriter?.getError(),
906
1134
  skills: step.skills,
907
1135
  timestamp: Date.now(),
908
1136
  }, null, 2),
@@ -921,13 +1149,22 @@ async function runSingleStep(
921
1149
  model: finalResult?.model,
922
1150
  attemptedModels: attemptedModels.length > 0 ? attemptedModels : undefined,
923
1151
  modelAttempts,
1152
+ totalCost: costSummaryFromAttempts(modelAttempts),
924
1153
  artifactPaths,
925
- interrupted: finalResult?.interrupted,
1154
+ transcriptPath: transcriptWriter ? artifactPaths?.transcriptPath : undefined,
1155
+ transcriptError: transcriptWriter?.getError(),
1156
+ interrupted: timedOutAfterAcceptance || turnBudgetExceeded ? false : finalResult?.interrupted,
1157
+ timedOut: timedOutAfterAcceptance ? true : finalResult?.timedOut,
1158
+ turnBudget,
1159
+ turnBudgetExceeded: turnBudgetExceeded || undefined,
1160
+ wrapUpRequested: finalResult?.wrapUpRequested || turnBudget?.outcome === "wrap-up-requested" || turnBudgetExceeded || undefined,
1161
+ toolBudget,
1162
+ toolBudgetBlocked: toolBudgetBlocked || undefined,
926
1163
  completionGuardTriggered: completionGuardTriggeredFinal,
927
- structuredOutput: (finalResult as (RunPiStreamingResult & { structuredOutput?: unknown }) | undefined)?.structuredOutput,
928
- structuredOutputPath: effectiveStructuredOutput?.outputPath,
929
- structuredOutputSchemaPath: effectiveStructuredOutput?.schemaPath,
930
- acceptance,
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,
1167
+ acceptance: effectiveAcceptance,
931
1168
  };
932
1169
  }
933
1170
 
@@ -1056,9 +1293,29 @@ function ensureParallelProgressFile(cwd: string, group: Extract<RunnerStep, { pa
1056
1293
  writeInitialProgressFile(cwd);
1057
1294
  }
1058
1295
 
1296
+ function resolveAsyncStepTranscriptPath(input: {
1297
+ artifactsDir?: string;
1298
+ artifactConfig?: Partial<ArtifactConfig>;
1299
+ runId: string;
1300
+ agent: string;
1301
+ flatIndex: number;
1302
+ flatStepCount: number;
1303
+ }): string | undefined {
1304
+ if (!input.artifactsDir || input.artifactConfig?.enabled === false || input.artifactConfig?.includeTranscript === false) return undefined;
1305
+ return getArtifactPaths(
1306
+ input.artifactsDir,
1307
+ input.runId,
1308
+ input.agent,
1309
+ input.flatStepCount > 1 ? input.flatIndex : undefined,
1310
+ ).transcriptPath;
1311
+ }
1312
+
1313
+ type SingleStepResult = Awaited<ReturnType<typeof runSingleStep>>;
1314
+
1059
1315
  async function runSubagent(config: SubagentRunConfig): Promise<void> {
1060
1316
  const { id, steps, resultPath, cwd, placeholder, taskIndex, totalTasks, maxOutput, artifactsDir, artifactConfig } =
1061
1317
  config;
1318
+ const globalSemaphore = new Semaphore(config.globalConcurrencyLimit ?? DEFAULT_GLOBAL_CONCURRENCY_LIMIT);
1062
1319
  let previousOutput = "";
1063
1320
  const outputs: ChainOutputMap = {};
1064
1321
  const results: StepResult[] = [];
@@ -1069,13 +1326,23 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1069
1326
  const eventsPath = path.join(asyncDir, "events.jsonl");
1070
1327
  const logPath = path.join(asyncDir, `subagent-log-${id}.md`);
1071
1328
  const controlConfig = config.controlConfig ?? DEFAULT_CONTROL_CONFIG;
1072
- let activeChildInterrupt: (() => void) | undefined;
1329
+ const activeChildInterrupts = new Map<number, () => void>();
1330
+ const activeChildTimeouts = new Map<number, () => void>();
1331
+ const activeChildTurnBudgetAborts = new Map<number, (message: string, state?: TurnBudgetState) => void>();
1332
+ const pendingStepSteers: SteerRequest[] = [];
1073
1333
  let interrupted = false;
1074
1334
  let currentActivityState: ActivityState | undefined;
1075
1335
  let activityTimer: NodeJS.Timeout | undefined;
1336
+ let timeoutTimer: NodeJS.Timeout | undefined;
1337
+ let timedOut = false;
1338
+ let turnBudgetExceeded = false;
1339
+ const timeoutMessage = config.timeoutMs !== undefined ? `Subagent timed out after ${config.timeoutMs}ms.` : undefined;
1340
+ const timeoutAbortController = new AbortController();
1076
1341
  let previousCumulativeTokens: TokenUsage = { input: 0, output: 0, total: 0 };
1077
1342
  let latestSessionFile: string | undefined;
1078
1343
 
1344
+ const flatSteps = flattenSteps(steps);
1345
+ const initialFlatStepCount = flatSteps.length;
1079
1346
  const parallelGroups: Array<{ start: number; count: number; stepIndex: number }> = [];
1080
1347
  const initialStatusSteps: RunnerStatusStep[] = [];
1081
1348
  let flatStepCount = 0;
@@ -1084,6 +1351,8 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1084
1351
  if (isParallelGroup(step)) {
1085
1352
  parallelGroups.push({ start: flatStepCount, count: step.parallel.length, stepIndex });
1086
1353
  for (const task of step.parallel) {
1354
+ const taskFlatIndex = flatStepCount;
1355
+ const transcriptPath = resolveAsyncStepTranscriptPath({ artifactsDir, artifactConfig, runId: id, agent: task.agent, flatIndex: taskFlatIndex, flatStepCount: initialFlatStepCount });
1087
1356
  initialStatusSteps.push({
1088
1357
  agent: task.agent,
1089
1358
  phase: task.phase,
@@ -1091,7 +1360,9 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1091
1360
  outputName: task.outputName,
1092
1361
  structured: task.structured,
1093
1362
  status: "pending",
1363
+ ...(task.toolBudget ? { toolBudget: initialToolBudgetState(task.toolBudget) } : {}),
1094
1364
  ...(task.sessionFile ? { sessionFile: task.sessionFile } : {}),
1365
+ ...(transcriptPath ? { transcriptPath } : {}),
1095
1366
  skills: task.skills,
1096
1367
  model: task.model,
1097
1368
  thinking: task.thinking,
@@ -1099,8 +1370,8 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1099
1370
  recentTools: [],
1100
1371
  recentOutput: [],
1101
1372
  });
1373
+ flatStepCount++;
1102
1374
  }
1103
- flatStepCount += step.parallel.length;
1104
1375
  } else if (isDynamicRunnerGroup(step)) {
1105
1376
  parallelGroups.push({ start: flatStepCount, count: 1, stepIndex });
1106
1377
  initialStatusSteps.push({
@@ -1110,11 +1381,14 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1110
1381
  outputName: step.collect.as,
1111
1382
  structured: Boolean(step.collect.outputSchema),
1112
1383
  status: "pending",
1384
+ ...(step.parallel.toolBudget ? { toolBudget: initialToolBudgetState(step.parallel.toolBudget) } : {}),
1113
1385
  recentTools: [],
1114
1386
  recentOutput: [],
1115
1387
  });
1116
1388
  flatStepCount++;
1117
1389
  } else {
1390
+ const stepFlatIndex = flatStepCount;
1391
+ const transcriptPath = resolveAsyncStepTranscriptPath({ artifactsDir, artifactConfig, runId: id, agent: step.agent, flatIndex: stepFlatIndex, flatStepCount: initialFlatStepCount });
1118
1392
  initialStatusSteps.push({
1119
1393
  agent: step.agent,
1120
1394
  phase: step.phase,
@@ -1122,7 +1396,9 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1122
1396
  outputName: step.outputName,
1123
1397
  structured: step.structured,
1124
1398
  status: "pending",
1399
+ ...(step.toolBudget ? { toolBudget: initialToolBudgetState(step.toolBudget) } : {}),
1125
1400
  ...(step.sessionFile ? { sessionFile: step.sessionFile } : {}),
1401
+ ...(transcriptPath ? { transcriptPath } : {}),
1126
1402
  skills: step.skills,
1127
1403
  model: step.model,
1128
1404
  thinking: step.thinking,
@@ -1133,11 +1409,11 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1133
1409
  flatStepCount++;
1134
1410
  }
1135
1411
  }
1136
- const flatSteps = flattenSteps(steps);
1137
1412
  const sessionEnabled = Boolean(config.sessionDir)
1138
1413
  || shareEnabled
1139
1414
  || flatSteps.some((step) => Boolean(step.sessionFile));
1140
1415
  const statusPayload: RunnerStatusPayload = {
1416
+ lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
1141
1417
  runId: id,
1142
1418
  ...(config.sessionId ? { sessionId: config.sessionId } : {}),
1143
1419
  mode: config.resultMode ?? (flatSteps.length > 1 ? "chain" : "single"),
@@ -1145,6 +1421,10 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1145
1421
  lastActivityAt: overallStartTime,
1146
1422
  startedAt: overallStartTime,
1147
1423
  lastUpdate: overallStartTime,
1424
+ ...(config.timeoutMs !== undefined ? { timeoutMs: config.timeoutMs } : {}),
1425
+ ...(config.deadlineAt !== undefined ? { deadlineAt: config.deadlineAt } : {}),
1426
+ ...(config.turnBudget ? { turnBudget: initialTurnBudgetState(config.turnBudget) } : {}),
1427
+ ...(config.toolBudget ? { toolBudget: initialToolBudgetState(config.toolBudget) } : {}),
1148
1428
  pid: process.pid,
1149
1429
  cwd,
1150
1430
  currentStep: 0,
@@ -1216,6 +1496,117 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1216
1496
  writeAtomicJson(statusPath, statusPayload);
1217
1497
  emitNestedSelfEvent(statusPayload.state === "running" || statusPayload.state === "queued" ? "subagent.nested.updated" : "subagent.nested.completed");
1218
1498
  };
1499
+ const registerStepInterrupt = (flatIndex: number, interrupt: (() => void) | undefined): void => {
1500
+ if (!interrupt) {
1501
+ activeChildInterrupts.delete(flatIndex);
1502
+ return;
1503
+ }
1504
+ activeChildInterrupts.set(flatIndex, interrupt);
1505
+ if (interrupted) interrupt();
1506
+ };
1507
+ const registerStepTimeout = (flatIndex: number, interrupt: (() => void) | undefined): void => {
1508
+ if (!interrupt) {
1509
+ activeChildTimeouts.delete(flatIndex);
1510
+ return;
1511
+ }
1512
+ activeChildTimeouts.set(flatIndex, interrupt);
1513
+ if (timedOut) interrupt();
1514
+ };
1515
+ const registerStepTurnBudgetAbort = (flatIndex: number, abort: ((message: string, state?: TurnBudgetState) => void) | undefined): void => {
1516
+ if (!abort) {
1517
+ activeChildTurnBudgetAborts.delete(flatIndex);
1518
+ return;
1519
+ }
1520
+ activeChildTurnBudgetAborts.set(flatIndex, abort);
1521
+ };
1522
+ const interruptActiveChildren = (): void => {
1523
+ for (const interrupt of [...activeChildInterrupts.values()]) interrupt();
1524
+ };
1525
+ const timeoutActiveChildren = (): void => {
1526
+ for (const interrupt of [...activeChildTimeouts.values()]) interrupt();
1527
+ };
1528
+ const nestedRuns = function* (children: NestedRunSummary[] | undefined): Generator<NestedRunSummary> {
1529
+ for (const child of children ?? []) {
1530
+ yield child;
1531
+ yield* nestedRuns(child.children);
1532
+ yield* nestedRuns(child.steps?.flatMap((step) => step.children ?? []));
1533
+ }
1534
+ };
1535
+ const interruptNestedAsyncDescendants = (): void => {
1536
+ if (!config.nestedRoute) return;
1537
+ let registry: ReturnType<typeof projectNestedEvents>;
1538
+ try {
1539
+ registry = projectNestedEvents(config.nestedRoute);
1540
+ } catch (error) {
1541
+ appendJsonl(eventsPath, JSON.stringify({
1542
+ type: "subagent.nested.interrupt_failed",
1543
+ ts: Date.now(),
1544
+ runId: id,
1545
+ message: error instanceof Error ? error.message : String(error),
1546
+ }));
1547
+ return;
1548
+ }
1549
+ for (const run of nestedRuns(registry.children)) {
1550
+ if (run.state !== "running" && run.state !== "queued") continue;
1551
+ const nestedAsyncDir = run.asyncDir ?? resolveNestedAsyncDir(config.nestedRoute.rootRunId, run);
1552
+ if (!nestedAsyncDir) continue;
1553
+ try {
1554
+ deliverInterruptRequest({ asyncDir: nestedAsyncDir, pid: run.pid, source: "ancestor-interrupt" });
1555
+ } catch (error) {
1556
+ appendJsonl(eventsPath, JSON.stringify({
1557
+ type: "subagent.nested.interrupt_failed",
1558
+ ts: Date.now(),
1559
+ runId: id,
1560
+ targetRunId: run.id,
1561
+ message: error instanceof Error ? error.message : String(error),
1562
+ }));
1563
+ }
1564
+ }
1565
+ };
1566
+ const timeoutNestedAsyncDescendants = (): void => {
1567
+ if (!config.nestedRoute) return;
1568
+ let registry: ReturnType<typeof projectNestedEvents>;
1569
+ try {
1570
+ registry = projectNestedEvents(config.nestedRoute);
1571
+ } catch (error) {
1572
+ appendJsonl(eventsPath, JSON.stringify({
1573
+ type: "subagent.nested.timeout_failed",
1574
+ ts: Date.now(),
1575
+ runId: id,
1576
+ message: error instanceof Error ? error.message : String(error),
1577
+ }));
1578
+ return;
1579
+ }
1580
+ for (const run of nestedRuns(registry.children)) {
1581
+ if (run.state !== "running" && run.state !== "queued") continue;
1582
+ const nestedAsyncDir = run.asyncDir ?? resolveNestedAsyncDir(config.nestedRoute.rootRunId, run);
1583
+ if (!nestedAsyncDir) continue;
1584
+ try {
1585
+ deliverTimeoutRequest({ asyncDir: nestedAsyncDir, pid: run.pid, source: "ancestor-timeout" });
1586
+ } catch (error) {
1587
+ appendJsonl(eventsPath, JSON.stringify({
1588
+ type: "subagent.nested.timeout_failed",
1589
+ ts: Date.now(),
1590
+ runId: id,
1591
+ targetRunId: run.id,
1592
+ message: error instanceof Error ? error.message : String(error),
1593
+ }));
1594
+ }
1595
+ }
1596
+ };
1597
+ const pausedStepResult = (agent: string): SingleStepResult => ({
1598
+ agent,
1599
+ output: "Paused after interrupt. Waiting for explicit next action.",
1600
+ exitCode: 0,
1601
+ interrupted: true,
1602
+ });
1603
+ const timedOutStepResult = (agent: string): SingleStepResult => ({
1604
+ agent,
1605
+ output: timeoutMessage ?? "Subagent timed out.",
1606
+ error: timeoutMessage ?? "Subagent timed out.",
1607
+ exitCode: 1,
1608
+ timedOut: true,
1609
+ });
1219
1610
  const consumePendingAppendRequests = (): void => {
1220
1611
  if (statusPayload.mode !== "chain" || statusPayload.state !== "running") return;
1221
1612
  const requests = consumeChainAppendRequests(asyncDir);
@@ -1346,6 +1737,58 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1346
1737
  appendControlEvent(event);
1347
1738
  return true;
1348
1739
  };
1740
+ const deliverSteerRequest = (request: SteerRequest): void => {
1741
+ if (statusPayload.state !== "running") return;
1742
+ const runningIndexes = statusPayload.steps
1743
+ .map((step, index) => ({ step, index }))
1744
+ .filter(({ step }) => step.status === "running")
1745
+ .map(({ index }) => index);
1746
+ const targets = request.targetIndex !== undefined ? [request.targetIndex] : runningIndexes;
1747
+ const now = Date.now();
1748
+ const accepted: number[] = [];
1749
+ const rejected: Array<{ index: number; reason: string }> = [];
1750
+ for (const index of targets) {
1751
+ 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;
1759
+ }
1760
+ enqueueStepSteer(asyncDir, index, request);
1761
+ step.steerCount = (step.steerCount ?? 0) + 1;
1762
+ step.lastSteerAt = now;
1763
+ accepted.push(index);
1764
+ }
1765
+ if (accepted.length > 0) {
1766
+ statusPayload.steerCount = (statusPayload.steerCount ?? 0) + accepted.length;
1767
+ statusPayload.lastSteerAt = now;
1768
+ statusPayload.lastUpdate = now;
1769
+ writeStatusPayload();
1770
+ }
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
+ }));
1782
+ };
1783
+ const flushPendingStepSteers = (flatIndex: number): void => {
1784
+ const remaining: SteerRequest[] = [];
1785
+ for (const request of pendingStepSteers.splice(0)) {
1786
+ if (request.targetIndex === undefined) deliverSteerRequest({ ...request, targetIndex: flatIndex });
1787
+ else if (request.targetIndex === flatIndex) deliverSteerRequest(request);
1788
+ else remaining.push(request);
1789
+ }
1790
+ pendingStepSteers.push(...remaining);
1791
+ };
1349
1792
  const updateStepModel = (flatIndex: number, model: string | undefined, thinking: string | undefined, now = Date.now()): void => {
1350
1793
  const step = statusPayload.steps[flatIndex];
1351
1794
  if (!step) return;
@@ -1354,6 +1797,40 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1354
1797
  statusPayload.lastUpdate = now;
1355
1798
  writeStatusPayload();
1356
1799
  };
1800
+ const updateStepTurnBudget = (flatIndex: number, turnCount: number, now: number, terminalAssistantStop: boolean): void => {
1801
+ const budget = config.turnBudget;
1802
+ const step = statusPayload.steps[flatIndex];
1803
+ if (!budget || !step || timedOut || turnBudgetExceeded || step.turnBudgetExceeded) return;
1804
+ if (turnCount < budget.maxTurns) {
1805
+ const state: TurnBudgetState = { ...budget, outcome: "within-budget", turnCount };
1806
+ step.turnBudget = state;
1807
+ statusPayload.turnBudget = state;
1808
+ return;
1809
+ }
1810
+ const state = turnBudgetState(budget, turnCount, false);
1811
+ step.turnBudget = state;
1812
+ statusPayload.turnBudget = state;
1813
+ if (!step.wrapUpRequested) {
1814
+ step.wrapUpRequested = true;
1815
+ statusPayload.wrapUpRequested = true;
1816
+ appendRecentStepOutput(step, [turnBudgetSoftNote(budget, turnCount)]);
1817
+ }
1818
+ if (!shouldAbortForTurnBudget(budget, turnCount, terminalAssistantStop)) return;
1819
+ const exceededState = turnBudgetState(budget, turnCount, true);
1820
+ const message = turnBudgetExceededMessage(budget, turnCount);
1821
+ step.turnBudget = exceededState;
1822
+ step.turnBudgetExceeded = true;
1823
+ step.wrapUpRequested = true;
1824
+ step.error = message;
1825
+ turnBudgetExceeded = true;
1826
+ statusPayload.turnBudget = exceededState;
1827
+ statusPayload.turnBudgetExceeded = true;
1828
+ statusPayload.wrapUpRequested = true;
1829
+ statusPayload.error = message;
1830
+ statusPayload.lastUpdate = now;
1831
+ appendJsonl(eventsPath, JSON.stringify({ type: "subagent.step.turn_budget_exceeded", ts: now, runId: id, stepIndex: flatIndex, agent: step.agent, turnCount, maxTurns: budget.maxTurns, graceTurns: budget.graceTurns, message }));
1832
+ activeChildTurnBudgetAborts.get(flatIndex)?.(message, exceededState);
1833
+ };
1357
1834
  const updateStepFromChildEvent = (flatIndex: number, event: ChildEvent): void => {
1358
1835
  const step = statusPayload.steps[flatIndex];
1359
1836
  if (!step) return;
@@ -1363,6 +1840,11 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1363
1840
  const mutates = isMutatingTool(event.toolName, event.args);
1364
1841
  const currentPath = resolveCurrentPath(event.toolName, event.args);
1365
1842
  step.toolCount = (step.toolCount ?? 0) + 1;
1843
+ const configuredToolBudget = flatSteps[flatIndex]?.toolBudget;
1844
+ if (configuredToolBudget) {
1845
+ step.toolBudget = toolBudgetState(configuredToolBudget, step.toolCount);
1846
+ statusPayload.toolBudget = step.toolBudget;
1847
+ }
1366
1848
  step.currentTool = event.toolName;
1367
1849
  step.currentToolArgs = extractToolArgsPreview(event.args ?? {});
1368
1850
  step.currentToolStartedAt = now;
@@ -1384,6 +1866,15 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1384
1866
  const toolSnapshot = pendingToolResults[flatIndex];
1385
1867
  pendingToolResults[flatIndex] = undefined;
1386
1868
  const resultText = extractTextFromContent(event.message.content);
1869
+ if (toolSnapshot && resultText.includes("Tool budget hard limit reached")) {
1870
+ const configuredToolBudget = flatSteps[flatIndex]?.toolBudget;
1871
+ if (configuredToolBudget) {
1872
+ step.toolBudget = toolBudgetState(configuredToolBudget, step.toolCount ?? 0, toolSnapshot.tool);
1873
+ step.toolBudgetBlocked = true;
1874
+ statusPayload.toolBudget = step.toolBudget;
1875
+ statusPayload.toolBudgetBlocked = true;
1876
+ }
1877
+ }
1387
1878
  appendRecentStepOutput(step, resultText.split("\n").slice(-10));
1388
1879
  if (toolSnapshot?.mutates && didMutatingToolFail(resultText)) {
1389
1880
  const state = mutatingFailureStates[flatIndex]!;
@@ -1434,6 +1925,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1434
1925
  statusPayload.totalTokens = { input: totalInput + input, output: totalOutput + output, total: totalInput + totalOutput + input + output };
1435
1926
  }
1436
1927
  statusPayload.turnCount = Math.max(statusPayload.turnCount ?? 0, step.turnCount);
1928
+ updateStepTurnBudget(flatIndex, step.turnCount, now, isTerminalAssistantStop(event.message));
1437
1929
  }
1438
1930
  syncTopLevelCurrentTool();
1439
1931
  step.lastActivityAt = now;
@@ -1531,17 +2023,68 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1531
2023
  ts: now,
1532
2024
  runId: id,
1533
2025
  }));
1534
- activeChildInterrupt?.();
2026
+ interruptNestedAsyncDescendants();
2027
+ interruptActiveChildren();
2028
+ };
2029
+ const timeoutRunner = () => {
2030
+ if (timedOut || interrupted || statusPayload.state !== "running") return;
2031
+ timedOut = true;
2032
+ const now = Date.now();
2033
+ const message = timeoutMessage ?? "Subagent timed out.";
2034
+ statusPayload.state = "failed";
2035
+ statusPayload.timedOut = true;
2036
+ statusPayload.error = message;
2037
+ currentActivityState = undefined;
2038
+ statusPayload.activityState = undefined;
2039
+ statusPayload.lastUpdate = now;
2040
+ for (const step of statusPayload.steps) {
2041
+ if (step.status !== "running" && step.status !== "pending") continue;
2042
+ step.status = "failed";
2043
+ step.error = message;
2044
+ step.exitCode = 1;
2045
+ step.timedOut = true;
2046
+ step.activityState = undefined;
2047
+ step.endedAt = now;
2048
+ step.durationMs = step.startedAt ? now - step.startedAt : 0;
2049
+ step.lastActivityAt = now;
2050
+ }
2051
+ writeStatusPayload();
2052
+ appendJsonl(eventsPath, JSON.stringify({
2053
+ type: "subagent.run.timed_out",
2054
+ ts: now,
2055
+ runId: id,
2056
+ timeoutMs: config.timeoutMs,
2057
+ deadlineAt: config.deadlineAt,
2058
+ message,
2059
+ }));
2060
+ timeoutAbortController.abort();
2061
+ timeoutNestedAsyncDescendants();
2062
+ timeoutActiveChildren();
1535
2063
  };
1536
2064
  process.on(ASYNC_INTERRUPT_SIGNAL, interruptRunner);
1537
- // Portable control inbox: the parent drops an interrupt request file here when
1538
- // it cannot deliver the OS signal (e.g. ENOSYS on Windows). Routes into the
1539
- // same graceful interruptRunner() so stop/steer work on every platform.
1540
- const disposeControlInbox = watchAsyncControlInbox(asyncDir, { onInterrupt: interruptRunner });
2065
+ // Portable control inbox: the parent drops control request files here when
2066
+ // it cannot deliver OS signals (e.g. ENOSYS on Windows) or when steering a
2067
+ // live child. Interrupts still route into the same graceful interruptRunner().
2068
+ const disposeControlInbox = watchAsyncControlInbox(asyncDir, {
2069
+ onInterrupt: interruptRunner,
2070
+ onTimeout: timeoutRunner,
2071
+ onSteer: (request) => {
2072
+ 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);
2076
+ },
2077
+ });
2078
+ if (config.deadlineAt !== undefined) {
2079
+ const remainingMs = Math.max(0, config.deadlineAt - Date.now());
2080
+ timeoutTimer = setTimeout(timeoutRunner, remainingMs);
2081
+ timeoutTimer.unref?.();
2082
+ }
1541
2083
  appendJsonl(
1542
2084
  eventsPath,
1543
2085
  JSON.stringify({
1544
2086
  type: "subagent.run.started",
2087
+ lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
1545
2088
  ts: overallStartTime,
1546
2089
  runId: id,
1547
2090
  mode: statusPayload.mode,
@@ -1554,7 +2097,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1554
2097
  let stepCursor = 0;
1555
2098
 
1556
2099
  while (true) {
1557
- if (interrupted) break;
2100
+ if (interrupted || timedOut || turnBudgetExceeded) break;
1558
2101
  consumePendingAppendRequests();
1559
2102
  if (stepCursor >= steps.length) break;
1560
2103
  const stepIndex = stepCursor++;
@@ -1606,7 +2149,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1606
2149
  placeholder.durationMs = 0;
1607
2150
  }
1608
2151
  previousOutput = "Dynamic fanout produced 0 results.";
1609
- const groupAcceptance = step.effectiveAcceptance?.explicit
2152
+ const groupAcceptance = step.effectiveAcceptance?.explicit && !timedOut
1610
2153
  ? await evaluateAcceptance({
1611
2154
  acceptance: step.effectiveAcceptance,
1612
2155
  output: "",
@@ -1615,39 +2158,59 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1615
2158
  notes: "Dynamic fanout produced 0 results.",
1616
2159
  }),
1617
2160
  cwd,
2161
+ signal: timeoutAbortController.signal,
2162
+ abortMessage: timeoutMessage ?? "Subagent timed out.",
1618
2163
  })
1619
2164
  : undefined;
1620
- if (placeholder && groupAcceptance) placeholder.acceptance = groupAcceptance;
1621
- const groupAcceptanceFailure = groupAcceptance ? acceptanceFailureMessage(groupAcceptance) : undefined;
1622
- if (groupAcceptanceFailure) {
2165
+ const groupTimedOut = timedOut || timeoutAbortController.signal.aborted;
2166
+ const effectiveGroupAcceptance = groupTimedOut ? undefined : groupAcceptance;
2167
+ if (placeholder && effectiveGroupAcceptance) placeholder.acceptance = effectiveGroupAcceptance;
2168
+ const groupAcceptanceFailure = effectiveGroupAcceptance ? acceptanceFailureMessage(effectiveGroupAcceptance) : undefined;
2169
+ if (groupTimedOut || groupAcceptanceFailure) {
2170
+ const errorMessage = groupTimedOut ? timeoutMessage ?? "Subagent timed out." : groupAcceptanceFailure!;
1623
2171
  statusPayload.state = "failed";
1624
- statusPayload.error = groupAcceptanceFailure;
2172
+ statusPayload.error = errorMessage;
1625
2173
  if (placeholder) {
1626
2174
  placeholder.status = "failed";
1627
- placeholder.error = groupAcceptanceFailure;
2175
+ placeholder.error = errorMessage;
1628
2176
  placeholder.exitCode = 1;
2177
+ placeholder.timedOut = groupTimedOut ? true : undefined;
1629
2178
  }
1630
- markDynamicGraphGroup(stepIndex, "failed", groupAcceptanceFailure, groupAcceptance);
1631
- statusPayload.lastUpdate = now;
2179
+ markDynamicGraphGroup(stepIndex, "failed", errorMessage, effectiveGroupAcceptance);
2180
+ statusPayload.lastUpdate = Date.now();
1632
2181
  writeStatusPayload();
1633
- results.push({ agent: step.parallel.agent, output: groupAcceptanceFailure, error: groupAcceptanceFailure, success: false, exitCode: 1, acceptance: groupAcceptance });
2182
+ results.push({ agent: step.parallel.agent, output: errorMessage, error: errorMessage, success: false, exitCode: 1, timedOut: groupTimedOut ? true : undefined, acceptance: effectiveGroupAcceptance });
1634
2183
  break;
1635
2184
  }
1636
2185
  flatIndex++;
1637
2186
  statusPayload.lastUpdate = now;
1638
- markDynamicGraphGroup(stepIndex, "completed", undefined, groupAcceptance);
2187
+ markDynamicGraphGroup(stepIndex, "completed", undefined, effectiveGroupAcceptance);
1639
2188
  writeStatusPayload();
1640
2189
  continue;
1641
2190
  }
1642
2191
 
1643
- const dynamicSteps = materialized.parallel.map((task, itemIndex) => ({
1644
- ...step.parallel,
1645
- task: task.task ?? step.parallel.task,
1646
- label: task.label ?? step.parallel.label,
1647
- structuredOutput: undefined,
1648
- structuredOutputSchema: step.parallel.structuredOutputSchema ?? step.parallel.structuredOutput?.schema,
1649
- }));
1650
- const dynamicStatusSteps: RunnerStatusStep[] = dynamicSteps.map((task) => ({
2192
+ const dynamicSteps = materialized.parallel.map((task, itemIndex) => {
2193
+ const thinkingOverride = step.thinkingOverrides?.[itemIndex];
2194
+ const model = thinkingOverride ? applyThinkingSuffix(step.parallel.model, thinkingOverride, true) : step.parallel.model;
2195
+ const thinking = thinkingOverride ? resolveEffectiveThinking(model, thinkingOverride) : undefined;
2196
+ return {
2197
+ ...step.parallel,
2198
+ task: task.task ?? step.parallel.task,
2199
+ label: task.label ?? step.parallel.label,
2200
+ ...(step.sessionFiles?.[itemIndex] ? { sessionFile: step.sessionFiles[itemIndex] } : {}),
2201
+ ...(thinkingOverride ? {
2202
+ ...(model ? { model } : {}),
2203
+ ...(thinking ? { thinking } : {}),
2204
+ ...(step.parallel.modelCandidates ? { modelCandidates: step.parallel.modelCandidates.map((candidate) => applyThinkingSuffix(candidate, thinkingOverride, true)) } : {}),
2205
+ } : {}),
2206
+ structuredOutput: undefined,
2207
+ structuredOutputSchema: step.parallel.structuredOutputSchema ?? step.parallel.structuredOutput?.schema,
2208
+ };
2209
+ });
2210
+ const dynamicFlatStepCount = Math.max(statusPayload.steps.length - 1 + dynamicSteps.length, 1);
2211
+ const dynamicStatusSteps: RunnerStatusStep[] = dynamicSteps.map((task, itemIndex) => {
2212
+ const transcriptPath = resolveAsyncStepTranscriptPath({ artifactsDir, artifactConfig, runId: id, agent: task.agent, flatIndex: groupStartFlatIndex + itemIndex, flatStepCount: dynamicFlatStepCount });
2213
+ return {
1651
2214
  agent: task.agent,
1652
2215
  phase: task.phase ?? step.phase,
1653
2216
  label: task.label,
@@ -1655,13 +2218,15 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1655
2218
  structured: Boolean(task.structuredOutputSchema),
1656
2219
  status: "pending",
1657
2220
  ...(task.sessionFile ? { sessionFile: task.sessionFile } : {}),
2221
+ ...(transcriptPath ? { transcriptPath } : {}),
1658
2222
  skills: task.skills,
1659
2223
  model: task.model,
1660
2224
  thinking: task.thinking,
1661
2225
  attemptedModels: task.modelCandidates && task.modelCandidates.length > 0 ? task.modelCandidates : task.model ? [task.model] : undefined,
1662
2226
  recentTools: [],
1663
2227
  recentOutput: [],
1664
- }));
2228
+ };
2229
+ });
1665
2230
  statusPayload.steps.splice(groupStartFlatIndex, 1, ...dynamicStatusSteps);
1666
2231
  if (config.childIntercomTargets) {
1667
2232
  config.childIntercomTargets = statusPayload.steps.map((statusStep, index) => resolveSubagentIntercomTarget(id, statusStep.agent, index));
@@ -1710,6 +2275,8 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1710
2275
  let aborted = false;
1711
2276
  const parallelResults = await mapConcurrent(dynamicSteps, concurrency, async (task, taskIdx) => {
1712
2277
  const fi = groupStartFlatIndex + taskIdx;
2278
+ if (timedOut) return timedOutStepResult(task.agent);
2279
+ if (interrupted) return pausedStepResult(task.agent);
1713
2280
  if (aborted && failFast) {
1714
2281
  const skippedAt = Date.now();
1715
2282
  statusPayload.steps[fi].status = "failed";
@@ -1735,6 +2302,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1735
2302
  statusPayload.lastUpdate = taskStartTime;
1736
2303
  writeStatusPayload();
1737
2304
  appendJsonl(eventsPath, JSON.stringify({ type: "subagent.step.started", ts: taskStartTime, runId: id, stepIndex: fi, agent: task.agent }));
2305
+ flushPendingStepSteers(fi);
1738
2306
  const singleResult = await runSingleStep(task, {
1739
2307
  previousOutput, placeholder, cwd, sessionEnabled,
1740
2308
  outputs,
@@ -1742,27 +2310,47 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1742
2310
  artifactsDir, artifactConfig, id,
1743
2311
  flatIndex: fi, flatStepCount: Math.max(statusPayload.steps.length, 1),
1744
2312
  outputFile: path.join(asyncDir, `output-${fi}.log`),
2313
+ steerInboxDir: stepSteerInboxDir(asyncDir, fi),
1745
2314
  piPackageRoot: config.piPackageRoot,
1746
2315
  piArgv1: config.piArgv1,
1747
2316
  childIntercomTarget: config.childIntercomTargets?.[fi],
1748
2317
  orchestratorIntercomTarget: config.controlIntercomTarget,
1749
2318
  nestedRoute: config.nestedRoute,
1750
- registerInterrupt: (interrupt) => {
1751
- activeChildInterrupt = interrupt;
1752
- },
2319
+ registerInterrupt: (interrupt) => registerStepInterrupt(fi, interrupt),
2320
+ registerTimeout: (interrupt) => registerStepTimeout(fi, interrupt),
2321
+ registerTurnBudgetAbort: (abort) => registerStepTurnBudgetAbort(fi, abort),
2322
+ timeoutSignal: timeoutAbortController.signal,
2323
+ timeoutMessage,
2324
+ turnBudget: config.turnBudget,
1753
2325
  onAttemptStart: (attempt) => updateStepModel(fi, attempt.model, attempt.thinking),
1754
2326
  onChildEvent: (event) => updateStepFromChildEvent(fi, event),
2327
+ skipAcceptance: () => timedOut,
1755
2328
  });
1756
2329
  const taskEndTime = Date.now();
1757
- statusPayload.steps[fi].status = singleResult.exitCode === 0 ? "complete" : "failed";
2330
+ const childInterrupted = singleResult.interrupted === true;
2331
+ statusPayload.steps[fi].status = timedOut ? "failed" : childInterrupted ? "paused" : singleResult.exitCode === 0 ? "complete" : "failed";
1758
2332
  statusPayload.steps[fi].endedAt = taskEndTime;
1759
2333
  statusPayload.steps[fi].durationMs = taskEndTime - taskStartTime;
1760
- statusPayload.steps[fi].exitCode = singleResult.exitCode;
2334
+ statusPayload.steps[fi].exitCode = timedOut ? 1 : childInterrupted ? 0 : singleResult.exitCode;
2335
+ statusPayload.steps[fi].timedOut = timedOut || singleResult.timedOut ? true : undefined;
2336
+ statusPayload.steps[fi].turnBudget = singleResult.turnBudget;
2337
+ statusPayload.steps[fi].turnBudgetExceeded = singleResult.turnBudgetExceeded;
2338
+ statusPayload.steps[fi].wrapUpRequested = singleResult.wrapUpRequested;
2339
+ statusPayload.steps[fi].toolBudget = singleResult.toolBudget;
2340
+ statusPayload.steps[fi].toolBudgetBlocked = singleResult.toolBudgetBlocked;
2341
+ if (singleResult.toolBudget) statusPayload.toolBudget = singleResult.toolBudget;
2342
+ if (singleResult.toolBudgetBlocked) statusPayload.toolBudgetBlocked = true;
2343
+ if (singleResult.turnBudget) statusPayload.turnBudget = singleResult.turnBudget;
2344
+ if (singleResult.turnBudgetExceeded) statusPayload.turnBudgetExceeded = true;
2345
+ if (singleResult.wrapUpRequested) statusPayload.wrapUpRequested = true;
1761
2346
  statusPayload.steps[fi].model = singleResult.model;
1762
2347
  statusPayload.steps[fi].thinking = resolveEffectiveThinking(singleResult.model, statusPayload.steps[fi].thinking);
1763
2348
  statusPayload.steps[fi].attemptedModels = singleResult.attemptedModels;
1764
2349
  statusPayload.steps[fi].modelAttempts = singleResult.modelAttempts;
1765
- statusPayload.steps[fi].error = singleResult.error;
2350
+ statusPayload.steps[fi].totalCost = singleResult.totalCost;
2351
+ statusPayload.steps[fi].error = timedOut ? (timeoutMessage ?? "Subagent timed out.") : singleResult.error;
2352
+ statusPayload.steps[fi].transcriptPath = singleResult.transcriptPath ?? statusPayload.steps[fi].transcriptPath;
2353
+ statusPayload.steps[fi].transcriptError = singleResult.transcriptError;
1766
2354
  statusPayload.steps[fi].structuredOutput = singleResult.structuredOutput;
1767
2355
  statusPayload.steps[fi].structuredOutputPath = singleResult.structuredOutputPath;
1768
2356
  statusPayload.steps[fi].structuredOutputSchemaPath = singleResult.structuredOutputSchemaPath;
@@ -1770,13 +2358,13 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1770
2358
  statusPayload.lastUpdate = taskEndTime;
1771
2359
  writeStatusPayload();
1772
2360
  appendJsonl(eventsPath, JSON.stringify({
1773
- type: singleResult.exitCode === 0 ? "subagent.step.completed" : "subagent.step.failed",
2361
+ type: timedOut ? "subagent.step.failed" : childInterrupted ? "subagent.step.paused" : singleResult.exitCode === 0 ? "subagent.step.completed" : "subagent.step.failed",
1774
2362
  ts: taskEndTime, runId: id, stepIndex: fi, agent: task.agent,
1775
- exitCode: singleResult.exitCode, durationMs: taskEndTime - taskStartTime,
2363
+ exitCode: timedOut ? 1 : childInterrupted ? 0 : singleResult.exitCode, durationMs: taskEndTime - taskStartTime,
1776
2364
  }));
1777
2365
  if (singleResult.exitCode !== 0 && failFast) aborted = true;
1778
- return { ...singleResult, skipped: false };
1779
- });
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 };
2367
+ }, globalSemaphore);
1780
2368
 
1781
2369
  flatIndex += dynamicSteps.length;
1782
2370
  for (const pr of parallelResults) {
@@ -1784,15 +2372,25 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1784
2372
  agent: pr.agent,
1785
2373
  output: pr.output,
1786
2374
  error: pr.error,
1787
- success: pr.exitCode === 0,
1788
- exitCode: pr.exitCode,
2375
+ success: pr.interrupted !== true && pr.exitCode === 0,
2376
+ exitCode: pr.interrupted === true ? 0 : pr.exitCode,
1789
2377
  skipped: pr.skipped,
2378
+ interrupted: pr.interrupted,
2379
+ timedOut: pr.timedOut,
2380
+ turnBudget: pr.turnBudget,
2381
+ turnBudgetExceeded: pr.turnBudgetExceeded,
2382
+ wrapUpRequested: pr.wrapUpRequested,
2383
+ toolBudget: pr.toolBudget,
2384
+ toolBudgetBlocked: pr.toolBudgetBlocked,
1790
2385
  sessionFile: pr.sessionFile,
1791
2386
  intercomTarget: pr.intercomTarget,
1792
2387
  model: pr.model,
1793
2388
  attemptedModels: pr.attemptedModels,
1794
2389
  modelAttempts: pr.modelAttempts,
2390
+ totalCost: pr.totalCost,
1795
2391
  artifactPaths: pr.artifactPaths,
2392
+ transcriptPath: pr.transcriptPath,
2393
+ transcriptError: pr.transcriptError,
1796
2394
  structuredOutput: pr.structuredOutput,
1797
2395
  structuredOutputPath: pr.structuredOutputPath,
1798
2396
  structuredOutputSchemaPath: pr.structuredOutputSchemaPath,
@@ -1811,7 +2409,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1811
2409
  stepIndex,
1812
2410
  };
1813
2411
  statusPayload.outputs = outputs;
1814
- const groupAcceptance = step.effectiveAcceptance
2412
+ const groupAcceptance = step.effectiveAcceptance && !timedOut
1815
2413
  ? await evaluateAcceptance({
1816
2414
  acceptance: step.effectiveAcceptance,
1817
2415
  output: "",
@@ -1820,21 +2418,27 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1820
2418
  notes: `Dynamic fanout collected ${collection.length} result(s) into ${step.collect.as}.`,
1821
2419
  }),
1822
2420
  cwd,
2421
+ signal: timeoutAbortController.signal,
2422
+ abortMessage: timeoutMessage ?? "Subagent timed out.",
1823
2423
  })
1824
2424
  : undefined;
1825
- const groupAcceptanceFailure = groupAcceptance ? acceptanceFailureMessage(groupAcceptance) : undefined;
1826
- markDynamicGraphGroup(stepIndex, groupAcceptanceFailure ? "failed" : "completed", groupAcceptanceFailure, groupAcceptance);
1827
- if (groupAcceptanceFailure) {
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);
2430
+ if (groupError) {
1828
2431
  results.push({
1829
2432
  agent: step.parallel.agent,
1830
- output: groupAcceptanceFailure,
1831
- error: groupAcceptanceFailure,
2433
+ output: groupError,
2434
+ error: groupError,
1832
2435
  success: false,
1833
2436
  exitCode: 1,
2437
+ timedOut: groupTimedOut ? true : undefined,
1834
2438
  structuredOutput: collection,
1835
- acceptance: groupAcceptance,
2439
+ acceptance: effectiveGroupAcceptance,
1836
2440
  });
1837
- statusPayload.error = groupAcceptanceFailure;
2441
+ statusPayload.error = groupError;
1838
2442
  }
1839
2443
  } catch (error) {
1840
2444
  const message = error instanceof DynamicFanoutError ? error.message : error instanceof Error ? error.message : String(error);
@@ -1900,6 +2504,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1900
2504
  setupHook: config.worktreeSetupHook
1901
2505
  ? { hookPath: config.worktreeSetupHook, timeoutMs: config.worktreeSetupHookTimeoutMs }
1902
2506
  : undefined,
2507
+ baseDir: config.worktreeBaseDir,
1903
2508
  });
1904
2509
  } catch (error) {
1905
2510
  const setupError = error instanceof Error ? error.message : String(error);
@@ -1941,6 +2546,8 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1941
2546
  concurrency,
1942
2547
  async (task, taskIdx) => {
1943
2548
  const fi = groupStartFlatIndex + taskIdx;
2549
+ if (timedOut) return timedOutStepResult(task.agent);
2550
+ if (interrupted) return pausedStepResult(task.agent);
1944
2551
  if (aborted && failFast) {
1945
2552
  const skippedAt = Date.now();
1946
2553
  statusPayload.steps[fi].status = "failed";
@@ -1981,6 +2588,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1981
2588
  ? path.join(config.sessionDir, `parallel-${taskIdx}`)
1982
2589
  : undefined;
1983
2590
  const { taskForRun, taskCwd } = prepareParallelTaskRun(task, cwd, worktreeSetup, taskIdx);
2591
+ flushPendingStepSteers(fi);
1984
2592
 
1985
2593
  const singleResult = await runSingleStep(taskForRun, {
1986
2594
  previousOutput, placeholder, cwd: taskCwd, sessionEnabled,
@@ -1989,16 +2597,21 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1989
2597
  artifactsDir, artifactConfig, id,
1990
2598
  flatIndex: fi, flatStepCount: Math.max(statusPayload.steps.length, 1),
1991
2599
  outputFile: path.join(asyncDir, `output-${fi}.log`),
2600
+ steerInboxDir: stepSteerInboxDir(asyncDir, fi),
1992
2601
  piPackageRoot: config.piPackageRoot,
1993
2602
  piArgv1: config.piArgv1,
1994
2603
  childIntercomTarget: config.childIntercomTargets?.[fi],
1995
2604
  orchestratorIntercomTarget: config.controlIntercomTarget,
1996
2605
  nestedRoute: config.nestedRoute,
1997
- registerInterrupt: (interrupt) => {
1998
- activeChildInterrupt = interrupt;
1999
- },
2606
+ registerInterrupt: (interrupt) => registerStepInterrupt(fi, interrupt),
2607
+ registerTimeout: (interrupt) => registerStepTimeout(fi, interrupt),
2608
+ registerTurnBudgetAbort: (abort) => registerStepTurnBudgetAbort(fi, abort),
2609
+ timeoutSignal: timeoutAbortController.signal,
2610
+ timeoutMessage,
2611
+ turnBudget: config.turnBudget,
2000
2612
  onAttemptStart: (attempt) => updateStepModel(fi, attempt.model, attempt.thinking),
2001
2613
  onChildEvent: (event) => updateStepFromChildEvent(fi, event),
2614
+ skipAcceptance: () => timedOut,
2002
2615
  });
2003
2616
  if (task.sessionFile) {
2004
2617
  latestSessionFile = task.sessionFile;
@@ -2006,16 +2619,31 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2006
2619
 
2007
2620
  const taskEndTime = Date.now();
2008
2621
  const taskDuration = taskEndTime - taskStartTime;
2622
+ const childInterrupted = singleResult.interrupted === true;
2009
2623
 
2010
- statusPayload.steps[fi].status = singleResult.exitCode === 0 ? "complete" : "failed";
2624
+ statusPayload.steps[fi].status = timedOut ? "failed" : childInterrupted ? "paused" : singleResult.exitCode === 0 ? "complete" : "failed";
2011
2625
  statusPayload.steps[fi].endedAt = taskEndTime;
2012
2626
  statusPayload.steps[fi].durationMs = taskDuration;
2013
- statusPayload.steps[fi].exitCode = singleResult.exitCode;
2627
+ statusPayload.steps[fi].exitCode = timedOut ? 1 : childInterrupted ? 0 : singleResult.exitCode;
2628
+ statusPayload.steps[fi].timedOut = timedOut || singleResult.timedOut ? true : undefined;
2629
+ statusPayload.steps[fi].turnBudget = singleResult.turnBudget;
2630
+ statusPayload.steps[fi].turnBudgetExceeded = singleResult.turnBudgetExceeded;
2631
+ statusPayload.steps[fi].wrapUpRequested = singleResult.wrapUpRequested;
2632
+ statusPayload.steps[fi].toolBudget = singleResult.toolBudget;
2633
+ statusPayload.steps[fi].toolBudgetBlocked = singleResult.toolBudgetBlocked;
2634
+ if (singleResult.toolBudget) statusPayload.toolBudget = singleResult.toolBudget;
2635
+ if (singleResult.toolBudgetBlocked) statusPayload.toolBudgetBlocked = true;
2636
+ if (singleResult.turnBudget) statusPayload.turnBudget = singleResult.turnBudget;
2637
+ if (singleResult.turnBudgetExceeded) statusPayload.turnBudgetExceeded = true;
2638
+ if (singleResult.wrapUpRequested) statusPayload.wrapUpRequested = true;
2014
2639
  statusPayload.steps[fi].model = singleResult.model;
2015
2640
  statusPayload.steps[fi].thinking = resolveEffectiveThinking(singleResult.model, statusPayload.steps[fi].thinking);
2016
2641
  statusPayload.steps[fi].attemptedModels = singleResult.attemptedModels;
2017
2642
  statusPayload.steps[fi].modelAttempts = singleResult.modelAttempts;
2018
- statusPayload.steps[fi].error = singleResult.error;
2643
+ statusPayload.steps[fi].totalCost = singleResult.totalCost;
2644
+ statusPayload.steps[fi].error = timedOut ? (timeoutMessage ?? "Subagent timed out.") : singleResult.error;
2645
+ statusPayload.steps[fi].transcriptPath = singleResult.transcriptPath ?? statusPayload.steps[fi].transcriptPath;
2646
+ statusPayload.steps[fi].transcriptError = singleResult.transcriptError;
2019
2647
  statusPayload.steps[fi].structuredOutput = singleResult.structuredOutput;
2020
2648
  statusPayload.steps[fi].structuredOutputPath = singleResult.structuredOutputPath;
2021
2649
  statusPayload.steps[fi].structuredOutputSchemaPath = singleResult.structuredOutputSchemaPath;
@@ -2024,9 +2652,9 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2024
2652
  writeStatusPayload();
2025
2653
 
2026
2654
  appendJsonl(eventsPath, JSON.stringify({
2027
- type: singleResult.exitCode === 0 ? "subagent.step.completed" : "subagent.step.failed",
2655
+ type: timedOut ? "subagent.step.failed" : childInterrupted ? "subagent.step.paused" : singleResult.exitCode === 0 ? "subagent.step.completed" : "subagent.step.failed",
2028
2656
  ts: taskEndTime, runId: id, stepIndex: fi, agent: task.agent,
2029
- exitCode: singleResult.exitCode, durationMs: taskDuration,
2657
+ exitCode: timedOut ? 1 : childInterrupted ? 0 : singleResult.exitCode, durationMs: taskDuration,
2030
2658
  }));
2031
2659
  if (singleResult.completionGuardTriggered) {
2032
2660
  const event = buildControlEvent({
@@ -2043,8 +2671,9 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2043
2671
  }
2044
2672
 
2045
2673
  if (singleResult.exitCode !== 0 && failFast) aborted = true;
2046
- return { ...singleResult, skipped: false };
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 };
2047
2675
  },
2676
+ globalSemaphore,
2048
2677
  );
2049
2678
 
2050
2679
  flatIndex += group.parallel.length;
@@ -2072,15 +2701,25 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2072
2701
  agent: pr.agent,
2073
2702
  output: pr.output,
2074
2703
  error: pr.error,
2075
- success: pr.exitCode === 0,
2076
- exitCode: pr.exitCode,
2704
+ success: pr.interrupted !== true && pr.exitCode === 0,
2705
+ exitCode: pr.interrupted === true ? 0 : pr.exitCode,
2077
2706
  skipped: pr.skipped,
2707
+ interrupted: pr.interrupted,
2708
+ timedOut: pr.timedOut,
2709
+ turnBudget: pr.turnBudget,
2710
+ turnBudgetExceeded: pr.turnBudgetExceeded,
2711
+ wrapUpRequested: pr.wrapUpRequested,
2712
+ toolBudget: pr.toolBudget,
2713
+ toolBudgetBlocked: pr.toolBudgetBlocked,
2078
2714
  sessionFile: pr.sessionFile,
2079
2715
  intercomTarget: pr.intercomTarget,
2080
2716
  model: pr.model,
2081
2717
  attemptedModels: pr.attemptedModels,
2082
2718
  modelAttempts: pr.modelAttempts,
2719
+ totalCost: pr.totalCost,
2083
2720
  artifactPaths: pr.artifactPaths,
2721
+ transcriptPath: pr.transcriptPath,
2722
+ transcriptError: pr.transcriptError,
2084
2723
  structuredOutput: pr.structuredOutput,
2085
2724
  structuredOutputPath: pr.structuredOutputPath,
2086
2725
  structuredOutputSchemaPath: pr.structuredOutputSchemaPath,
@@ -2147,6 +2786,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2147
2786
  agent: seqStep.agent,
2148
2787
  }));
2149
2788
 
2789
+ flushPendingStepSteers(flatIndex);
2150
2790
  const singleResult = await runSingleStep(seqStep, {
2151
2791
  previousOutput, placeholder, cwd, sessionEnabled,
2152
2792
  outputs,
@@ -2154,16 +2794,21 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2154
2794
  artifactsDir, artifactConfig, id,
2155
2795
  flatIndex, flatStepCount: Math.max(statusPayload.steps.length, 1),
2156
2796
  outputFile: path.join(asyncDir, `output-${flatIndex}.log`),
2797
+ steerInboxDir: stepSteerInboxDir(asyncDir, flatIndex),
2157
2798
  piPackageRoot: config.piPackageRoot,
2158
2799
  piArgv1: config.piArgv1,
2159
2800
  childIntercomTarget: config.childIntercomTargets?.[flatIndex],
2160
2801
  orchestratorIntercomTarget: config.controlIntercomTarget,
2161
2802
  nestedRoute: config.nestedRoute,
2162
- registerInterrupt: (interrupt) => {
2163
- activeChildInterrupt = interrupt;
2164
- },
2803
+ registerInterrupt: (interrupt) => registerStepInterrupt(flatIndex, interrupt),
2804
+ registerTimeout: (interrupt) => registerStepTimeout(flatIndex, interrupt),
2805
+ registerTurnBudgetAbort: (abort) => registerStepTurnBudgetAbort(flatIndex, abort),
2806
+ timeoutSignal: timeoutAbortController.signal,
2807
+ timeoutMessage,
2808
+ turnBudget: config.turnBudget,
2165
2809
  onAttemptStart: (attempt) => updateStepModel(flatIndex, attempt.model, attempt.thinking),
2166
2810
  onChildEvent: (event) => updateStepFromChildEvent(flatIndex, event),
2811
+ skipAcceptance: () => timedOut,
2167
2812
  });
2168
2813
  if (seqStep.sessionFile) {
2169
2814
  latestSessionFile = seqStep.sessionFile;
@@ -2172,20 +2817,30 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2172
2817
  previousOutput = singleResult.output;
2173
2818
  results.push({
2174
2819
  agent: singleResult.agent,
2175
- output: singleResult.output,
2176
- error: singleResult.error,
2177
- success: singleResult.exitCode === 0,
2178
- exitCode: singleResult.exitCode,
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,
2179
2824
  sessionFile: singleResult.sessionFile,
2180
2825
  intercomTarget: singleResult.intercomTarget,
2181
2826
  model: singleResult.model,
2182
2827
  attemptedModels: singleResult.attemptedModels,
2183
2828
  modelAttempts: singleResult.modelAttempts,
2829
+ totalCost: singleResult.totalCost,
2184
2830
  artifactPaths: singleResult.artifactPaths,
2831
+ transcriptPath: singleResult.transcriptPath,
2832
+ transcriptError: singleResult.transcriptError,
2185
2833
  structuredOutput: singleResult.structuredOutput,
2186
2834
  structuredOutputPath: singleResult.structuredOutputPath,
2187
2835
  structuredOutputSchemaPath: singleResult.structuredOutputSchemaPath,
2188
2836
  acceptance: singleResult.acceptance,
2837
+ interrupted: singleResult.interrupted,
2838
+ timedOut: timedOut || singleResult.timedOut ? true : undefined,
2839
+ turnBudget: singleResult.turnBudget,
2840
+ turnBudgetExceeded: singleResult.turnBudgetExceeded,
2841
+ wrapUpRequested: singleResult.wrapUpRequested,
2842
+ toolBudget: singleResult.toolBudget,
2843
+ toolBudgetBlocked: singleResult.toolBudgetBlocked,
2189
2844
  });
2190
2845
  if (seqStep.outputName) {
2191
2846
  outputs[seqStep.outputName] = outputEntryFromAsyncResult({
@@ -2218,15 +2873,30 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2218
2873
  }
2219
2874
 
2220
2875
  const stepEndTime = Date.now();
2221
- statusPayload.steps[flatIndex].status = singleResult.exitCode === 0 ? "complete" : "failed";
2876
+ const childInterrupted = singleResult.interrupted === true;
2877
+ statusPayload.steps[flatIndex].status = timedOut ? "failed" : childInterrupted ? "paused" : singleResult.exitCode === 0 ? "complete" : "failed";
2222
2878
  statusPayload.steps[flatIndex].endedAt = stepEndTime;
2223
2879
  statusPayload.steps[flatIndex].durationMs = stepEndTime - stepStartTime;
2224
- statusPayload.steps[flatIndex].exitCode = singleResult.exitCode;
2880
+ statusPayload.steps[flatIndex].exitCode = timedOut ? 1 : childInterrupted ? 0 : singleResult.exitCode;
2881
+ statusPayload.steps[flatIndex].timedOut = timedOut || singleResult.timedOut ? true : undefined;
2882
+ statusPayload.steps[flatIndex].turnBudget = singleResult.turnBudget;
2883
+ statusPayload.steps[flatIndex].turnBudgetExceeded = singleResult.turnBudgetExceeded;
2884
+ statusPayload.steps[flatIndex].wrapUpRequested = singleResult.wrapUpRequested;
2885
+ statusPayload.steps[flatIndex].toolBudget = singleResult.toolBudget;
2886
+ statusPayload.steps[flatIndex].toolBudgetBlocked = singleResult.toolBudgetBlocked;
2887
+ if (singleResult.toolBudget) statusPayload.toolBudget = singleResult.toolBudget;
2888
+ if (singleResult.toolBudgetBlocked) statusPayload.toolBudgetBlocked = true;
2889
+ if (singleResult.turnBudget) statusPayload.turnBudget = singleResult.turnBudget;
2890
+ if (singleResult.turnBudgetExceeded) statusPayload.turnBudgetExceeded = true;
2891
+ if (singleResult.wrapUpRequested) statusPayload.wrapUpRequested = true;
2225
2892
  statusPayload.steps[flatIndex].model = singleResult.model;
2226
2893
  statusPayload.steps[flatIndex].thinking = resolveEffectiveThinking(singleResult.model, statusPayload.steps[flatIndex].thinking);
2227
2894
  statusPayload.steps[flatIndex].attemptedModels = singleResult.attemptedModels;
2228
2895
  statusPayload.steps[flatIndex].modelAttempts = singleResult.modelAttempts;
2229
- statusPayload.steps[flatIndex].error = singleResult.error;
2896
+ statusPayload.steps[flatIndex].totalCost = singleResult.totalCost;
2897
+ statusPayload.steps[flatIndex].error = timedOut ? (timeoutMessage ?? "Subagent timed out.") : singleResult.error;
2898
+ statusPayload.steps[flatIndex].transcriptPath = singleResult.transcriptPath ?? statusPayload.steps[flatIndex].transcriptPath;
2899
+ statusPayload.steps[flatIndex].transcriptError = singleResult.transcriptError;
2230
2900
  statusPayload.steps[flatIndex].structuredOutput = singleResult.structuredOutput;
2231
2901
  statusPayload.steps[flatIndex].structuredOutputPath = singleResult.structuredOutputPath;
2232
2902
  statusPayload.steps[flatIndex].structuredOutputSchemaPath = singleResult.structuredOutputSchemaPath;
@@ -2239,12 +2909,12 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2239
2909
  writeStatusPayload();
2240
2910
 
2241
2911
  appendJsonl(eventsPath, JSON.stringify({
2242
- type: singleResult.exitCode === 0 ? "subagent.step.completed" : "subagent.step.failed",
2912
+ type: timedOut ? "subagent.step.failed" : childInterrupted ? "subagent.step.paused" : singleResult.exitCode === 0 ? "subagent.step.completed" : "subagent.step.failed",
2243
2913
  ts: stepEndTime,
2244
2914
  runId: id,
2245
2915
  stepIndex: flatIndex,
2246
2916
  agent: seqStep.agent,
2247
- exitCode: singleResult.exitCode,
2917
+ exitCode: timedOut ? 1 : childInterrupted ? 0 : singleResult.exitCode,
2248
2918
  durationMs: stepEndTime - stepStartTime,
2249
2919
  tokens: stepTokens,
2250
2920
  }));
@@ -2283,6 +2953,12 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2283
2953
  }
2284
2954
 
2285
2955
  const resultMode = config.resultMode ?? statusPayload.mode;
2956
+ const totalCost = results.reduce<CostSummary>((sum, result) => ({
2957
+ inputTokens: sum.inputTokens + (result.totalCost?.inputTokens ?? 0),
2958
+ outputTokens: sum.outputTokens + (result.totalCost?.outputTokens ?? 0),
2959
+ costUsd: sum.costUsd + (result.totalCost?.costUsd ?? 0),
2960
+ }), { inputTokens: 0, outputTokens: 0, costUsd: 0 });
2961
+ const finalTotalCost = totalCost.inputTokens > 0 || totalCost.outputTokens > 0 || totalCost.costUsd > 0 ? totalCost : undefined;
2286
2962
  const finalFlatAgents = statusPayload.steps.map((step) => step.agent);
2287
2963
  const agentName = finalFlatAgents.length === 1
2288
2964
  ? finalFlatAgents[0]!
@@ -2323,14 +2999,27 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2323
2999
  clearInterval(activityTimer);
2324
3000
  activityTimer = undefined;
2325
3001
  }
3002
+ if (timeoutTimer) {
3003
+ clearTimeout(timeoutTimer);
3004
+ timeoutTimer = undefined;
3005
+ }
2326
3006
  disposeControlInbox();
2327
3007
  const effectiveSessionFile = sessionFile ?? latestSessionFile;
2328
3008
  const runEndedAt = Date.now();
2329
- statusPayload.state = interrupted ? "paused" : results.every((r) => r.success) ? "complete" : "failed";
3009
+ statusPayload.state = timedOut || turnBudgetExceeded ? "failed" : interrupted ? "paused" : results.every((r) => r.success) ? "complete" : "failed";
2330
3010
  statusPayload.activityState = undefined;
3011
+ if (timedOut) {
3012
+ statusPayload.timedOut = true;
3013
+ statusPayload.error = timeoutMessage ?? "Subagent timed out.";
3014
+ }
3015
+ if (turnBudgetExceeded && !statusPayload.error) {
3016
+ const budget = statusPayload.turnBudget;
3017
+ statusPayload.error = budget ? turnBudgetExceededMessage(budget, budget.turnCount) : "Subagent exceeded turn budget.";
3018
+ }
2331
3019
  statusPayload.endedAt = runEndedAt;
2332
3020
  statusPayload.lastUpdate = runEndedAt;
2333
3021
  statusPayload.sessionFile = effectiveSessionFile;
3022
+ statusPayload.totalCost = finalTotalCost;
2334
3023
  statusPayload.shareUrl = shareUrl;
2335
3024
  statusPayload.gistUrl = gistUrl;
2336
3025
  statusPayload.shareError = shareError;
@@ -2345,10 +3034,13 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2345
3034
  eventsPath,
2346
3035
  JSON.stringify({
2347
3036
  type: "subagent.run.completed",
3037
+ lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
2348
3038
  ts: runEndedAt,
2349
3039
  runId: id,
2350
3040
  status: statusPayload.state,
2351
3041
  durationMs: runEndedAt - overallStartTime,
3042
+ totalTokens: statusPayload.totalTokens,
3043
+ totalCost: finalTotalCost,
2352
3044
  }),
2353
3045
  );
2354
3046
  writeRunLog(logPath, {
@@ -2372,25 +3064,44 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2372
3064
 
2373
3065
  try {
2374
3066
  writeAtomicJson(resultPath, {
3067
+ lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
2375
3068
  id,
2376
3069
  agent: agentName,
2377
3070
  mode: resultMode,
2378
- success: !interrupted && results.every((r) => r.success),
2379
- state: interrupted ? "paused" : results.every((r) => r.success) ? "complete" : "failed",
2380
- summary: interrupted ? "Paused after interrupt. Waiting for explicit next action." : summary,
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,
3074
+ ...(config.timeoutMs !== undefined ? { timeoutMs: config.timeoutMs } : {}),
3075
+ ...(config.deadlineAt !== undefined ? { deadlineAt: config.deadlineAt } : {}),
3076
+ ...(statusPayload.turnBudget ? { turnBudget: statusPayload.turnBudget } : {}),
3077
+ ...(statusPayload.turnBudgetExceeded ? { turnBudgetExceeded: true } : {}),
3078
+ ...(statusPayload.wrapUpRequested ? { wrapUpRequested: true } : {}),
3079
+ ...(statusPayload.toolBudget ? { toolBudget: statusPayload.toolBudget } : {}),
3080
+ ...(statusPayload.toolBudgetBlocked ? { toolBudgetBlocked: true } : {}),
3081
+ ...(timedOut ? { timedOut: true, error: timeoutMessage ?? "Subagent timed out." } : turnBudgetExceeded ? { error: statusPayload.error ?? "Subagent exceeded turn budget." } : {}),
2381
3082
  results: results.map((r) => ({
2382
3083
  agent: r.agent,
2383
3084
  output: r.output,
2384
3085
  error: r.error,
2385
3086
  success: r.success,
2386
3087
  skipped: r.skipped || undefined,
3088
+ interrupted: r.interrupted || undefined,
3089
+ timedOut: r.timedOut || undefined,
3090
+ turnBudget: r.turnBudget,
3091
+ turnBudgetExceeded: r.turnBudgetExceeded || undefined,
3092
+ wrapUpRequested: r.wrapUpRequested || undefined,
3093
+ toolBudget: r.toolBudget,
3094
+ toolBudgetBlocked: r.toolBudgetBlocked || undefined,
2387
3095
  sessionFile: r.sessionFile,
2388
3096
  intercomTarget: r.intercomTarget,
2389
3097
  model: r.model,
2390
3098
  attemptedModels: r.attemptedModels,
2391
3099
  modelAttempts: r.modelAttempts,
3100
+ totalCost: r.totalCost,
2392
3101
  artifactPaths: r.artifactPaths,
2393
3102
  truncated: r.truncated,
3103
+ transcriptPath: r.transcriptPath,
3104
+ transcriptError: r.transcriptError,
2394
3105
  structuredOutput: r.structuredOutput,
2395
3106
  structuredOutputPath: r.structuredOutputPath,
2396
3107
  structuredOutputSchemaPath: r.structuredOutputSchemaPath,
@@ -2398,9 +3109,11 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2398
3109
  })),
2399
3110
  outputs,
2400
3111
  workflowGraph: statusPayload.workflowGraph,
2401
- exitCode: interrupted || results.every((r) => r.success) ? 0 : 1,
3112
+ exitCode: timedOut || turnBudgetExceeded ? 1 : interrupted || results.every((r) => r.success) ? 0 : 1,
2402
3113
  timestamp: runEndedAt,
2403
3114
  durationMs: runEndedAt - overallStartTime,
3115
+ totalTokens: statusPayload.totalTokens,
3116
+ totalCost: finalTotalCost,
2404
3117
  truncated,
2405
3118
  artifactsDir,
2406
3119
  cwd,