pi-subagents 0.24.4 → 0.27.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 (48) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +145 -27
  3. package/package.json +1 -1
  4. package/prompts/parallel-context-build.md +3 -1
  5. package/prompts/parallel-handoff-plan.md +3 -1
  6. package/prompts/review-loop.md +1 -1
  7. package/skills/pi-subagents/SKILL.md +71 -20
  8. package/src/agents/agent-management.ts +57 -15
  9. package/src/agents/agent-serializer.ts +3 -2
  10. package/src/agents/agents.ts +47 -16
  11. package/src/agents/chain-serializer.ts +120 -0
  12. package/src/extension/fanout-child.ts +171 -0
  13. package/src/extension/index.ts +7 -2
  14. package/src/extension/schemas.ts +138 -5
  15. package/src/intercom/result-intercom.ts +108 -0
  16. package/src/runs/background/async-execution.ts +185 -10
  17. package/src/runs/background/async-job-tracker.ts +41 -6
  18. package/src/runs/background/async-resume.ts +28 -15
  19. package/src/runs/background/async-status.ts +71 -31
  20. package/src/runs/background/result-watcher.ts +111 -54
  21. package/src/runs/background/run-id-resolver.ts +83 -0
  22. package/src/runs/background/run-status.ts +89 -4
  23. package/src/runs/background/stale-run-reconciler.ts +46 -1
  24. package/src/runs/background/subagent-runner.ts +648 -42
  25. package/src/runs/foreground/chain-execution.ts +331 -118
  26. package/src/runs/foreground/execution.ts +226 -10
  27. package/src/runs/foreground/subagent-executor.ts +377 -14
  28. package/src/runs/shared/acceptance-contract.ts +291 -0
  29. package/src/runs/shared/acceptance-evaluation.ts +221 -0
  30. package/src/runs/shared/acceptance-finalization.ts +161 -0
  31. package/src/runs/shared/acceptance-reports.ts +127 -0
  32. package/src/runs/shared/acceptance.ts +22 -0
  33. package/src/runs/shared/chain-outputs.ts +101 -0
  34. package/src/runs/shared/completion-guard.ts +26 -3
  35. package/src/runs/shared/dynamic-fanout.ts +293 -0
  36. package/src/runs/shared/nested-events.ts +819 -0
  37. package/src/runs/shared/nested-path.ts +52 -0
  38. package/src/runs/shared/nested-render.ts +115 -0
  39. package/src/runs/shared/parallel-utils.ts +31 -1
  40. package/src/runs/shared/pi-args.ts +73 -5
  41. package/src/runs/shared/structured-output.ts +77 -0
  42. package/src/runs/shared/subagent-prompt-runtime.ts +77 -7
  43. package/src/runs/shared/workflow-graph.ts +206 -0
  44. package/src/shared/formatters.ts +2 -2
  45. package/src/shared/settings.ts +53 -4
  46. package/src/shared/types.ts +345 -0
  47. package/src/slash/slash-commands.ts +41 -3
  48. package/src/tui/render.ts +268 -43
@@ -8,15 +8,21 @@ import { appendJsonl, getArtifactPaths } from "../../shared/artifacts.ts";
8
8
  import { PI_CODING_AGENT_PACKAGE, getPiSpawnCommand, resolveInstalledPiPackageRoot } from "../shared/pi-spawn.ts";
9
9
  import { captureSingleOutputSnapshot, finalizeSingleOutput, formatSavedOutputReference, resolveSingleOutput, type SingleOutputSnapshot } from "../shared/single-output.ts";
10
10
  import {
11
+ type AcceptanceFinalizationTurn,
12
+ type AcceptanceLedger,
11
13
  type ActivityState,
12
14
  type ArtifactConfig,
13
15
  type ArtifactPaths,
14
16
  type AsyncParallelGroupStatus,
15
17
  type AsyncStatus,
18
+ type ChainOutputMap,
16
19
  type ModelAttempt,
20
+ type NestedRouteInfo,
17
21
  type ResolvedControlConfig,
18
22
  type SubagentRunMode,
23
+ type TokenUsage,
19
24
  type Usage,
25
+ type WorkflowGraphSnapshot,
20
26
  DEFAULT_MAX_OUTPUT,
21
27
  type MaxOutputConfig,
22
28
  truncateOutput,
@@ -33,6 +39,7 @@ import {
33
39
  import {
34
40
  type RunnerSubagentStep as SubagentStep,
35
41
  type RunnerStep,
42
+ isDynamicRunnerGroup,
36
43
  isParallelGroup,
37
44
  flattenSteps,
38
45
  mapConcurrent,
@@ -40,10 +47,14 @@ import {
40
47
  MAX_PARALLEL_CONCURRENCY,
41
48
  } from "../shared/parallel-utils.ts";
42
49
  import { buildPiArgs, cleanupTempDir } from "../shared/pi-args.ts";
50
+ import { outputEntryFromAsyncResult, resolveOutputReferences } from "../shared/chain-outputs.ts";
51
+ import { createStructuredOutputRuntime, readStructuredOutput } from "../shared/structured-output.ts";
52
+ import { collectDynamicResults, DynamicFanoutError, materializeDynamicParallelStep, validateDynamicCollection } from "../shared/dynamic-fanout.ts";
53
+ import { nestedSummaryFromAsyncStatus, writeNestedEvent } from "../shared/nested-events.ts";
43
54
  import { formatModelAttemptNote, isRetryableModelFailure } from "../shared/model-fallback.ts";
44
55
  import { attachPostExitStdioGuard, trySignalChild } from "../../shared/post-exit-stdio-guard.ts";
45
56
  import { detectSubagentError, extractTextFromContent, extractToolArgsPreview, getFinalOutput } from "../../shared/utils.ts";
46
- import { evaluateCompletionMutationGuard } from "../shared/completion-guard.ts";
57
+ import { evaluateCompletionMutationGuard, resolveCompletionPolicy } from "../shared/completion-guard.ts";
47
58
  import {
48
59
  createMutatingFailureState,
49
60
  didMutatingToolFail,
@@ -56,7 +67,6 @@ import {
56
67
  summarizeRecentMutatingFailures,
57
68
  } from "../shared/long-running-guard.ts";
58
69
  import { parseSessionTokens } from "../../shared/session-tokens.ts";
59
- import type { TokenUsage } from "../../shared/types.ts";
60
70
  import {
61
71
  cleanupWorktrees,
62
72
  createWorktrees,
@@ -68,6 +78,20 @@ import {
68
78
  } from "../shared/worktree.ts";
69
79
  import { resolveEffectiveThinking } from "../../shared/model-info.ts";
70
80
  import { writeInitialProgressFile } from "../../shared/settings.ts";
81
+ import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts";
82
+ import {
83
+ acceptanceFailureMessage,
84
+ acceptanceSelfReviewConfig,
85
+ attachFinalizationToLedger,
86
+ buildFinalizationProcessFailureLedger,
87
+ createFinalizationProcessFailureTurn,
88
+ createFinalizationTurn,
89
+ evaluateAcceptance,
90
+ formatAcceptanceFinalizationPrompt,
91
+ formatAcceptancePrompt,
92
+ shouldRunAcceptanceFinalization,
93
+ stripAcceptanceReport,
94
+ } from "../shared/acceptance.ts";
71
95
 
72
96
  interface SubagentRunConfig {
73
97
  id: string;
@@ -92,6 +116,10 @@ interface SubagentRunConfig {
92
116
  controlIntercomTarget?: string;
93
117
  childIntercomTargets?: Array<string | undefined>;
94
118
  resultMode?: SubagentRunMode;
119
+ dynamicFanoutMaxItems?: number;
120
+ workflowGraph?: WorkflowGraphSnapshot;
121
+ nestedRoute?: NestedRouteInfo;
122
+ nestedSelf?: { parentRunId: string; parentStepIndex?: number; depth: number; path?: Array<{ runId: string; stepIndex?: number; agent?: string }> };
95
123
  }
96
124
 
97
125
  interface StepResult {
@@ -99,6 +127,7 @@ interface StepResult {
99
127
  output: string;
100
128
  error?: string;
101
129
  success: boolean;
130
+ exitCode?: number | null;
102
131
  skipped?: boolean;
103
132
  sessionFile?: string;
104
133
  intercomTarget?: string;
@@ -107,6 +136,10 @@ interface StepResult {
107
136
  modelAttempts?: ModelAttempt[];
108
137
  artifactPaths?: ArtifactPaths;
109
138
  truncated?: boolean;
139
+ structuredOutput?: unknown;
140
+ structuredOutputPath?: string;
141
+ structuredOutputSchemaPath?: string;
142
+ acceptance?: AcceptanceLedger;
110
143
  }
111
144
 
112
145
  const ASYNC_INTERRUPT_SIGNAL: NodeJS.Signals = process.platform === "win32" ? "SIGBREAK" : "SIGUSR2";
@@ -539,6 +572,7 @@ function writeRunLog(
539
572
  /** Context for running a single step */
540
573
  interface SingleStepContext {
541
574
  previousOutput: string;
575
+ outputs?: ChainOutputMap;
542
576
  placeholder: string;
543
577
  cwd: string;
544
578
  sessionEnabled: boolean;
@@ -554,6 +588,7 @@ interface SingleStepContext {
554
588
  registerInterrupt?: (interrupt: (() => void) | undefined) => void;
555
589
  childIntercomTarget?: string;
556
590
  orchestratorIntercomTarget?: string;
591
+ nestedRoute?: NestedRouteInfo;
557
592
  onAttemptStart?: (attempt: { model?: string; thinking?: string }) => void;
558
593
  onChildEvent?: (event: ChildEvent) => void;
559
594
  }
@@ -575,9 +610,22 @@ async function runSingleStep(
575
610
  sessionFile?: string;
576
611
  intercomTarget?: string;
577
612
  completionGuardTriggered?: boolean;
613
+ structuredOutput?: unknown;
614
+ structuredOutputPath?: string;
615
+ structuredOutputSchemaPath?: string;
616
+ acceptance?: AcceptanceLedger;
578
617
  }> {
618
+ const effectiveStructuredOutput = step.structuredOutput ?? (step.structuredOutputSchema
619
+ ? createStructuredOutputRuntime(step.structuredOutputSchema, path.join(path.dirname(ctx.outputFile), "structured-output"))
620
+ : undefined);
579
621
  const placeholderRegex = new RegExp(ctx.placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g");
580
- const task = step.task.replace(placeholderRegex, () => ctx.previousOutput);
622
+ let task = step.task.replace(placeholderRegex, () => ctx.previousOutput);
623
+ task = resolveOutputReferences(task, ctx.outputs ?? {});
624
+ const taskForCompletionGuard = task;
625
+ if (step.effectiveAcceptance) {
626
+ const acceptancePrompt = formatAcceptancePrompt(step.effectiveAcceptance);
627
+ if (acceptancePrompt) task = `${task}\n${acceptancePrompt}`;
628
+ }
581
629
  const sessionEnabled = Boolean(step.sessionFile) || ctx.sessionEnabled;
582
630
  const sessionDir = step.sessionFile ? undefined : ctx.sessionDir;
583
631
 
@@ -608,6 +656,13 @@ async function runSingleStep(
608
656
  const candidate = candidates[index];
609
657
  ctx.onAttemptStart?.({ model: candidate, thinking: resolveEffectiveThinking(candidate, step.thinking) });
610
658
  const outputSnapshot = captureSingleOutputSnapshot(step.outputPath);
659
+ if (effectiveStructuredOutput) {
660
+ try {
661
+ if (fs.existsSync(effectiveStructuredOutput.outputPath)) fs.unlinkSync(effectiveStructuredOutput.outputPath);
662
+ } catch {
663
+ // Missing/stale structured-output files are handled after the child exits.
664
+ }
665
+ }
611
666
  const { args, env, tempDir } = buildPiArgs({
612
667
  baseArgs: ["--mode", "json", "-p"],
613
668
  task,
@@ -629,6 +684,11 @@ async function runSingleStep(
629
684
  runId: ctx.id,
630
685
  childAgentName: step.agent,
631
686
  childIndex: ctx.flatIndex,
687
+ parentEventSink: ctx.nestedRoute?.eventSink,
688
+ parentControlInbox: ctx.nestedRoute?.controlInbox,
689
+ parentRootRunId: ctx.nestedRoute?.rootRunId,
690
+ parentCapabilityToken: ctx.nestedRoute?.capabilityToken,
691
+ structuredOutput: effectiveStructuredOutput,
632
692
  });
633
693
  const run = await runPiStreaming(
634
694
  args,
@@ -645,10 +705,29 @@ async function runSingleStep(
645
705
  cleanupTempDir(tempDir);
646
706
 
647
707
  const hiddenError = run.exitCode === 0 && !run.error ? detectSubagentError(run.messages) : null;
648
- const completionGuard = run.exitCode === 0 && !run.error && !hiddenError?.hasError && step.completionGuard !== false
708
+ let structuredOutput: unknown;
709
+ let structuredError: string | undefined;
710
+ if (effectiveStructuredOutput && run.exitCode === 0 && !run.error && !hiddenError?.hasError) {
711
+ const structured = readStructuredOutput({
712
+ schema: effectiveStructuredOutput.schema,
713
+ schemaPath: effectiveStructuredOutput.schemaPath,
714
+ outputPath: effectiveStructuredOutput.outputPath,
715
+ });
716
+ if (structured.error) structuredError = structured.error;
717
+ else structuredOutput = structured.value;
718
+ }
719
+ const completionPolicy = resolveCompletionPolicy({
720
+ agent: step.agent,
721
+ task: taskForCompletionGuard,
722
+ completionGuardEnabled: step.completionGuard !== false,
723
+ usesAcceptanceContract: step.effectiveAcceptance?.explicit === true,
724
+ tools: step.tools,
725
+ mcpDirectTools: step.mcpDirectTools,
726
+ });
727
+ const completionGuard = run.exitCode === 0 && !run.error && !hiddenError?.hasError && completionPolicy === "mutation-guard"
649
728
  ? evaluateCompletionMutationGuard({
650
729
  agent: step.agent,
651
- task,
730
+ task: taskForCompletionGuard,
652
731
  messages: run.messages,
653
732
  tools: step.tools,
654
733
  mcpDirectTools: step.mcpDirectTools,
@@ -658,14 +737,17 @@ async function runSingleStep(
658
737
  const completionGuardError = completionGuardTriggered
659
738
  ? "Subagent completed without making edits for an implementation task.\nIt appears to have returned planning or scratchpad output instead of applying changes."
660
739
  : undefined;
661
- const effectiveExitCode = completionGuardTriggered
740
+ const effectiveExitCode = completionGuardError
662
741
  ? 1
663
- : hiddenError?.hasError
742
+ : structuredError
743
+ ? 1
744
+ : hiddenError?.hasError
664
745
  ? (hiddenError.exitCode ?? 1)
665
746
  : run.error && run.exitCode === 0
666
747
  ? 1
667
748
  : run.exitCode;
668
749
  const error = completionGuardError
750
+ ?? structuredError
669
751
  ?? (hiddenError?.hasError
670
752
  ? hiddenError.details
671
753
  ? `${hiddenError.errorType} failed (exit ${effectiveExitCode}): ${hiddenError.details}`
@@ -682,22 +764,24 @@ async function runSingleStep(
682
764
  if (candidate) attemptedModels.push(candidate);
683
765
  completionGuardTriggeredFinal = completionGuardTriggered;
684
766
  finalOutputSnapshot = outputSnapshot;
685
- finalResult = { ...run, exitCode: effectiveExitCode, model: candidate ?? run.model, error };
686
- if (attempt.success || completionGuardTriggered) break;
767
+ finalResult = { ...run, exitCode: effectiveExitCode, model: candidate ?? run.model, error, structuredOutput } as RunPiStreamingResult & { structuredOutput?: unknown };
768
+ if (attempt.success || completionGuardError) break;
687
769
  if (!isRetryableModelFailure(error) || index === candidates.length - 1) break;
688
770
  attemptNotes.push(formatModelAttemptNote(attempt, candidates[index + 1]));
689
771
  }
690
772
 
691
773
  const rawOutput = finalResult?.finalOutput ?? "";
774
+ const outputForPersistence = stripAcceptanceReport(rawOutput);
692
775
  const resolvedOutput = step.outputPath && finalResult?.exitCode === 0
693
- ? resolveSingleOutput(step.outputPath, rawOutput, finalOutputSnapshot)
694
- : { fullOutput: rawOutput };
776
+ ? resolveSingleOutput(step.outputPath, outputForPersistence, finalOutputSnapshot)
777
+ : { fullOutput: outputForPersistence };
695
778
  const output = resolvedOutput.fullOutput;
696
779
  const outputReference = resolvedOutput.savedPath ? formatSavedOutputReference(resolvedOutput.savedPath, output) : undefined;
697
780
  let outputForSummary = output;
698
781
  if (attemptNotes.length > 0) {
699
782
  outputForSummary = `${attemptNotes.join("\n")}\n\n${outputForSummary}`.trim();
700
783
  }
784
+ const outputForAcceptance = rawOutput;
701
785
  const finalizedOutput = finalizeSingleOutput({
702
786
  fullOutput: outputForSummary,
703
787
  outputPath: step.outputPath,
@@ -708,6 +792,121 @@ async function runSingleStep(
708
792
  saveError: resolvedOutput.saveError,
709
793
  });
710
794
  outputForSummary = finalizedOutput.displayOutput;
795
+ const acceptanceForInitialReport = step.effectiveAcceptance && shouldRunAcceptanceFinalization(step.effectiveAcceptance)
796
+ ? acceptanceSelfReviewConfig(step.effectiveAcceptance)
797
+ : step.effectiveAcceptance;
798
+ let acceptance = acceptanceForInitialReport
799
+ ? await evaluateAcceptance({
800
+ acceptance: acceptanceForInitialReport,
801
+ output: outputForAcceptance,
802
+ cwd: step.cwd ?? ctx.cwd,
803
+ })
804
+ : undefined;
805
+ if (acceptance && step.effectiveAcceptance && shouldRunAcceptanceFinalization(step.effectiveAcceptance) && (finalResult?.exitCode ?? 1) === 0 && !finalResult?.interrupted) {
806
+ const sessionFile = step.sessionFile ?? (sessionDir ? findLatestSessionFile(sessionDir) ?? undefined : undefined);
807
+ const maxTurns = step.effectiveAcceptance.finalization.maxTurns;
808
+ const turns: AcceptanceFinalizationTurn[] = [];
809
+ if (!sessionFile) {
810
+ const message = "Acceptance finalization requires a session file for same-session continuation.";
811
+ turns.push(createFinalizationProcessFailureTurn({ turn: 1, prompt: "", message }));
812
+ acceptance = buildFinalizationProcessFailureLedger({ initialLedger: acceptance, turns, maxTurns, message });
813
+ } else {
814
+ const selfReviewAcceptance = acceptanceSelfReviewConfig(step.effectiveAcceptance);
815
+ let previousFailure = acceptanceFailureMessage(acceptance);
816
+ let authoritativeLedger = acceptance;
817
+ for (let turn = 1; turn <= maxTurns; turn++) {
818
+ const prompt = formatAcceptanceFinalizationPrompt({
819
+ acceptance: step.effectiveAcceptance,
820
+ initialOutput: outputForAcceptance,
821
+ initialLedger: acceptance,
822
+ turn,
823
+ maxTurns,
824
+ ...(previousFailure ? { previousFailure } : {}),
825
+ });
826
+ const { args, env, tempDir } = buildPiArgs({
827
+ baseArgs: ["--mode", "json", "-p"],
828
+ task: prompt,
829
+ sessionEnabled: true,
830
+ sessionFile,
831
+ model: finalResult?.model ?? step.model,
832
+ thinking: step.thinking,
833
+ inheritProjectContext: step.inheritProjectContext,
834
+ inheritSkills: step.inheritSkills,
835
+ tools: step.tools,
836
+ extensions: step.extensions,
837
+ systemPrompt: step.systemPrompt,
838
+ systemPromptMode: step.systemPromptMode,
839
+ mcpDirectTools: step.mcpDirectTools,
840
+ cwd: step.cwd ?? ctx.cwd,
841
+ promptFileStem: `${step.agent}-acceptance-finalization`,
842
+ intercomSessionName: ctx.childIntercomTarget,
843
+ orchestratorIntercomTarget: ctx.orchestratorIntercomTarget,
844
+ runId: ctx.id,
845
+ childAgentName: step.agent,
846
+ childIndex: ctx.flatIndex,
847
+ parentEventSink: ctx.nestedRoute?.eventSink,
848
+ parentControlInbox: ctx.nestedRoute?.controlInbox,
849
+ parentRootRunId: ctx.nestedRoute?.rootRunId,
850
+ parentCapabilityToken: ctx.nestedRoute?.capabilityToken,
851
+ });
852
+ ctx.onAttemptStart?.({ model: finalResult?.model ?? step.model, thinking: resolveEffectiveThinking(finalResult?.model ?? step.model, step.thinking) });
853
+ const finalizationRun = await runPiStreaming(
854
+ args,
855
+ step.cwd ?? ctx.cwd,
856
+ `${ctx.outputFile}.finalization-${turn}.log`,
857
+ env,
858
+ ctx.piPackageRoot,
859
+ ctx.piArgv1,
860
+ step.maxSubagentDepth,
861
+ { eventsPath, runId: ctx.id, stepIndex: ctx.flatIndex, agent: step.agent },
862
+ ctx.registerInterrupt,
863
+ ctx.onChildEvent,
864
+ );
865
+ cleanupTempDir(tempDir);
866
+ modelAttempts.push({
867
+ model: finalResult?.model ?? finalizationRun.model ?? step.model ?? "default",
868
+ success: finalizationRun.exitCode === 0 && !finalizationRun.error,
869
+ exitCode: finalizationRun.exitCode,
870
+ error: finalizationRun.error,
871
+ usage: finalizationRun.usage,
872
+ });
873
+ const finalizationOutput = finalizationRun.finalOutput;
874
+ if (finalizationRun.exitCode !== 0 || finalizationRun.error || finalizationRun.interrupted) {
875
+ const message = finalizationRun.error ?? "Acceptance finalization turn did not complete successfully.";
876
+ turns.push(createFinalizationProcessFailureTurn({ turn, prompt, rawOutput: finalizationOutput, message }));
877
+ acceptance = buildFinalizationProcessFailureLedger({ initialLedger: acceptance, turns, maxTurns, message });
878
+ break;
879
+ }
880
+ const selfReviewLedger = await evaluateAcceptance({
881
+ acceptance: selfReviewAcceptance,
882
+ output: finalizationOutput,
883
+ cwd: step.cwd ?? ctx.cwd,
884
+ });
885
+ authoritativeLedger = selfReviewLedger;
886
+ turns.push(createFinalizationTurn({ turn, prompt, rawOutput: finalizationOutput, ledger: selfReviewLedger }));
887
+ const failure = acceptanceFailureMessage(selfReviewLedger);
888
+ if (!failure) {
889
+ authoritativeLedger = step.effectiveAcceptance === selfReviewAcceptance
890
+ ? selfReviewLedger
891
+ : await evaluateAcceptance({
892
+ acceptance: step.effectiveAcceptance,
893
+ output: finalizationOutput,
894
+ cwd: step.cwd ?? ctx.cwd,
895
+ });
896
+ acceptance = attachFinalizationToLedger({ initialLedger: acceptance, authoritativeLedger, turns, status: "completed", maxTurns });
897
+ break;
898
+ }
899
+ previousFailure = failure;
900
+ if (turn === maxTurns) acceptance = attachFinalizationToLedger({ initialLedger: acceptance, authoritativeLedger, turns, status: "failed", maxTurns });
901
+ }
902
+ }
903
+ }
904
+ const acceptanceFailure = acceptance ? acceptanceFailureMessage(acceptance) : undefined;
905
+ const acceptanceCanFailRun = acceptanceFailure && acceptance?.explicit && (finalResult?.exitCode ?? 1) === 0 && !finalResult?.interrupted;
906
+ const effectiveFinalExitCode = acceptanceCanFailRun ? 1 : finalResult?.exitCode ?? 1;
907
+ const effectiveFinalError = acceptanceCanFailRun
908
+ ? (finalResult?.error ? `${finalResult.error}\n${acceptanceFailure}` : acceptanceFailure)
909
+ : finalResult?.error;
711
910
 
712
911
  if (artifactPaths && ctx.artifactConfig?.enabled !== false) {
713
912
  if (ctx.artifactConfig?.includeOutput !== false) {
@@ -720,7 +919,7 @@ async function runSingleStep(
720
919
  runId: ctx.id,
721
920
  agent: step.agent,
722
921
  task,
723
- exitCode: finalResult?.exitCode,
922
+ exitCode: effectiveFinalExitCode,
724
923
  model: finalResult?.model,
725
924
  attemptedModels: attemptedModels.length > 0 ? attemptedModels : undefined,
726
925
  modelAttempts,
@@ -735,8 +934,8 @@ async function runSingleStep(
735
934
  return {
736
935
  agent: step.agent,
737
936
  output: outputForSummary,
738
- exitCode: finalResult?.exitCode ?? 1,
739
- error: finalResult?.error,
937
+ exitCode: effectiveFinalExitCode,
938
+ error: effectiveFinalError,
740
939
  sessionFile: step.sessionFile,
741
940
  intercomTarget: ctx.childIntercomTarget,
742
941
  model: finalResult?.model,
@@ -745,6 +944,10 @@ async function runSingleStep(
745
944
  artifactPaths,
746
945
  interrupted: finalResult?.interrupted,
747
946
  completionGuardTriggered: completionGuardTriggeredFinal,
947
+ structuredOutput: (finalResult as (RunPiStreamingResult & { structuredOutput?: unknown }) | undefined)?.structuredOutput,
948
+ structuredOutputPath: effectiveStructuredOutput?.outputPath,
949
+ structuredOutputSchemaPath: effectiveStructuredOutput?.schemaPath,
950
+ acceptance,
748
951
  };
749
952
  }
750
953
 
@@ -787,7 +990,7 @@ function markParallelGroupSetupFailure(input: {
787
990
  input.statusPayload.steps[flatTaskIndex].endedAt = input.failedAt;
788
991
  input.statusPayload.steps[flatTaskIndex].durationMs = 0;
789
992
  input.statusPayload.steps[flatTaskIndex].exitCode = 1;
790
- input.results.push({ agent: input.group.parallel[taskIndex].agent, output: input.setupError, success: false, sessionFile: input.group.parallel[taskIndex].sessionFile });
993
+ input.results.push({ agent: input.group.parallel[taskIndex].agent, output: input.setupError, success: false, exitCode: 1, sessionFile: input.group.parallel[taskIndex].sessionFile });
791
994
  }
792
995
  input.statusPayload.currentStep = input.groupStartFlatIndex;
793
996
  input.statusPayload.lastUpdate = input.failedAt;
@@ -877,6 +1080,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
877
1080
  const { id, steps, resultPath, cwd, placeholder, taskIndex, totalTasks, maxOutput, artifactsDir, artifactConfig } =
878
1081
  config;
879
1082
  let previousOutput = "";
1083
+ const outputs: ChainOutputMap = {};
880
1084
  const results: StepResult[] = [];
881
1085
  const overallStartTime = Date.now();
882
1086
  const shareEnabled = config.share === true;
@@ -893,13 +1097,59 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
893
1097
  let latestSessionFile: string | undefined;
894
1098
 
895
1099
  const parallelGroups: Array<{ start: number; count: number; stepIndex: number }> = [];
1100
+ const initialStatusSteps: RunnerStatusStep[] = [];
896
1101
  let flatStepCount = 0;
897
1102
  for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) {
898
1103
  const step = steps[stepIndex]!;
899
1104
  if (isParallelGroup(step)) {
900
1105
  parallelGroups.push({ start: flatStepCount, count: step.parallel.length, stepIndex });
1106
+ for (const task of step.parallel) {
1107
+ initialStatusSteps.push({
1108
+ agent: task.agent,
1109
+ phase: task.phase,
1110
+ label: task.label,
1111
+ outputName: task.outputName,
1112
+ structured: task.structured,
1113
+ status: "pending",
1114
+ ...(task.sessionFile ? { sessionFile: task.sessionFile } : {}),
1115
+ skills: task.skills,
1116
+ model: task.model,
1117
+ thinking: task.thinking,
1118
+ attemptedModels: task.modelCandidates && task.modelCandidates.length > 0 ? task.modelCandidates : task.model ? [task.model] : undefined,
1119
+ recentTools: [],
1120
+ recentOutput: [],
1121
+ });
1122
+ }
901
1123
  flatStepCount += step.parallel.length;
1124
+ } else if (isDynamicRunnerGroup(step)) {
1125
+ parallelGroups.push({ start: flatStepCount, count: 1, stepIndex });
1126
+ initialStatusSteps.push({
1127
+ agent: `expand:${step.parallel.agent}`,
1128
+ phase: step.phase ?? step.parallel.phase,
1129
+ label: step.label ?? step.parallel.label ?? `Dynamic fanout (${step.collect.as})`,
1130
+ outputName: step.collect.as,
1131
+ structured: Boolean(step.collect.outputSchema),
1132
+ status: "pending",
1133
+ recentTools: [],
1134
+ recentOutput: [],
1135
+ });
1136
+ flatStepCount++;
902
1137
  } else {
1138
+ initialStatusSteps.push({
1139
+ agent: step.agent,
1140
+ phase: step.phase,
1141
+ label: step.label,
1142
+ outputName: step.outputName,
1143
+ structured: step.structured,
1144
+ status: "pending",
1145
+ ...(step.sessionFile ? { sessionFile: step.sessionFile } : {}),
1146
+ skills: step.skills,
1147
+ model: step.model,
1148
+ thinking: step.thinking,
1149
+ attemptedModels: step.modelCandidates && step.modelCandidates.length > 0 ? step.modelCandidates : step.model ? [step.model] : undefined,
1150
+ recentTools: [],
1151
+ recentOutput: [],
1152
+ });
903
1153
  flatStepCount++;
904
1154
  }
905
1155
  }
@@ -920,17 +1170,8 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
920
1170
  currentStep: 0,
921
1171
  chainStepCount: steps.length,
922
1172
  parallelGroups,
923
- steps: flatSteps.map((step) => ({
924
- agent: step.agent,
925
- status: "pending",
926
- ...(step.sessionFile ? { sessionFile: step.sessionFile } : {}),
927
- skills: step.skills,
928
- model: step.model,
929
- thinking: step.thinking,
930
- attemptedModels: step.modelCandidates && step.modelCandidates.length > 0 ? step.modelCandidates : step.model ? [step.model] : undefined,
931
- recentTools: [],
932
- recentOutput: [],
933
- })),
1173
+ workflowGraph: config.workflowGraph,
1174
+ steps: initialStatusSteps,
934
1175
  artifactsDir,
935
1176
  sessionDir: config.sessionDir,
936
1177
  outputFile: path.join(asyncDir, "output-0.log"),
@@ -938,6 +1179,70 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
938
1179
 
939
1180
  fs.mkdirSync(asyncDir, { recursive: true });
940
1181
  writeAtomicJson(statusPath, statusPayload);
1182
+ const emitNestedSelfEvent = (type: "subagent.nested.updated" | "subagent.nested.completed"): void => {
1183
+ if (!config.nestedRoute || !config.nestedSelf) return;
1184
+ try {
1185
+ writeNestedEvent(config.nestedRoute, {
1186
+ type,
1187
+ ts: Date.now(),
1188
+ parentRunId: config.nestedSelf.parentRunId,
1189
+ parentStepIndex: config.nestedSelf.parentStepIndex,
1190
+ child: nestedSummaryFromAsyncStatus(statusPayload, asyncDir, {
1191
+ id,
1192
+ parentRunId: config.nestedSelf.parentRunId,
1193
+ parentStepIndex: config.nestedSelf.parentStepIndex,
1194
+ depth: config.nestedSelf.depth,
1195
+ path: config.nestedSelf.path,
1196
+ mode: statusPayload.mode,
1197
+ ts: Date.now(),
1198
+ }),
1199
+ });
1200
+ } catch (error) {
1201
+ console.error("Failed to emit nested async status event:", error);
1202
+ }
1203
+ };
1204
+ const refreshWorkflowGraph = (): void => {
1205
+ if (!config.workflowGraph) return;
1206
+ const graph = structuredClone(statusPayload.workflowGraph ?? config.workflowGraph);
1207
+ const normalize = (status: RunnerStatusStep["status"]): "pending" | "running" | "completed" | "failed" | "paused" | "detached" => {
1208
+ if (status === "complete" || status === "completed") return "completed";
1209
+ if (status === "running" || status === "failed" || status === "paused" || status === "pending") return status;
1210
+ return "pending";
1211
+ };
1212
+ const updateNode = (node: NonNullable<typeof graph.nodes>[number]): void => {
1213
+ if (node.flatIndex !== undefined) {
1214
+ const step = statusPayload.steps[node.flatIndex];
1215
+ if (step) {
1216
+ node.status = normalize(step.status);
1217
+ node.error = step.error;
1218
+ node.acceptanceStatus = step.acceptance?.status;
1219
+ }
1220
+ if (statusPayload.currentStep === node.flatIndex) graph.currentNodeId = node.id;
1221
+ }
1222
+ for (const child of node.children ?? []) updateNode(child);
1223
+ if (node.children?.length) {
1224
+ if (node.children.every((child) => child.status === "completed")) node.status = "completed";
1225
+ else if (node.children.some((child) => child.status === "running")) node.status = "running";
1226
+ else if (node.children.some((child) => child.status === "failed")) node.status = "failed";
1227
+ else if (node.children.some((child) => child.status === "paused")) node.status = "paused";
1228
+ }
1229
+ if (node.error) node.status = "failed";
1230
+ };
1231
+ for (const node of graph.nodes) updateNode(node);
1232
+ statusPayload.workflowGraph = graph;
1233
+ };
1234
+ const writeStatusPayload = (): void => {
1235
+ refreshWorkflowGraph();
1236
+ writeAtomicJson(statusPath, statusPayload);
1237
+ emitNestedSelfEvent(statusPayload.state === "running" || statusPayload.state === "queued" ? "subagent.nested.updated" : "subagent.nested.completed");
1238
+ };
1239
+ const markDynamicGraphGroup = (stepIndex: number, status: "completed" | "failed" | "running", error?: string, acceptance?: AcceptanceLedger): void => {
1240
+ const groupNode = statusPayload.workflowGraph?.nodes.find((node) => node.id === `step-${stepIndex}`);
1241
+ if (!groupNode) return;
1242
+ groupNode.status = status;
1243
+ groupNode.error = error;
1244
+ groupNode.acceptanceStatus = acceptance?.status ?? groupNode.acceptanceStatus;
1245
+ };
941
1246
 
942
1247
  const stepOutputActivityAt = (index: number): number => {
943
1248
  const step = statusPayload.steps[index];
@@ -954,8 +1259,8 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
954
1259
  };
955
1260
  const emittedControlEventKeys = new Set<string>();
956
1261
  const activeLongRunningSteps = new Set<number>();
957
- const mutatingFailureStates = flatSteps.map(() => createMutatingFailureState());
958
- const pendingToolResults: Array<{ tool: string; path?: string; mutates: boolean; startedAt?: number } | undefined> = [];
1262
+ const mutatingFailureStates = initialStatusSteps.map(() => createMutatingFailureState());
1263
+ const pendingToolResults: Array<{ tool: string; path?: string; mutates: boolean; startedAt?: number } | undefined> = initialStatusSteps.map(() => undefined);
959
1264
  const mutatingFailureWindowMs = 5 * 60_000;
960
1265
  const appendControlEvent = (event: ReturnType<typeof buildControlEvent>) => {
961
1266
  if (!controlConfig.enabled) return;
@@ -1028,7 +1333,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1028
1333
  step.model = model;
1029
1334
  step.thinking = thinking;
1030
1335
  statusPayload.lastUpdate = now;
1031
- writeAtomicJson(statusPath, statusPayload);
1336
+ writeStatusPayload();
1032
1337
  };
1033
1338
  const updateStepFromChildEvent = (flatIndex: number, event: ChildEvent): void => {
1034
1339
  const step = statusPayload.steps[flatIndex];
@@ -1096,7 +1401,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1096
1401
  resetMutatingFailureState(mutatingFailureStates[flatIndex]!);
1097
1402
  }
1098
1403
  } else if (event.type === "message_end" && event.message?.role === "assistant") {
1099
- appendRecentStepOutput(step, extractTextFromContent(event.message.content).split("\n").slice(-10));
1404
+ appendRecentStepOutput(step, stripAcceptanceReport(extractTextFromContent(event.message.content)).split("\n").slice(-10));
1100
1405
  step.turnCount = (step.turnCount ?? 0) + 1;
1101
1406
  const usage = event.message.usage;
1102
1407
  if (usage) {
@@ -1116,7 +1421,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1116
1421
  statusPayload.lastActivityAt = now;
1117
1422
  statusPayload.lastUpdate = now;
1118
1423
  maybeEmitActiveLongRunning(flatIndex, now);
1119
- writeAtomicJson(statusPath, statusPayload);
1424
+ writeStatusPayload();
1120
1425
  };
1121
1426
  const updateRunnerActivityState = (now: number): boolean => {
1122
1427
  if (!controlConfig.enabled) return false;
@@ -1171,7 +1476,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1171
1476
  changed = true;
1172
1477
  }
1173
1478
  statusPayload.lastUpdate = now;
1174
- if (changed) writeAtomicJson(statusPath, statusPayload);
1479
+ if (changed) writeStatusPayload();
1175
1480
  return changed;
1176
1481
  };
1177
1482
  if (controlConfig.enabled) {
@@ -1200,7 +1505,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1200
1505
  step.lastActivityAt = now;
1201
1506
  }
1202
1507
  }
1203
- writeAtomicJson(statusPath, statusPayload);
1508
+ writeStatusPayload();
1204
1509
  appendJsonl(eventsPath, JSON.stringify({
1205
1510
  type: "subagent.run.paused",
1206
1511
  ts: now,
@@ -1227,6 +1532,262 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1227
1532
  if (interrupted) break;
1228
1533
  const step = steps[stepIndex];
1229
1534
 
1535
+ if (isDynamicRunnerGroup(step)) {
1536
+ const groupStartFlatIndex = flatIndex;
1537
+ let materialized: ReturnType<typeof materializeDynamicParallelStep>;
1538
+ try {
1539
+ materialized = materializeDynamicParallelStep(step as Parameters<typeof materializeDynamicParallelStep>[0], outputs, stepIndex, { maxItems: config.dynamicFanoutMaxItems, allowRunnerFields: true });
1540
+ if (materialized.collectedOnEmpty) validateDynamicCollection(step.collect.outputSchema, materialized.collectedOnEmpty);
1541
+ } catch (error) {
1542
+ const now = Date.now();
1543
+ const message = error instanceof DynamicFanoutError ? error.message : error instanceof Error ? error.message : String(error);
1544
+ statusPayload.state = "failed";
1545
+ statusPayload.error = message;
1546
+ statusPayload.currentStep = flatIndex;
1547
+ const placeholder = statusPayload.steps[groupStartFlatIndex];
1548
+ if (placeholder) {
1549
+ placeholder.status = "failed";
1550
+ placeholder.error = message;
1551
+ placeholder.startedAt = now;
1552
+ placeholder.endedAt = now;
1553
+ placeholder.durationMs = 0;
1554
+ placeholder.exitCode = 1;
1555
+ }
1556
+ statusPayload.lastUpdate = now;
1557
+ markDynamicGraphGroup(stepIndex, "failed", message);
1558
+ writeStatusPayload();
1559
+ results.push({ agent: step.parallel.agent, output: message, error: message, success: false, exitCode: 1 });
1560
+ break;
1561
+ }
1562
+
1563
+ if (materialized.parallel.length === 0) {
1564
+ const now = Date.now();
1565
+ const collection = materialized.collectedOnEmpty ?? [];
1566
+ outputs[step.collect.as] = {
1567
+ text: JSON.stringify(collection),
1568
+ structured: collection,
1569
+ agent: step.parallel.agent,
1570
+ stepIndex,
1571
+ };
1572
+ statusPayload.outputs = outputs;
1573
+ const placeholder = statusPayload.steps[groupStartFlatIndex];
1574
+ if (placeholder) {
1575
+ placeholder.status = "complete";
1576
+ placeholder.startedAt = now;
1577
+ placeholder.endedAt = now;
1578
+ placeholder.durationMs = 0;
1579
+ }
1580
+ previousOutput = "Dynamic fanout produced 0 results.";
1581
+ flatIndex++;
1582
+ statusPayload.lastUpdate = now;
1583
+ markDynamicGraphGroup(stepIndex, "completed");
1584
+ writeStatusPayload();
1585
+ continue;
1586
+ }
1587
+
1588
+ const dynamicSteps = materialized.parallel.map((task, itemIndex) => ({
1589
+ ...step.parallel,
1590
+ task: task.task ?? step.parallel.task,
1591
+ label: task.label ?? step.parallel.label,
1592
+ structuredOutput: undefined,
1593
+ structuredOutputSchema: step.parallel.structuredOutputSchema ?? step.parallel.structuredOutput?.schema,
1594
+ }));
1595
+ const dynamicStatusSteps: RunnerStatusStep[] = dynamicSteps.map((task) => ({
1596
+ agent: task.agent,
1597
+ phase: task.phase ?? step.phase,
1598
+ label: task.label,
1599
+ outputName: undefined,
1600
+ structured: Boolean(task.structuredOutputSchema),
1601
+ status: "pending",
1602
+ ...(task.sessionFile ? { sessionFile: task.sessionFile } : {}),
1603
+ skills: task.skills,
1604
+ model: task.model,
1605
+ thinking: task.thinking,
1606
+ attemptedModels: task.modelCandidates && task.modelCandidates.length > 0 ? task.modelCandidates : task.model ? [task.model] : undefined,
1607
+ recentTools: [],
1608
+ recentOutput: [],
1609
+ }));
1610
+ statusPayload.steps.splice(groupStartFlatIndex, 1, ...dynamicStatusSteps);
1611
+ if (config.childIntercomTargets) {
1612
+ config.childIntercomTargets = statusPayload.steps.map((statusStep, index) => resolveSubagentIntercomTarget(id, statusStep.agent, index));
1613
+ }
1614
+ mutatingFailureStates.splice(groupStartFlatIndex, 1, ...dynamicStatusSteps.map(() => createMutatingFailureState()));
1615
+ pendingToolResults.splice(groupStartFlatIndex, 1, ...dynamicStatusSteps.map(() => undefined));
1616
+ const materializedDelta = dynamicStatusSteps.length - 1;
1617
+ for (const group of statusPayload.parallelGroups) {
1618
+ if (group.stepIndex === stepIndex) {
1619
+ group.start = groupStartFlatIndex;
1620
+ group.count = dynamicStatusSteps.length;
1621
+ } else if (group.start > groupStartFlatIndex) {
1622
+ group.start += materializedDelta;
1623
+ }
1624
+ }
1625
+ if (statusPayload.workflowGraph) {
1626
+ const shiftFlatIndexes = (nodes: NonNullable<typeof statusPayload.workflowGraph>["nodes"]): void => {
1627
+ for (const node of nodes) {
1628
+ if (node.stepIndex !== undefined && node.stepIndex > stepIndex && node.flatIndex !== undefined && node.flatIndex >= groupStartFlatIndex) {
1629
+ node.flatIndex += dynamicStatusSteps.length;
1630
+ }
1631
+ if (node.children) shiftFlatIndexes(node.children);
1632
+ }
1633
+ };
1634
+ shiftFlatIndexes(statusPayload.workflowGraph.nodes);
1635
+ const groupNode = statusPayload.workflowGraph.nodes.find((node) => node.id === `step-${stepIndex}`);
1636
+ if (groupNode) {
1637
+ groupNode.children = materialized.items.map((item, itemIndex) => ({
1638
+ id: `step-${stepIndex}-item-${item.idKey}`,
1639
+ kind: "agent",
1640
+ agent: step.parallel.agent,
1641
+ phase: dynamicSteps[itemIndex]?.phase ?? step.phase,
1642
+ label: dynamicSteps[itemIndex]?.label?.trim() || `${step.parallel.agent} ${item.key}`,
1643
+ status: "pending",
1644
+ flatIndex: groupStartFlatIndex + itemIndex,
1645
+ stepIndex,
1646
+ itemKey: item.key,
1647
+ structured: Boolean(dynamicSteps[itemIndex]?.structuredOutputSchema),
1648
+ }));
1649
+ }
1650
+ }
1651
+ writeStatusPayload();
1652
+
1653
+ const concurrency = step.concurrency ?? MAX_PARALLEL_CONCURRENCY;
1654
+ const failFast = step.failFast ?? false;
1655
+ let aborted = false;
1656
+ const parallelResults = await mapConcurrent(dynamicSteps, concurrency, async (task, taskIdx) => {
1657
+ const fi = groupStartFlatIndex + taskIdx;
1658
+ if (aborted && failFast) {
1659
+ const skippedAt = Date.now();
1660
+ statusPayload.steps[fi].status = "failed";
1661
+ statusPayload.steps[fi].error = "Skipped due to fail-fast";
1662
+ statusPayload.steps[fi].startedAt = skippedAt;
1663
+ statusPayload.steps[fi].endedAt = skippedAt;
1664
+ statusPayload.steps[fi].durationMs = 0;
1665
+ statusPayload.steps[fi].exitCode = -1;
1666
+ statusPayload.lastUpdate = skippedAt;
1667
+ writeStatusPayload();
1668
+ return { agent: task.agent, output: "(skipped — fail-fast)", exitCode: -1 as number | null, skipped: true };
1669
+ }
1670
+ const taskStartTime = Date.now();
1671
+ statusPayload.currentStep = fi;
1672
+ statusPayload.steps[fi].status = "running";
1673
+ statusPayload.steps[fi].error = undefined;
1674
+ statusPayload.steps[fi].activityState = undefined;
1675
+ resetStepLiveDetail(statusPayload.steps[fi]);
1676
+ statusPayload.steps[fi].startedAt = taskStartTime;
1677
+ statusPayload.steps[fi].lastActivityAt = taskStartTime;
1678
+ statusPayload.outputFile = path.join(asyncDir, `output-${fi}.log`);
1679
+ statusPayload.lastActivityAt = taskStartTime;
1680
+ statusPayload.lastUpdate = taskStartTime;
1681
+ writeStatusPayload();
1682
+ appendJsonl(eventsPath, JSON.stringify({ type: "subagent.step.started", ts: taskStartTime, runId: id, stepIndex: fi, agent: task.agent }));
1683
+ const singleResult = await runSingleStep(task, {
1684
+ previousOutput, placeholder, cwd, sessionEnabled,
1685
+ outputs,
1686
+ sessionDir: config.sessionDir ? path.join(config.sessionDir, `dynamic-${stepIndex}-${taskIdx}`) : undefined,
1687
+ artifactsDir, artifactConfig, id,
1688
+ flatIndex: fi, flatStepCount: Math.max(statusPayload.steps.length, 1),
1689
+ outputFile: path.join(asyncDir, `output-${fi}.log`),
1690
+ piPackageRoot: config.piPackageRoot,
1691
+ piArgv1: config.piArgv1,
1692
+ childIntercomTarget: config.childIntercomTargets?.[fi],
1693
+ orchestratorIntercomTarget: config.controlIntercomTarget,
1694
+ nestedRoute: config.nestedRoute,
1695
+ registerInterrupt: (interrupt) => {
1696
+ activeChildInterrupt = interrupt;
1697
+ },
1698
+ onAttemptStart: (attempt) => updateStepModel(fi, attempt.model, attempt.thinking),
1699
+ onChildEvent: (event) => updateStepFromChildEvent(fi, event),
1700
+ });
1701
+ const taskEndTime = Date.now();
1702
+ statusPayload.steps[fi].status = singleResult.exitCode === 0 ? "complete" : "failed";
1703
+ statusPayload.steps[fi].endedAt = taskEndTime;
1704
+ statusPayload.steps[fi].durationMs = taskEndTime - taskStartTime;
1705
+ statusPayload.steps[fi].exitCode = singleResult.exitCode;
1706
+ statusPayload.steps[fi].model = singleResult.model;
1707
+ statusPayload.steps[fi].thinking = resolveEffectiveThinking(singleResult.model, statusPayload.steps[fi].thinking);
1708
+ statusPayload.steps[fi].attemptedModels = singleResult.attemptedModels;
1709
+ statusPayload.steps[fi].modelAttempts = singleResult.modelAttempts;
1710
+ statusPayload.steps[fi].error = singleResult.error;
1711
+ statusPayload.steps[fi].structuredOutput = singleResult.structuredOutput;
1712
+ statusPayload.steps[fi].structuredOutputPath = singleResult.structuredOutputPath;
1713
+ statusPayload.steps[fi].structuredOutputSchemaPath = singleResult.structuredOutputSchemaPath;
1714
+ statusPayload.steps[fi].acceptance = singleResult.acceptance;
1715
+ statusPayload.lastUpdate = taskEndTime;
1716
+ writeStatusPayload();
1717
+ appendJsonl(eventsPath, JSON.stringify({
1718
+ type: singleResult.exitCode === 0 ? "subagent.step.completed" : "subagent.step.failed",
1719
+ ts: taskEndTime, runId: id, stepIndex: fi, agent: task.agent,
1720
+ exitCode: singleResult.exitCode, durationMs: taskEndTime - taskStartTime,
1721
+ }));
1722
+ if (singleResult.exitCode !== 0 && failFast) aborted = true;
1723
+ return { ...singleResult, skipped: false };
1724
+ });
1725
+
1726
+ flatIndex += dynamicSteps.length;
1727
+ for (const pr of parallelResults) {
1728
+ results.push({
1729
+ agent: pr.agent,
1730
+ output: pr.output,
1731
+ error: pr.error,
1732
+ success: pr.exitCode === 0,
1733
+ exitCode: pr.exitCode,
1734
+ skipped: pr.skipped,
1735
+ sessionFile: pr.sessionFile,
1736
+ intercomTarget: pr.intercomTarget,
1737
+ model: pr.model,
1738
+ attemptedModels: pr.attemptedModels,
1739
+ modelAttempts: pr.modelAttempts,
1740
+ artifactPaths: pr.artifactPaths,
1741
+ structuredOutput: pr.structuredOutput,
1742
+ structuredOutputPath: pr.structuredOutputPath,
1743
+ structuredOutputSchemaPath: pr.structuredOutputSchemaPath,
1744
+ acceptance: pr.acceptance,
1745
+ });
1746
+ }
1747
+ const collection = collectDynamicResults(step as Parameters<typeof collectDynamicResults>[0], materialized.items, parallelResults);
1748
+ const failures = parallelResults.filter((result) => result.exitCode !== 0 && result.exitCode !== -1);
1749
+ if (failures.length === 0) {
1750
+ try {
1751
+ validateDynamicCollection(step.collect.outputSchema, collection);
1752
+ outputs[step.collect.as] = {
1753
+ text: JSON.stringify(collection),
1754
+ structured: collection,
1755
+ agent: step.parallel.agent,
1756
+ stepIndex,
1757
+ };
1758
+ statusPayload.outputs = outputs;
1759
+ markDynamicGraphGroup(stepIndex, "completed");
1760
+ } catch (error) {
1761
+ const message = error instanceof DynamicFanoutError ? error.message : error instanceof Error ? error.message : String(error);
1762
+ results.push({ agent: step.parallel.agent, output: message, error: message, success: false, exitCode: 1, structuredOutput: collection });
1763
+ statusPayload.error = message;
1764
+ markDynamicGraphGroup(stepIndex, "failed", message);
1765
+ }
1766
+ }
1767
+ previousOutput = aggregateParallelOutputs(
1768
+ parallelResults.map((r, i) => ({
1769
+ agent: r.agent,
1770
+ taskIndex: i,
1771
+ output: r.output,
1772
+ exitCode: r.exitCode,
1773
+ error: r.error,
1774
+ })),
1775
+ (i, agent) => `=== Dynamic Item ${i + 1} (${agent}, key ${materialized.items[i]?.key ?? i}) ===`,
1776
+ );
1777
+ appendJsonl(eventsPath, JSON.stringify({
1778
+ type: "subagent.dynamic.completed",
1779
+ ts: Date.now(),
1780
+ runId: id,
1781
+ stepIndex,
1782
+ success: failures.length === 0,
1783
+ }));
1784
+ if (failures.length > 0) markDynamicGraphGroup(stepIndex, "failed", failures[0]?.error ?? "Dynamic fanout child failed.");
1785
+ statusPayload.lastUpdate = Date.now();
1786
+ writeStatusPayload();
1787
+ if (failures.length > 0 || statusPayload.error) break;
1788
+ continue;
1789
+ }
1790
+
1230
1791
  if (isParallelGroup(step)) {
1231
1792
  const group = step;
1232
1793
  const concurrency = group.concurrency ?? MAX_PARALLEL_CONCURRENCY;
@@ -1311,7 +1872,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1311
1872
  statusPayload.steps[fi].exitCode = -1;
1312
1873
  statusPayload.steps[fi].activityState = undefined;
1313
1874
  statusPayload.lastUpdate = skippedAt;
1314
- writeAtomicJson(statusPath, statusPayload);
1875
+ writeStatusPayload();
1315
1876
  appendJsonl(eventsPath, JSON.stringify({
1316
1877
  type: "subagent.step.failed", ts: skippedAt, runId: id, stepIndex: fi, agent: task.agent, exitCode: -1, durationMs: 0,
1317
1878
  }));
@@ -1331,7 +1892,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1331
1892
  statusPayload.outputFile = path.join(asyncDir, `output-${fi}.log`);
1332
1893
  statusPayload.lastActivityAt = taskStartTime;
1333
1894
  statusPayload.lastUpdate = taskStartTime;
1334
- writeAtomicJson(statusPath, statusPayload);
1895
+ writeStatusPayload();
1335
1896
 
1336
1897
  appendJsonl(eventsPath, JSON.stringify({
1337
1898
  type: "subagent.step.started", ts: taskStartTime, runId: id, stepIndex: fi, agent: task.agent,
@@ -1344,6 +1905,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1344
1905
 
1345
1906
  const singleResult = await runSingleStep(taskForRun, {
1346
1907
  previousOutput, placeholder, cwd: taskCwd, sessionEnabled,
1908
+ outputs,
1347
1909
  sessionDir: taskSessionDir,
1348
1910
  artifactsDir, artifactConfig, id,
1349
1911
  flatIndex: fi, flatStepCount: flatSteps.length,
@@ -1352,6 +1914,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1352
1914
  piArgv1: config.piArgv1,
1353
1915
  childIntercomTarget: config.childIntercomTargets?.[fi],
1354
1916
  orchestratorIntercomTarget: config.controlIntercomTarget,
1917
+ nestedRoute: config.nestedRoute,
1355
1918
  registerInterrupt: (interrupt) => {
1356
1919
  activeChildInterrupt = interrupt;
1357
1920
  },
@@ -1374,8 +1937,12 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1374
1937
  statusPayload.steps[fi].attemptedModels = singleResult.attemptedModels;
1375
1938
  statusPayload.steps[fi].modelAttempts = singleResult.modelAttempts;
1376
1939
  statusPayload.steps[fi].error = singleResult.error;
1940
+ statusPayload.steps[fi].structuredOutput = singleResult.structuredOutput;
1941
+ statusPayload.steps[fi].structuredOutputPath = singleResult.structuredOutputPath;
1942
+ statusPayload.steps[fi].structuredOutputSchemaPath = singleResult.structuredOutputSchemaPath;
1943
+ statusPayload.steps[fi].acceptance = singleResult.acceptance;
1377
1944
  statusPayload.lastUpdate = taskEndTime;
1378
- writeAtomicJson(statusPath, statusPayload);
1945
+ writeStatusPayload();
1379
1946
 
1380
1947
  appendJsonl(eventsPath, JSON.stringify({
1381
1948
  type: singleResult.exitCode === 0 ? "subagent.step.completed" : "subagent.step.failed",
@@ -1419,7 +1986,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1419
1986
  }
1420
1987
  statusPayload.totalTokens = { ...previousCumulativeTokens };
1421
1988
  statusPayload.lastUpdate = Date.now();
1422
- writeAtomicJson(statusPath, statusPayload);
1989
+ writeStatusPayload();
1423
1990
 
1424
1991
  for (const pr of parallelResults) {
1425
1992
  results.push({
@@ -1427,6 +1994,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1427
1994
  output: pr.output,
1428
1995
  error: pr.error,
1429
1996
  success: pr.exitCode === 0,
1997
+ exitCode: pr.exitCode,
1430
1998
  skipped: pr.skipped,
1431
1999
  sessionFile: pr.sessionFile,
1432
2000
  intercomTarget: pr.intercomTarget,
@@ -1434,8 +2002,21 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1434
2002
  attemptedModels: pr.attemptedModels,
1435
2003
  modelAttempts: pr.modelAttempts,
1436
2004
  artifactPaths: pr.artifactPaths,
1437
- });
2005
+ structuredOutput: pr.structuredOutput,
2006
+ structuredOutputPath: pr.structuredOutputPath,
2007
+ structuredOutputSchemaPath: pr.structuredOutputSchemaPath,
2008
+ acceptance: pr.acceptance,
2009
+ });
2010
+ }
2011
+ for (let t = 0; t < group.parallel.length; t++) {
2012
+ const outputName = group.parallel[t]?.outputName;
2013
+ if (outputName) outputs[outputName] = outputEntryFromAsyncResult({
2014
+ agent: parallelResults[t]!.agent,
2015
+ output: parallelResults[t]!.output,
2016
+ structuredOutput: parallelResults[t]!.structuredOutput,
2017
+ }, stepIndex);
1438
2018
  }
2019
+ statusPayload.outputs = outputs;
1439
2020
 
1440
2021
  previousOutput = aggregateParallelOutputs(
1441
2022
  parallelResults.map((r) => ({
@@ -1477,7 +2058,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1477
2058
  statusPayload.lastActivityAt = stepStartTime;
1478
2059
  statusPayload.lastUpdate = stepStartTime;
1479
2060
  statusPayload.outputFile = path.join(asyncDir, `output-${flatIndex}.log`);
1480
- writeAtomicJson(statusPath, statusPayload);
2061
+ writeStatusPayload();
1481
2062
 
1482
2063
  appendJsonl(eventsPath, JSON.stringify({
1483
2064
  type: "subagent.step.started",
@@ -1489,6 +2070,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1489
2070
 
1490
2071
  const singleResult = await runSingleStep(seqStep, {
1491
2072
  previousOutput, placeholder, cwd, sessionEnabled,
2073
+ outputs,
1492
2074
  sessionDir: config.sessionDir,
1493
2075
  artifactsDir, artifactConfig, id,
1494
2076
  flatIndex, flatStepCount: flatSteps.length,
@@ -1497,6 +2079,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1497
2079
  piArgv1: config.piArgv1,
1498
2080
  childIntercomTarget: config.childIntercomTargets?.[flatIndex],
1499
2081
  orchestratorIntercomTarget: config.controlIntercomTarget,
2082
+ nestedRoute: config.nestedRoute,
1500
2083
  registerInterrupt: (interrupt) => {
1501
2084
  activeChildInterrupt = interrupt;
1502
2085
  },
@@ -1513,13 +2096,26 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1513
2096
  output: singleResult.output,
1514
2097
  error: singleResult.error,
1515
2098
  success: singleResult.exitCode === 0,
2099
+ exitCode: singleResult.exitCode,
1516
2100
  sessionFile: singleResult.sessionFile,
1517
2101
  intercomTarget: singleResult.intercomTarget,
1518
2102
  model: singleResult.model,
1519
2103
  attemptedModels: singleResult.attemptedModels,
1520
2104
  modelAttempts: singleResult.modelAttempts,
1521
2105
  artifactPaths: singleResult.artifactPaths,
2106
+ structuredOutput: singleResult.structuredOutput,
2107
+ structuredOutputPath: singleResult.structuredOutputPath,
2108
+ structuredOutputSchemaPath: singleResult.structuredOutputSchemaPath,
2109
+ acceptance: singleResult.acceptance,
1522
2110
  });
2111
+ if (seqStep.outputName) {
2112
+ outputs[seqStep.outputName] = outputEntryFromAsyncResult({
2113
+ agent: singleResult.agent,
2114
+ output: singleResult.output,
2115
+ structuredOutput: singleResult.structuredOutput,
2116
+ }, stepIndex);
2117
+ }
2118
+ statusPayload.outputs = outputs;
1523
2119
 
1524
2120
  const cumulativeTokens = config.sessionDir ? parseSessionTokens(config.sessionDir) : null;
1525
2121
  let stepTokens: TokenUsage | null = cumulativeTokens
@@ -1552,12 +2148,16 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1552
2148
  statusPayload.steps[flatIndex].attemptedModels = singleResult.attemptedModels;
1553
2149
  statusPayload.steps[flatIndex].modelAttempts = singleResult.modelAttempts;
1554
2150
  statusPayload.steps[flatIndex].error = singleResult.error;
2151
+ statusPayload.steps[flatIndex].structuredOutput = singleResult.structuredOutput;
2152
+ statusPayload.steps[flatIndex].structuredOutputPath = singleResult.structuredOutputPath;
2153
+ statusPayload.steps[flatIndex].structuredOutputSchemaPath = singleResult.structuredOutputSchemaPath;
2154
+ statusPayload.steps[flatIndex].acceptance = singleResult.acceptance;
1555
2155
  if (stepTokens) {
1556
2156
  statusPayload.steps[flatIndex].tokens = stepTokens;
1557
2157
  statusPayload.totalTokens = { ...previousCumulativeTokens };
1558
2158
  }
1559
2159
  statusPayload.lastUpdate = stepEndTime;
1560
- writeAtomicJson(statusPath, statusPayload);
2160
+ writeStatusPayload();
1561
2161
 
1562
2162
  appendJsonl(eventsPath, JSON.stringify({
1563
2163
  type: singleResult.exitCode === 0 ? "subagent.step.completed" : "subagent.step.failed",
@@ -1653,13 +2253,13 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1653
2253
  statusPayload.shareUrl = shareUrl;
1654
2254
  statusPayload.gistUrl = gistUrl;
1655
2255
  statusPayload.shareError = shareError;
1656
- if (statusPayload.state === "failed") {
2256
+ if (statusPayload.state === "failed" && !statusPayload.error) {
1657
2257
  const failedStep = statusPayload.steps.find((s) => s.status === "failed");
1658
2258
  if (failedStep?.agent) {
1659
2259
  statusPayload.error = `Step failed: ${failedStep.agent}`;
1660
2260
  }
1661
2261
  }
1662
- writeAtomicJson(statusPath, statusPayload);
2262
+ writeStatusPayload();
1663
2263
  appendJsonl(
1664
2264
  eventsPath,
1665
2265
  JSON.stringify({
@@ -1710,7 +2310,13 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1710
2310
  modelAttempts: r.modelAttempts,
1711
2311
  artifactPaths: r.artifactPaths,
1712
2312
  truncated: r.truncated,
2313
+ structuredOutput: r.structuredOutput,
2314
+ structuredOutputPath: r.structuredOutputPath,
2315
+ structuredOutputSchemaPath: r.structuredOutputSchemaPath,
2316
+ acceptance: r.acceptance,
1713
2317
  })),
2318
+ outputs,
2319
+ workflowGraph: statusPayload.workflowGraph,
1714
2320
  exitCode: interrupted || results.every((r) => r.success) ? 0 : 1,
1715
2321
  timestamp: runEndedAt,
1716
2322
  durationMs: runEndedAt - overallStartTime,