pi-subagents 0.25.0 → 0.28.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 (40) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +175 -19
  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/skills/pi-subagents/SKILL.md +60 -17
  7. package/src/agents/agent-management.ts +71 -15
  8. package/src/agents/agent-serializer.ts +13 -2
  9. package/src/agents/agents.ts +88 -17
  10. package/src/agents/chain-serializer.ts +120 -0
  11. package/src/extension/fanout-child.ts +2 -0
  12. package/src/extension/index.ts +5 -2
  13. package/src/extension/schemas.ts +132 -6
  14. package/src/intercom/result-intercom.ts +5 -0
  15. package/src/runs/background/async-execution.ts +88 -6
  16. package/src/runs/background/async-status.ts +11 -1
  17. package/src/runs/background/run-status.ts +10 -1
  18. package/src/runs/background/subagent-runner.ts +665 -39
  19. package/src/runs/foreground/chain-execution.ts +369 -118
  20. package/src/runs/foreground/execution.ts +392 -19
  21. package/src/runs/foreground/subagent-executor.ts +126 -3
  22. package/src/runs/shared/acceptance-contract.ts +318 -0
  23. package/src/runs/shared/acceptance-evaluation.ts +221 -0
  24. package/src/runs/shared/acceptance-finalization.ts +173 -0
  25. package/src/runs/shared/acceptance-reports.ts +127 -0
  26. package/src/runs/shared/acceptance.ts +22 -0
  27. package/src/runs/shared/chain-outputs.ts +101 -0
  28. package/src/runs/shared/completion-guard.ts +26 -3
  29. package/src/runs/shared/dynamic-fanout.ts +293 -0
  30. package/src/runs/shared/parallel-utils.ts +33 -1
  31. package/src/runs/shared/pi-args.ts +11 -0
  32. package/src/runs/shared/structured-output.ts +77 -0
  33. package/src/runs/shared/subagent-prompt-runtime.ts +53 -3
  34. package/src/runs/shared/workflow-graph.ts +210 -0
  35. package/src/shared/formatters.ts +2 -2
  36. package/src/shared/settings.ts +53 -4
  37. package/src/shared/types.ts +265 -1
  38. package/src/shared/utils.ts +7 -0
  39. package/src/slash/slash-commands.ts +41 -3
  40. package/src/tui/render.ts +178 -45
@@ -8,16 +8,22 @@ 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,
17
20
  type NestedRouteInfo,
18
21
  type ResolvedControlConfig,
22
+ type ResourceLimitExceeded,
19
23
  type SubagentRunMode,
24
+ type TokenUsage,
20
25
  type Usage,
26
+ type WorkflowGraphSnapshot,
21
27
  DEFAULT_MAX_OUTPUT,
22
28
  type MaxOutputConfig,
23
29
  truncateOutput,
@@ -34,6 +40,7 @@ import {
34
40
  import {
35
41
  type RunnerSubagentStep as SubagentStep,
36
42
  type RunnerStep,
43
+ isDynamicRunnerGroup,
37
44
  isParallelGroup,
38
45
  flattenSteps,
39
46
  mapConcurrent,
@@ -41,11 +48,14 @@ import {
41
48
  MAX_PARALLEL_CONCURRENCY,
42
49
  } from "../shared/parallel-utils.ts";
43
50
  import { buildPiArgs, cleanupTempDir } from "../shared/pi-args.ts";
51
+ import { outputEntryFromAsyncResult, resolveOutputReferences } from "../shared/chain-outputs.ts";
52
+ import { createStructuredOutputRuntime, readStructuredOutput } from "../shared/structured-output.ts";
53
+ import { collectDynamicResults, DynamicFanoutError, materializeDynamicParallelStep, validateDynamicCollection } from "../shared/dynamic-fanout.ts";
44
54
  import { nestedSummaryFromAsyncStatus, writeNestedEvent } from "../shared/nested-events.ts";
45
55
  import { formatModelAttemptNote, isRetryableModelFailure } from "../shared/model-fallback.ts";
46
56
  import { attachPostExitStdioGuard, trySignalChild } from "../../shared/post-exit-stdio-guard.ts";
47
- import { detectSubagentError, extractTextFromContent, extractToolArgsPreview, getFinalOutput } from "../../shared/utils.ts";
48
- import { evaluateCompletionMutationGuard } from "../shared/completion-guard.ts";
57
+ import { detectSubagentError, extractTextFromContent, extractToolArgsPreview, formatResourceLimitExceeded, getFinalOutput } from "../../shared/utils.ts";
58
+ import { evaluateCompletionMutationGuard, resolveCompletionPolicy } from "../shared/completion-guard.ts";
49
59
  import {
50
60
  createMutatingFailureState,
51
61
  didMutatingToolFail,
@@ -58,7 +68,6 @@ import {
58
68
  summarizeRecentMutatingFailures,
59
69
  } from "../shared/long-running-guard.ts";
60
70
  import { parseSessionTokens } from "../../shared/session-tokens.ts";
61
- import type { TokenUsage } from "../../shared/types.ts";
62
71
  import {
63
72
  cleanupWorktrees,
64
73
  createWorktrees,
@@ -70,6 +79,20 @@ import {
70
79
  } from "../shared/worktree.ts";
71
80
  import { resolveEffectiveThinking } from "../../shared/model-info.ts";
72
81
  import { writeInitialProgressFile } from "../../shared/settings.ts";
82
+ import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts";
83
+ import {
84
+ acceptanceFailureMessage,
85
+ acceptanceSelfReviewConfig,
86
+ attachFinalizationToLedger,
87
+ buildFinalizationProcessFailureLedger,
88
+ createFinalizationProcessFailureTurn,
89
+ createFinalizationTurn,
90
+ evaluateAcceptance,
91
+ formatAcceptanceFinalizationPrompt,
92
+ formatAcceptancePrompt,
93
+ shouldRunAcceptanceFinalization,
94
+ stripAcceptanceReport,
95
+ } from "../shared/acceptance.ts";
73
96
 
74
97
  interface SubagentRunConfig {
75
98
  id: string;
@@ -94,6 +117,8 @@ interface SubagentRunConfig {
94
117
  controlIntercomTarget?: string;
95
118
  childIntercomTargets?: Array<string | undefined>;
96
119
  resultMode?: SubagentRunMode;
120
+ dynamicFanoutMaxItems?: number;
121
+ workflowGraph?: WorkflowGraphSnapshot;
97
122
  nestedRoute?: NestedRouteInfo;
98
123
  nestedSelf?: { parentRunId: string; parentStepIndex?: number; depth: number; path?: Array<{ runId: string; stepIndex?: number; agent?: string }> };
99
124
  }
@@ -103,6 +128,7 @@ interface StepResult {
103
128
  output: string;
104
129
  error?: string;
105
130
  success: boolean;
131
+ exitCode?: number | null;
106
132
  skipped?: boolean;
107
133
  sessionFile?: string;
108
134
  intercomTarget?: string;
@@ -111,6 +137,11 @@ interface StepResult {
111
137
  modelAttempts?: ModelAttempt[];
112
138
  artifactPaths?: ArtifactPaths;
113
139
  truncated?: boolean;
140
+ structuredOutput?: unknown;
141
+ structuredOutputPath?: string;
142
+ structuredOutputSchemaPath?: string;
143
+ acceptance?: AcceptanceLedger;
144
+ resourceLimitExceeded?: ResourceLimitExceeded;
114
145
  }
115
146
 
116
147
  const ASYNC_INTERRUPT_SIGNAL: NodeJS.Signals = process.platform === "win32" ? "SIGBREAK" : "SIGUSR2";
@@ -205,6 +236,7 @@ interface RunPiStreamingResult {
205
236
  finalOutput: string;
206
237
  interrupted?: boolean;
207
238
  observedMutationAttempt?: boolean;
239
+ resourceLimitExceeded?: ResourceLimitExceeded;
208
240
  }
209
241
 
210
242
  function runPiStreaming(
@@ -218,6 +250,8 @@ function runPiStreaming(
218
250
  childEventContext?: ChildEventContext,
219
251
  registerInterrupt?: (interrupt: (() => void) | undefined) => void,
220
252
  onChildEvent?: (event: ChildEvent) => void,
253
+ maxExecutionTimeMs?: number,
254
+ maxTokens?: number,
221
255
  ): Promise<RunPiStreamingResult> {
222
256
  return new Promise((resolve) => {
223
257
  const outputStream = fs.createWriteStream(outputFile, { flags: "w" });
@@ -241,7 +275,10 @@ function runPiStreaming(
241
275
  let error: string | undefined;
242
276
  let assistantError: string | undefined;
243
277
  let interrupted = false;
278
+ let resourceLimitExceeded: ResourceLimitExceeded | undefined;
244
279
  let observedMutationAttempt = false;
280
+ let resourceLimitTimer: NodeJS.Timeout | undefined;
281
+ let resourceLimitEscalationTimer: NodeJS.Timeout | undefined;
245
282
  const rawStdoutLines: string[] = [];
246
283
 
247
284
  const writeOutputLine = (line: string) => {
@@ -255,6 +292,19 @@ function runPiStreaming(
255
292
  }
256
293
  };
257
294
 
295
+ const triggerResourceLimit = (kind: ResourceLimitExceeded["kind"], limit: number, observed?: number) => {
296
+ if (settled || resourceLimitExceeded) return;
297
+ const message = formatResourceLimitExceeded({ agent: childEventContext?.agent ?? "subagent", kind, limit, observed });
298
+ resourceLimitExceeded = { kind, limit, ...(observed !== undefined ? { observed } : {}), message };
299
+ error = message;
300
+ writeOutputLine(message);
301
+ trySignalChild(child, "SIGINT");
302
+ resourceLimitEscalationTimer = setTimeout(() => {
303
+ if (!settled) trySignalChild(child, "SIGTERM");
304
+ }, 1000);
305
+ resourceLimitEscalationTimer.unref?.();
306
+ };
307
+
258
308
  const appendChildEvent = (event: Record<string, unknown>) => {
259
309
  if (!childEventContext) return;
260
310
  appendJsonl(childEventContext.eventsPath, JSON.stringify({
@@ -309,6 +359,10 @@ function runPiStreaming(
309
359
  usage.cacheRead += eventUsage.cacheRead ?? 0;
310
360
  usage.cacheWrite += eventUsage.cacheWrite ?? 0;
311
361
  usage.cost += eventUsage.cost?.total ?? 0;
362
+ const observedTokens = usage.input + usage.output;
363
+ if (maxTokens !== undefined && observedTokens >= maxTokens) {
364
+ triggerResourceLimit("maxTokens", maxTokens, observedTokens);
365
+ }
312
366
  }
313
367
  const stopReason = (event.message as { stopReason?: string }).stopReason;
314
368
  const hasToolCall = Array.isArray(event.message.content)
@@ -344,6 +398,12 @@ function runPiStreaming(
344
398
  let finalDrainTimer: NodeJS.Timeout | undefined;
345
399
  let finalHardKillTimer: NodeJS.Timeout | undefined;
346
400
  let settled = false;
401
+ if (maxExecutionTimeMs !== undefined) {
402
+ resourceLimitTimer = setTimeout(() => {
403
+ triggerResourceLimit("maxExecutionTimeMs", maxExecutionTimeMs);
404
+ }, maxExecutionTimeMs);
405
+ resourceLimitTimer.unref?.();
406
+ }
347
407
  const clearStdioGuard = attachPostExitStdioGuard(child, { idleMs: 2000, hardMs: 8000 });
348
408
  child.stdout.on("data", (chunk: Buffer) => {
349
409
  const text = chunk.toString();
@@ -357,7 +417,7 @@ function runPiStreaming(
357
417
  processStderrText(chunk.toString());
358
418
  });
359
419
  registerInterrupt?.(() => {
360
- if (settled) return;
420
+ if (settled || resourceLimitExceeded) return;
361
421
  interrupted = true;
362
422
  if (!error) error = "Interrupted. Waiting for explicit next action.";
363
423
  trySignalChild(child, "SIGINT");
@@ -374,6 +434,14 @@ function runPiStreaming(
374
434
  clearTimeout(finalHardKillTimer);
375
435
  finalHardKillTimer = undefined;
376
436
  }
437
+ if (resourceLimitTimer) {
438
+ clearTimeout(resourceLimitTimer);
439
+ resourceLimitTimer = undefined;
440
+ }
441
+ if (resourceLimitEscalationTimer) {
442
+ clearTimeout(resourceLimitEscalationTimer);
443
+ resourceLimitEscalationTimer = undefined;
444
+ }
377
445
  };
378
446
  function startFinalDrain(): void {
379
447
  if (childExited || finalDrainTimer || settled) return;
@@ -405,12 +473,12 @@ function runPiStreaming(
405
473
  if (stdoutBuf.trim()) processStdoutLine(stdoutBuf);
406
474
  if (stderrBuf.trim()) appendChildLine("subagent.child.stderr", stderrBuf);
407
475
  outputStream.end();
408
- const finalOutput = getFinalOutput(messages) || rawStdoutLines.join("\n").trim();
409
- const finalError = error ?? assistantError;
476
+ const finalOutput = resourceLimitExceeded?.message ?? (getFinalOutput(messages) || rawStdoutLines.join("\n").trim());
477
+ const finalError = resourceLimitExceeded?.message ?? error ?? assistantError;
410
478
  const forcedDrainAfterFinalSuccess = forcedTerminationSignal && cleanTerminalAssistantStopReceived && !finalError;
411
479
  resolve({
412
480
  stderr,
413
- exitCode: interrupted || forcedDrainAfterFinalSuccess ? 0 : forcedTerminationSignal || signal ? (exitCode ?? 1) : exitCode,
481
+ exitCode: resourceLimitExceeded ? 1 : interrupted || forcedDrainAfterFinalSuccess ? 0 : forcedTerminationSignal || signal ? (exitCode ?? 1) : exitCode,
414
482
  messages,
415
483
  usage,
416
484
  model,
@@ -418,6 +486,7 @@ function runPiStreaming(
418
486
  finalOutput,
419
487
  interrupted,
420
488
  observedMutationAttempt,
489
+ resourceLimitExceeded,
421
490
  });
422
491
  });
423
492
 
@@ -427,9 +496,9 @@ function runPiStreaming(
427
496
  clearDrainTimers();
428
497
  clearStdioGuard();
429
498
  outputStream.end();
430
- const finalOutput = getFinalOutput(messages) || rawStdoutLines.join("\n").trim();
499
+ const finalOutput = resourceLimitExceeded?.message ?? (getFinalOutput(messages) || rawStdoutLines.join("\n").trim());
431
500
  const spawnErrorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
432
- resolve({ stderr, exitCode: 1, messages, usage, model, error: error ?? assistantError ?? spawnErrorMessage, finalOutput, observedMutationAttempt });
501
+ resolve({ stderr, exitCode: 1, messages, usage, model, error: resourceLimitExceeded?.message ?? error ?? assistantError ?? spawnErrorMessage, finalOutput, observedMutationAttempt, resourceLimitExceeded });
433
502
  });
434
503
  });
435
504
  }
@@ -543,6 +612,7 @@ function writeRunLog(
543
612
  /** Context for running a single step */
544
613
  interface SingleStepContext {
545
614
  previousOutput: string;
615
+ outputs?: ChainOutputMap;
546
616
  placeholder: string;
547
617
  cwd: string;
548
618
  sessionEnabled: boolean;
@@ -580,9 +650,23 @@ async function runSingleStep(
580
650
  sessionFile?: string;
581
651
  intercomTarget?: string;
582
652
  completionGuardTriggered?: boolean;
653
+ structuredOutput?: unknown;
654
+ structuredOutputPath?: string;
655
+ structuredOutputSchemaPath?: string;
656
+ acceptance?: AcceptanceLedger;
657
+ resourceLimitExceeded?: ResourceLimitExceeded;
583
658
  }> {
659
+ const effectiveStructuredOutput = step.structuredOutput ?? (step.structuredOutputSchema
660
+ ? createStructuredOutputRuntime(step.structuredOutputSchema, path.join(path.dirname(ctx.outputFile), "structured-output"))
661
+ : undefined);
584
662
  const placeholderRegex = new RegExp(ctx.placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g");
585
- const task = step.task.replace(placeholderRegex, () => ctx.previousOutput);
663
+ let task = step.task.replace(placeholderRegex, () => ctx.previousOutput);
664
+ task = resolveOutputReferences(task, ctx.outputs ?? {});
665
+ const taskForCompletionGuard = task;
666
+ if (step.effectiveAcceptance) {
667
+ const acceptancePrompt = formatAcceptancePrompt(step.effectiveAcceptance);
668
+ if (acceptancePrompt) task = `${task}\n${acceptancePrompt}`;
669
+ }
586
670
  const sessionEnabled = Boolean(step.sessionFile) || ctx.sessionEnabled;
587
671
  const sessionDir = step.sessionFile ? undefined : ctx.sessionDir;
588
672
 
@@ -613,6 +697,13 @@ async function runSingleStep(
613
697
  const candidate = candidates[index];
614
698
  ctx.onAttemptStart?.({ model: candidate, thinking: resolveEffectiveThinking(candidate, step.thinking) });
615
699
  const outputSnapshot = captureSingleOutputSnapshot(step.outputPath);
700
+ if (effectiveStructuredOutput) {
701
+ try {
702
+ if (fs.existsSync(effectiveStructuredOutput.outputPath)) fs.unlinkSync(effectiveStructuredOutput.outputPath);
703
+ } catch {
704
+ // Missing/stale structured-output files are handled after the child exits.
705
+ }
706
+ }
616
707
  const { args, env, tempDir } = buildPiArgs({
617
708
  baseArgs: ["--mode", "json", "-p"],
618
709
  task,
@@ -638,6 +729,7 @@ async function runSingleStep(
638
729
  parentControlInbox: ctx.nestedRoute?.controlInbox,
639
730
  parentRootRunId: ctx.nestedRoute?.rootRunId,
640
731
  parentCapabilityToken: ctx.nestedRoute?.capabilityToken,
732
+ structuredOutput: effectiveStructuredOutput,
641
733
  });
642
734
  const run = await runPiStreaming(
643
735
  args,
@@ -650,14 +742,35 @@ async function runSingleStep(
650
742
  { eventsPath, runId: ctx.id, stepIndex: ctx.flatIndex, agent: step.agent },
651
743
  ctx.registerInterrupt,
652
744
  ctx.onChildEvent,
745
+ step.maxExecutionTimeMs,
746
+ step.maxTokens,
653
747
  );
654
748
  cleanupTempDir(tempDir);
655
749
 
656
750
  const hiddenError = run.exitCode === 0 && !run.error ? detectSubagentError(run.messages) : null;
657
- const completionGuard = run.exitCode === 0 && !run.error && !hiddenError?.hasError && step.completionGuard !== false
751
+ let structuredOutput: unknown;
752
+ let structuredError: string | undefined;
753
+ if (effectiveStructuredOutput && run.exitCode === 0 && !run.error && !hiddenError?.hasError) {
754
+ const structured = readStructuredOutput({
755
+ schema: effectiveStructuredOutput.schema,
756
+ schemaPath: effectiveStructuredOutput.schemaPath,
757
+ outputPath: effectiveStructuredOutput.outputPath,
758
+ });
759
+ if (structured.error) structuredError = structured.error;
760
+ else structuredOutput = structured.value;
761
+ }
762
+ const completionPolicy = resolveCompletionPolicy({
763
+ agent: step.agent,
764
+ task: taskForCompletionGuard,
765
+ completionGuardEnabled: step.completionGuard !== false,
766
+ usesAcceptanceContract: step.effectiveAcceptance?.explicit === true,
767
+ tools: step.tools,
768
+ mcpDirectTools: step.mcpDirectTools,
769
+ });
770
+ const completionGuard = run.exitCode === 0 && !run.error && !hiddenError?.hasError && completionPolicy === "mutation-guard"
658
771
  ? evaluateCompletionMutationGuard({
659
772
  agent: step.agent,
660
- task,
773
+ task: taskForCompletionGuard,
661
774
  messages: run.messages,
662
775
  tools: step.tools,
663
776
  mcpDirectTools: step.mcpDirectTools,
@@ -667,14 +780,17 @@ async function runSingleStep(
667
780
  const completionGuardError = completionGuardTriggered
668
781
  ? "Subagent completed without making edits for an implementation task.\nIt appears to have returned planning or scratchpad output instead of applying changes."
669
782
  : undefined;
670
- const effectiveExitCode = completionGuardTriggered
783
+ const effectiveExitCode = completionGuardError
671
784
  ? 1
672
- : hiddenError?.hasError
785
+ : structuredError
786
+ ? 1
787
+ : hiddenError?.hasError
673
788
  ? (hiddenError.exitCode ?? 1)
674
789
  : run.error && run.exitCode === 0
675
790
  ? 1
676
791
  : run.exitCode;
677
792
  const error = completionGuardError
793
+ ?? structuredError
678
794
  ?? (hiddenError?.hasError
679
795
  ? hiddenError.details
680
796
  ? `${hiddenError.errorType} failed (exit ${effectiveExitCode}): ${hiddenError.details}`
@@ -691,22 +807,24 @@ async function runSingleStep(
691
807
  if (candidate) attemptedModels.push(candidate);
692
808
  completionGuardTriggeredFinal = completionGuardTriggered;
693
809
  finalOutputSnapshot = outputSnapshot;
694
- finalResult = { ...run, exitCode: effectiveExitCode, model: candidate ?? run.model, error };
695
- if (attempt.success || completionGuardTriggered) break;
696
- if (!isRetryableModelFailure(error) || index === candidates.length - 1) break;
810
+ finalResult = { ...run, exitCode: effectiveExitCode, model: candidate ?? run.model, error, structuredOutput } as RunPiStreamingResult & { structuredOutput?: unknown };
811
+ if (attempt.success || completionGuardError) break;
812
+ if (run.resourceLimitExceeded || !isRetryableModelFailure(error) || index === candidates.length - 1) break;
697
813
  attemptNotes.push(formatModelAttemptNote(attempt, candidates[index + 1]));
698
814
  }
699
815
 
700
816
  const rawOutput = finalResult?.finalOutput ?? "";
817
+ const outputForPersistence = stripAcceptanceReport(rawOutput);
701
818
  const resolvedOutput = step.outputPath && finalResult?.exitCode === 0
702
- ? resolveSingleOutput(step.outputPath, rawOutput, finalOutputSnapshot)
703
- : { fullOutput: rawOutput };
819
+ ? resolveSingleOutput(step.outputPath, outputForPersistence, finalOutputSnapshot)
820
+ : { fullOutput: outputForPersistence };
704
821
  const output = resolvedOutput.fullOutput;
705
822
  const outputReference = resolvedOutput.savedPath ? formatSavedOutputReference(resolvedOutput.savedPath, output) : undefined;
706
823
  let outputForSummary = output;
707
824
  if (attemptNotes.length > 0) {
708
825
  outputForSummary = `${attemptNotes.join("\n")}\n\n${outputForSummary}`.trim();
709
826
  }
827
+ const outputForAcceptance = rawOutput;
710
828
  const finalizedOutput = finalizeSingleOutput({
711
829
  fullOutput: outputForSummary,
712
830
  outputPath: step.outputPath,
@@ -717,6 +835,123 @@ async function runSingleStep(
717
835
  saveError: resolvedOutput.saveError,
718
836
  });
719
837
  outputForSummary = finalizedOutput.displayOutput;
838
+ const acceptanceForInitialReport = step.effectiveAcceptance && shouldRunAcceptanceFinalization(step.effectiveAcceptance)
839
+ ? acceptanceSelfReviewConfig(step.effectiveAcceptance)
840
+ : step.effectiveAcceptance;
841
+ let acceptance = acceptanceForInitialReport
842
+ ? await evaluateAcceptance({
843
+ acceptance: acceptanceForInitialReport,
844
+ output: outputForAcceptance,
845
+ cwd: step.cwd ?? ctx.cwd,
846
+ })
847
+ : undefined;
848
+ if (acceptance && step.effectiveAcceptance && shouldRunAcceptanceFinalization(step.effectiveAcceptance) && (finalResult?.exitCode ?? 1) === 0 && !finalResult?.interrupted) {
849
+ const sessionFile = step.sessionFile ?? (sessionDir ? findLatestSessionFile(sessionDir) ?? undefined : undefined);
850
+ const maxTurns = step.effectiveAcceptance.finalization.maxTurns;
851
+ const turns: AcceptanceFinalizationTurn[] = [];
852
+ if (!sessionFile) {
853
+ const message = "Acceptance finalization requires a session file for same-session continuation.";
854
+ turns.push(createFinalizationProcessFailureTurn({ turn: 1, prompt: "", message }));
855
+ acceptance = buildFinalizationProcessFailureLedger({ initialLedger: acceptance, turns, maxTurns, message });
856
+ } else {
857
+ const selfReviewAcceptance = acceptanceSelfReviewConfig(step.effectiveAcceptance);
858
+ let previousFailure = acceptanceFailureMessage(acceptance);
859
+ let authoritativeLedger = acceptance;
860
+ for (let turn = 1; turn <= maxTurns; turn++) {
861
+ const prompt = formatAcceptanceFinalizationPrompt({
862
+ acceptance: step.effectiveAcceptance,
863
+ initialOutput: outputForAcceptance,
864
+ initialLedger: acceptance,
865
+ turn,
866
+ maxTurns,
867
+ ...(previousFailure ? { previousFailure } : {}),
868
+ });
869
+ const { args, env, tempDir } = buildPiArgs({
870
+ baseArgs: ["--mode", "json", "-p"],
871
+ task: prompt,
872
+ sessionEnabled: true,
873
+ sessionFile,
874
+ model: finalResult?.model ?? step.model,
875
+ thinking: step.thinking,
876
+ inheritProjectContext: step.inheritProjectContext,
877
+ inheritSkills: step.inheritSkills,
878
+ tools: step.tools,
879
+ extensions: step.extensions,
880
+ systemPrompt: step.systemPrompt,
881
+ systemPromptMode: step.systemPromptMode,
882
+ mcpDirectTools: step.mcpDirectTools,
883
+ cwd: step.cwd ?? ctx.cwd,
884
+ promptFileStem: `${step.agent}-acceptance-finalization`,
885
+ intercomSessionName: ctx.childIntercomTarget,
886
+ orchestratorIntercomTarget: ctx.orchestratorIntercomTarget,
887
+ runId: ctx.id,
888
+ childAgentName: step.agent,
889
+ childIndex: ctx.flatIndex,
890
+ parentEventSink: ctx.nestedRoute?.eventSink,
891
+ parentControlInbox: ctx.nestedRoute?.controlInbox,
892
+ parentRootRunId: ctx.nestedRoute?.rootRunId,
893
+ parentCapabilityToken: ctx.nestedRoute?.capabilityToken,
894
+ });
895
+ ctx.onAttemptStart?.({ model: finalResult?.model ?? step.model, thinking: resolveEffectiveThinking(finalResult?.model ?? step.model, step.thinking) });
896
+ const finalizationRun = await runPiStreaming(
897
+ args,
898
+ step.cwd ?? ctx.cwd,
899
+ `${ctx.outputFile}.finalization-${turn}.log`,
900
+ env,
901
+ ctx.piPackageRoot,
902
+ ctx.piArgv1,
903
+ step.maxSubagentDepth,
904
+ { eventsPath, runId: ctx.id, stepIndex: ctx.flatIndex, agent: step.agent },
905
+ ctx.registerInterrupt,
906
+ ctx.onChildEvent,
907
+ step.maxExecutionTimeMs,
908
+ step.maxTokens,
909
+ );
910
+ cleanupTempDir(tempDir);
911
+ modelAttempts.push({
912
+ model: finalResult?.model ?? finalizationRun.model ?? step.model ?? "default",
913
+ success: finalizationRun.exitCode === 0 && !finalizationRun.error,
914
+ exitCode: finalizationRun.exitCode,
915
+ error: finalizationRun.error,
916
+ usage: finalizationRun.usage,
917
+ });
918
+ const finalizationOutput = finalizationRun.finalOutput;
919
+ if (finalizationRun.exitCode !== 0 || finalizationRun.error || finalizationRun.interrupted) {
920
+ const message = finalizationRun.error ?? "Acceptance finalization turn did not complete successfully.";
921
+ turns.push(createFinalizationProcessFailureTurn({ turn, prompt, rawOutput: finalizationOutput, message }));
922
+ acceptance = buildFinalizationProcessFailureLedger({ initialLedger: acceptance, turns, maxTurns, message });
923
+ break;
924
+ }
925
+ const selfReviewLedger = await evaluateAcceptance({
926
+ acceptance: selfReviewAcceptance,
927
+ output: finalizationOutput,
928
+ cwd: step.cwd ?? ctx.cwd,
929
+ });
930
+ authoritativeLedger = selfReviewLedger;
931
+ turns.push(createFinalizationTurn({ turn, prompt, rawOutput: finalizationOutput, ledger: selfReviewLedger }));
932
+ const failure = acceptanceFailureMessage(selfReviewLedger);
933
+ if (!failure) {
934
+ authoritativeLedger = step.effectiveAcceptance === selfReviewAcceptance
935
+ ? selfReviewLedger
936
+ : await evaluateAcceptance({
937
+ acceptance: step.effectiveAcceptance,
938
+ output: finalizationOutput,
939
+ cwd: step.cwd ?? ctx.cwd,
940
+ });
941
+ acceptance = attachFinalizationToLedger({ initialLedger: acceptance, authoritativeLedger, turns, status: "completed", maxTurns });
942
+ break;
943
+ }
944
+ previousFailure = failure;
945
+ if (turn === maxTurns) acceptance = attachFinalizationToLedger({ initialLedger: acceptance, authoritativeLedger, turns, status: "failed", maxTurns });
946
+ }
947
+ }
948
+ }
949
+ const acceptanceFailure = acceptance ? acceptanceFailureMessage(acceptance) : undefined;
950
+ const acceptanceCanFailRun = acceptanceFailure && acceptance?.explicit && (finalResult?.exitCode ?? 1) === 0 && !finalResult?.interrupted;
951
+ const effectiveFinalExitCode = acceptanceCanFailRun ? 1 : finalResult?.exitCode ?? 1;
952
+ const effectiveFinalError = acceptanceCanFailRun
953
+ ? (finalResult?.error ? `${finalResult.error}\n${acceptanceFailure}` : acceptanceFailure)
954
+ : finalResult?.error;
720
955
 
721
956
  if (artifactPaths && ctx.artifactConfig?.enabled !== false) {
722
957
  if (ctx.artifactConfig?.includeOutput !== false) {
@@ -729,10 +964,11 @@ async function runSingleStep(
729
964
  runId: ctx.id,
730
965
  agent: step.agent,
731
966
  task,
732
- exitCode: finalResult?.exitCode,
967
+ exitCode: effectiveFinalExitCode,
733
968
  model: finalResult?.model,
734
969
  attemptedModels: attemptedModels.length > 0 ? attemptedModels : undefined,
735
970
  modelAttempts,
971
+ resourceLimitExceeded: finalResult?.resourceLimitExceeded,
736
972
  skills: step.skills,
737
973
  timestamp: Date.now(),
738
974
  }, null, 2),
@@ -744,8 +980,8 @@ async function runSingleStep(
744
980
  return {
745
981
  agent: step.agent,
746
982
  output: outputForSummary,
747
- exitCode: finalResult?.exitCode ?? 1,
748
- error: finalResult?.error,
983
+ exitCode: effectiveFinalExitCode,
984
+ error: effectiveFinalError,
749
985
  sessionFile: step.sessionFile,
750
986
  intercomTarget: ctx.childIntercomTarget,
751
987
  model: finalResult?.model,
@@ -754,6 +990,11 @@ async function runSingleStep(
754
990
  artifactPaths,
755
991
  interrupted: finalResult?.interrupted,
756
992
  completionGuardTriggered: completionGuardTriggeredFinal,
993
+ structuredOutput: (finalResult as (RunPiStreamingResult & { structuredOutput?: unknown }) | undefined)?.structuredOutput,
994
+ structuredOutputPath: effectiveStructuredOutput?.outputPath,
995
+ structuredOutputSchemaPath: effectiveStructuredOutput?.schemaPath,
996
+ acceptance,
997
+ resourceLimitExceeded: finalResult?.resourceLimitExceeded,
757
998
  };
758
999
  }
759
1000
 
@@ -796,7 +1037,7 @@ function markParallelGroupSetupFailure(input: {
796
1037
  input.statusPayload.steps[flatTaskIndex].endedAt = input.failedAt;
797
1038
  input.statusPayload.steps[flatTaskIndex].durationMs = 0;
798
1039
  input.statusPayload.steps[flatTaskIndex].exitCode = 1;
799
- input.results.push({ agent: input.group.parallel[taskIndex].agent, output: input.setupError, success: false, sessionFile: input.group.parallel[taskIndex].sessionFile });
1040
+ input.results.push({ agent: input.group.parallel[taskIndex].agent, output: input.setupError, success: false, exitCode: 1, sessionFile: input.group.parallel[taskIndex].sessionFile });
800
1041
  }
801
1042
  input.statusPayload.currentStep = input.groupStartFlatIndex;
802
1043
  input.statusPayload.lastUpdate = input.failedAt;
@@ -886,6 +1127,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
886
1127
  const { id, steps, resultPath, cwd, placeholder, taskIndex, totalTasks, maxOutput, artifactsDir, artifactConfig } =
887
1128
  config;
888
1129
  let previousOutput = "";
1130
+ const outputs: ChainOutputMap = {};
889
1131
  const results: StepResult[] = [];
890
1132
  const overallStartTime = Date.now();
891
1133
  const shareEnabled = config.share === true;
@@ -902,13 +1144,59 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
902
1144
  let latestSessionFile: string | undefined;
903
1145
 
904
1146
  const parallelGroups: Array<{ start: number; count: number; stepIndex: number }> = [];
1147
+ const initialStatusSteps: RunnerStatusStep[] = [];
905
1148
  let flatStepCount = 0;
906
1149
  for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) {
907
1150
  const step = steps[stepIndex]!;
908
1151
  if (isParallelGroup(step)) {
909
1152
  parallelGroups.push({ start: flatStepCount, count: step.parallel.length, stepIndex });
1153
+ for (const task of step.parallel) {
1154
+ initialStatusSteps.push({
1155
+ agent: task.agent,
1156
+ phase: task.phase,
1157
+ label: task.label,
1158
+ outputName: task.outputName,
1159
+ structured: task.structured,
1160
+ status: "pending",
1161
+ ...(task.sessionFile ? { sessionFile: task.sessionFile } : {}),
1162
+ skills: task.skills,
1163
+ model: task.model,
1164
+ thinking: task.thinking,
1165
+ attemptedModels: task.modelCandidates && task.modelCandidates.length > 0 ? task.modelCandidates : task.model ? [task.model] : undefined,
1166
+ recentTools: [],
1167
+ recentOutput: [],
1168
+ });
1169
+ }
910
1170
  flatStepCount += step.parallel.length;
1171
+ } else if (isDynamicRunnerGroup(step)) {
1172
+ parallelGroups.push({ start: flatStepCount, count: 1, stepIndex });
1173
+ initialStatusSteps.push({
1174
+ agent: `expand:${step.parallel.agent}`,
1175
+ phase: step.phase ?? step.parallel.phase,
1176
+ label: step.label ?? step.parallel.label ?? `Dynamic fanout (${step.collect.as})`,
1177
+ outputName: step.collect.as,
1178
+ structured: Boolean(step.collect.outputSchema),
1179
+ status: "pending",
1180
+ recentTools: [],
1181
+ recentOutput: [],
1182
+ });
1183
+ flatStepCount++;
911
1184
  } else {
1185
+ initialStatusSteps.push({
1186
+ agent: step.agent,
1187
+ phase: step.phase,
1188
+ label: step.label,
1189
+ outputName: step.outputName,
1190
+ structured: step.structured,
1191
+ status: "pending",
1192
+ ...(step.sessionFile ? { sessionFile: step.sessionFile } : {}),
1193
+ skills: step.skills,
1194
+ model: step.model,
1195
+ thinking: step.thinking,
1196
+ attemptedModels: step.modelCandidates && step.modelCandidates.length > 0 ? step.modelCandidates : step.model ? [step.model] : undefined,
1197
+ recentTools: [],
1198
+ recentOutput: [],
1199
+ });
912
1200
  flatStepCount++;
913
1201
  }
914
1202
  }
@@ -929,17 +1217,8 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
929
1217
  currentStep: 0,
930
1218
  chainStepCount: steps.length,
931
1219
  parallelGroups,
932
- steps: flatSteps.map((step) => ({
933
- agent: step.agent,
934
- status: "pending",
935
- ...(step.sessionFile ? { sessionFile: step.sessionFile } : {}),
936
- skills: step.skills,
937
- model: step.model,
938
- thinking: step.thinking,
939
- attemptedModels: step.modelCandidates && step.modelCandidates.length > 0 ? step.modelCandidates : step.model ? [step.model] : undefined,
940
- recentTools: [],
941
- recentOutput: [],
942
- })),
1220
+ workflowGraph: config.workflowGraph,
1221
+ steps: initialStatusSteps,
943
1222
  artifactsDir,
944
1223
  sessionDir: config.sessionDir,
945
1224
  outputFile: path.join(asyncDir, "output-0.log"),
@@ -969,10 +1248,48 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
969
1248
  console.error("Failed to emit nested async status event:", error);
970
1249
  }
971
1250
  };
1251
+ const refreshWorkflowGraph = (): void => {
1252
+ if (!config.workflowGraph) return;
1253
+ const graph = structuredClone(statusPayload.workflowGraph ?? config.workflowGraph);
1254
+ const normalize = (status: RunnerStatusStep["status"]): "pending" | "running" | "completed" | "failed" | "paused" | "detached" => {
1255
+ if (status === "complete" || status === "completed") return "completed";
1256
+ if (status === "running" || status === "failed" || status === "paused" || status === "pending") return status;
1257
+ return "pending";
1258
+ };
1259
+ const updateNode = (node: NonNullable<typeof graph.nodes>[number]): void => {
1260
+ if (node.flatIndex !== undefined) {
1261
+ const step = statusPayload.steps[node.flatIndex];
1262
+ if (step) {
1263
+ node.status = normalize(step.status);
1264
+ node.error = step.error;
1265
+ node.acceptanceStatus = step.acceptance?.status;
1266
+ }
1267
+ if (statusPayload.currentStep === node.flatIndex) graph.currentNodeId = node.id;
1268
+ }
1269
+ for (const child of node.children ?? []) updateNode(child);
1270
+ if (node.children?.length) {
1271
+ if (node.children.every((child) => child.status === "completed")) node.status = "completed";
1272
+ else if (node.children.some((child) => child.status === "running")) node.status = "running";
1273
+ else if (node.children.some((child) => child.status === "failed")) node.status = "failed";
1274
+ else if (node.children.some((child) => child.status === "paused")) node.status = "paused";
1275
+ }
1276
+ if (node.error) node.status = "failed";
1277
+ };
1278
+ for (const node of graph.nodes) updateNode(node);
1279
+ statusPayload.workflowGraph = graph;
1280
+ };
972
1281
  const writeStatusPayload = (): void => {
1282
+ refreshWorkflowGraph();
973
1283
  writeAtomicJson(statusPath, statusPayload);
974
1284
  emitNestedSelfEvent(statusPayload.state === "running" || statusPayload.state === "queued" ? "subagent.nested.updated" : "subagent.nested.completed");
975
1285
  };
1286
+ const markDynamicGraphGroup = (stepIndex: number, status: "completed" | "failed" | "running", error?: string, acceptance?: AcceptanceLedger): void => {
1287
+ const groupNode = statusPayload.workflowGraph?.nodes.find((node) => node.id === `step-${stepIndex}`);
1288
+ if (!groupNode) return;
1289
+ groupNode.status = status;
1290
+ groupNode.error = error;
1291
+ groupNode.acceptanceStatus = acceptance?.status ?? groupNode.acceptanceStatus;
1292
+ };
976
1293
 
977
1294
  const stepOutputActivityAt = (index: number): number => {
978
1295
  const step = statusPayload.steps[index];
@@ -989,8 +1306,8 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
989
1306
  };
990
1307
  const emittedControlEventKeys = new Set<string>();
991
1308
  const activeLongRunningSteps = new Set<number>();
992
- const mutatingFailureStates = flatSteps.map(() => createMutatingFailureState());
993
- const pendingToolResults: Array<{ tool: string; path?: string; mutates: boolean; startedAt?: number } | undefined> = [];
1309
+ const mutatingFailureStates = initialStatusSteps.map(() => createMutatingFailureState());
1310
+ const pendingToolResults: Array<{ tool: string; path?: string; mutates: boolean; startedAt?: number } | undefined> = initialStatusSteps.map(() => undefined);
994
1311
  const mutatingFailureWindowMs = 5 * 60_000;
995
1312
  const appendControlEvent = (event: ReturnType<typeof buildControlEvent>) => {
996
1313
  if (!controlConfig.enabled) return;
@@ -1131,7 +1448,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1131
1448
  resetMutatingFailureState(mutatingFailureStates[flatIndex]!);
1132
1449
  }
1133
1450
  } else if (event.type === "message_end" && event.message?.role === "assistant") {
1134
- appendRecentStepOutput(step, extractTextFromContent(event.message.content).split("\n").slice(-10));
1451
+ appendRecentStepOutput(step, stripAcceptanceReport(extractTextFromContent(event.message.content)).split("\n").slice(-10));
1135
1452
  step.turnCount = (step.turnCount ?? 0) + 1;
1136
1453
  const usage = event.message.usage;
1137
1454
  if (usage) {
@@ -1262,6 +1579,265 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1262
1579
  if (interrupted) break;
1263
1580
  const step = steps[stepIndex];
1264
1581
 
1582
+ if (isDynamicRunnerGroup(step)) {
1583
+ const groupStartFlatIndex = flatIndex;
1584
+ let materialized: ReturnType<typeof materializeDynamicParallelStep>;
1585
+ try {
1586
+ materialized = materializeDynamicParallelStep(step as Parameters<typeof materializeDynamicParallelStep>[0], outputs, stepIndex, { maxItems: config.dynamicFanoutMaxItems, allowRunnerFields: true });
1587
+ if (materialized.collectedOnEmpty) validateDynamicCollection(step.collect.outputSchema, materialized.collectedOnEmpty);
1588
+ } catch (error) {
1589
+ const now = Date.now();
1590
+ const message = error instanceof DynamicFanoutError ? error.message : error instanceof Error ? error.message : String(error);
1591
+ statusPayload.state = "failed";
1592
+ statusPayload.error = message;
1593
+ statusPayload.currentStep = flatIndex;
1594
+ const placeholder = statusPayload.steps[groupStartFlatIndex];
1595
+ if (placeholder) {
1596
+ placeholder.status = "failed";
1597
+ placeholder.error = message;
1598
+ placeholder.startedAt = now;
1599
+ placeholder.endedAt = now;
1600
+ placeholder.durationMs = 0;
1601
+ placeholder.exitCode = 1;
1602
+ }
1603
+ statusPayload.lastUpdate = now;
1604
+ markDynamicGraphGroup(stepIndex, "failed", message);
1605
+ writeStatusPayload();
1606
+ results.push({ agent: step.parallel.agent, output: message, error: message, success: false, exitCode: 1 });
1607
+ break;
1608
+ }
1609
+
1610
+ if (materialized.parallel.length === 0) {
1611
+ const now = Date.now();
1612
+ const collection = materialized.collectedOnEmpty ?? [];
1613
+ outputs[step.collect.as] = {
1614
+ text: JSON.stringify(collection),
1615
+ structured: collection,
1616
+ agent: step.parallel.agent,
1617
+ stepIndex,
1618
+ };
1619
+ statusPayload.outputs = outputs;
1620
+ const placeholder = statusPayload.steps[groupStartFlatIndex];
1621
+ if (placeholder) {
1622
+ placeholder.status = "complete";
1623
+ placeholder.startedAt = now;
1624
+ placeholder.endedAt = now;
1625
+ placeholder.durationMs = 0;
1626
+ }
1627
+ previousOutput = "Dynamic fanout produced 0 results.";
1628
+ flatIndex++;
1629
+ statusPayload.lastUpdate = now;
1630
+ markDynamicGraphGroup(stepIndex, "completed");
1631
+ writeStatusPayload();
1632
+ continue;
1633
+ }
1634
+
1635
+ const dynamicSteps = materialized.parallel.map((task, itemIndex) => ({
1636
+ ...step.parallel,
1637
+ task: task.task ?? step.parallel.task,
1638
+ label: task.label ?? step.parallel.label,
1639
+ structuredOutput: undefined,
1640
+ structuredOutputSchema: step.parallel.structuredOutputSchema ?? step.parallel.structuredOutput?.schema,
1641
+ }));
1642
+ const dynamicStatusSteps: RunnerStatusStep[] = dynamicSteps.map((task) => ({
1643
+ agent: task.agent,
1644
+ phase: task.phase ?? step.phase,
1645
+ label: task.label,
1646
+ outputName: undefined,
1647
+ structured: Boolean(task.structuredOutputSchema),
1648
+ status: "pending",
1649
+ ...(task.sessionFile ? { sessionFile: task.sessionFile } : {}),
1650
+ skills: task.skills,
1651
+ model: task.model,
1652
+ thinking: task.thinking,
1653
+ attemptedModels: task.modelCandidates && task.modelCandidates.length > 0 ? task.modelCandidates : task.model ? [task.model] : undefined,
1654
+ recentTools: [],
1655
+ recentOutput: [],
1656
+ }));
1657
+ statusPayload.steps.splice(groupStartFlatIndex, 1, ...dynamicStatusSteps);
1658
+ if (config.childIntercomTargets) {
1659
+ config.childIntercomTargets = statusPayload.steps.map((statusStep, index) => resolveSubagentIntercomTarget(id, statusStep.agent, index));
1660
+ }
1661
+ mutatingFailureStates.splice(groupStartFlatIndex, 1, ...dynamicStatusSteps.map(() => createMutatingFailureState()));
1662
+ pendingToolResults.splice(groupStartFlatIndex, 1, ...dynamicStatusSteps.map(() => undefined));
1663
+ const materializedDelta = dynamicStatusSteps.length - 1;
1664
+ for (const group of statusPayload.parallelGroups) {
1665
+ if (group.stepIndex === stepIndex) {
1666
+ group.start = groupStartFlatIndex;
1667
+ group.count = dynamicStatusSteps.length;
1668
+ } else if (group.start > groupStartFlatIndex) {
1669
+ group.start += materializedDelta;
1670
+ }
1671
+ }
1672
+ if (statusPayload.workflowGraph) {
1673
+ const shiftFlatIndexes = (nodes: NonNullable<typeof statusPayload.workflowGraph>["nodes"]): void => {
1674
+ for (const node of nodes) {
1675
+ if (node.stepIndex !== undefined && node.stepIndex > stepIndex && node.flatIndex !== undefined && node.flatIndex >= groupStartFlatIndex) {
1676
+ node.flatIndex += dynamicStatusSteps.length;
1677
+ }
1678
+ if (node.children) shiftFlatIndexes(node.children);
1679
+ }
1680
+ };
1681
+ shiftFlatIndexes(statusPayload.workflowGraph.nodes);
1682
+ const groupNode = statusPayload.workflowGraph.nodes.find((node) => node.id === `step-${stepIndex}`);
1683
+ if (groupNode) {
1684
+ groupNode.children = materialized.items.map((item, itemIndex) => ({
1685
+ id: `step-${stepIndex}-item-${item.idKey}`,
1686
+ kind: "agent",
1687
+ agent: step.parallel.agent,
1688
+ phase: dynamicSteps[itemIndex]?.phase ?? step.phase,
1689
+ label: dynamicSteps[itemIndex]?.label?.trim() || `${step.parallel.agent} ${item.key}`,
1690
+ status: "pending",
1691
+ flatIndex: groupStartFlatIndex + itemIndex,
1692
+ stepIndex,
1693
+ itemKey: item.key,
1694
+ structured: Boolean(dynamicSteps[itemIndex]?.structuredOutputSchema),
1695
+ }));
1696
+ }
1697
+ }
1698
+ writeStatusPayload();
1699
+
1700
+ const concurrency = step.concurrency ?? MAX_PARALLEL_CONCURRENCY;
1701
+ const failFast = step.failFast ?? false;
1702
+ let aborted = false;
1703
+ const parallelResults = await mapConcurrent(dynamicSteps, concurrency, async (task, taskIdx) => {
1704
+ const fi = groupStartFlatIndex + taskIdx;
1705
+ if (aborted && failFast) {
1706
+ const skippedAt = Date.now();
1707
+ statusPayload.steps[fi].status = "failed";
1708
+ statusPayload.steps[fi].error = "Skipped due to fail-fast";
1709
+ statusPayload.steps[fi].startedAt = skippedAt;
1710
+ statusPayload.steps[fi].endedAt = skippedAt;
1711
+ statusPayload.steps[fi].durationMs = 0;
1712
+ statusPayload.steps[fi].exitCode = -1;
1713
+ statusPayload.lastUpdate = skippedAt;
1714
+ writeStatusPayload();
1715
+ return { agent: task.agent, output: "(skipped — fail-fast)", exitCode: -1 as number | null, skipped: true };
1716
+ }
1717
+ const taskStartTime = Date.now();
1718
+ statusPayload.currentStep = fi;
1719
+ statusPayload.steps[fi].status = "running";
1720
+ statusPayload.steps[fi].error = undefined;
1721
+ statusPayload.steps[fi].activityState = undefined;
1722
+ resetStepLiveDetail(statusPayload.steps[fi]);
1723
+ statusPayload.steps[fi].startedAt = taskStartTime;
1724
+ statusPayload.steps[fi].lastActivityAt = taskStartTime;
1725
+ statusPayload.outputFile = path.join(asyncDir, `output-${fi}.log`);
1726
+ statusPayload.lastActivityAt = taskStartTime;
1727
+ statusPayload.lastUpdate = taskStartTime;
1728
+ writeStatusPayload();
1729
+ appendJsonl(eventsPath, JSON.stringify({ type: "subagent.step.started", ts: taskStartTime, runId: id, stepIndex: fi, agent: task.agent }));
1730
+ const singleResult = await runSingleStep(task, {
1731
+ previousOutput, placeholder, cwd, sessionEnabled,
1732
+ outputs,
1733
+ sessionDir: config.sessionDir ? path.join(config.sessionDir, `dynamic-${stepIndex}-${taskIdx}`) : undefined,
1734
+ artifactsDir, artifactConfig, id,
1735
+ flatIndex: fi, flatStepCount: Math.max(statusPayload.steps.length, 1),
1736
+ outputFile: path.join(asyncDir, `output-${fi}.log`),
1737
+ piPackageRoot: config.piPackageRoot,
1738
+ piArgv1: config.piArgv1,
1739
+ childIntercomTarget: config.childIntercomTargets?.[fi],
1740
+ orchestratorIntercomTarget: config.controlIntercomTarget,
1741
+ nestedRoute: config.nestedRoute,
1742
+ registerInterrupt: (interrupt) => {
1743
+ activeChildInterrupt = interrupt;
1744
+ },
1745
+ onAttemptStart: (attempt) => updateStepModel(fi, attempt.model, attempt.thinking),
1746
+ onChildEvent: (event) => updateStepFromChildEvent(fi, event),
1747
+ });
1748
+ const taskEndTime = Date.now();
1749
+ statusPayload.steps[fi].status = singleResult.exitCode === 0 ? "complete" : "failed";
1750
+ statusPayload.steps[fi].endedAt = taskEndTime;
1751
+ statusPayload.steps[fi].durationMs = taskEndTime - taskStartTime;
1752
+ statusPayload.steps[fi].exitCode = singleResult.exitCode;
1753
+ statusPayload.steps[fi].model = singleResult.model;
1754
+ statusPayload.steps[fi].thinking = resolveEffectiveThinking(singleResult.model, statusPayload.steps[fi].thinking);
1755
+ statusPayload.steps[fi].attemptedModels = singleResult.attemptedModels;
1756
+ statusPayload.steps[fi].modelAttempts = singleResult.modelAttempts;
1757
+ statusPayload.steps[fi].error = singleResult.error;
1758
+ statusPayload.steps[fi].structuredOutput = singleResult.structuredOutput;
1759
+ statusPayload.steps[fi].structuredOutputPath = singleResult.structuredOutputPath;
1760
+ statusPayload.steps[fi].structuredOutputSchemaPath = singleResult.structuredOutputSchemaPath;
1761
+ statusPayload.steps[fi].acceptance = singleResult.acceptance;
1762
+ statusPayload.steps[fi].resourceLimitExceeded = singleResult.resourceLimitExceeded;
1763
+ statusPayload.lastUpdate = taskEndTime;
1764
+ writeStatusPayload();
1765
+ appendJsonl(eventsPath, JSON.stringify({
1766
+ type: singleResult.exitCode === 0 ? "subagent.step.completed" : "subagent.step.failed",
1767
+ ts: taskEndTime, runId: id, stepIndex: fi, agent: task.agent,
1768
+ exitCode: singleResult.exitCode, durationMs: taskEndTime - taskStartTime,
1769
+ resourceLimitExceeded: singleResult.resourceLimitExceeded,
1770
+ }));
1771
+ if (singleResult.exitCode !== 0 && failFast) aborted = true;
1772
+ return { ...singleResult, skipped: false };
1773
+ });
1774
+
1775
+ flatIndex += dynamicSteps.length;
1776
+ for (const pr of parallelResults) {
1777
+ results.push({
1778
+ agent: pr.agent,
1779
+ output: pr.output,
1780
+ error: pr.error,
1781
+ success: pr.exitCode === 0,
1782
+ exitCode: pr.exitCode,
1783
+ skipped: pr.skipped,
1784
+ sessionFile: pr.sessionFile,
1785
+ intercomTarget: pr.intercomTarget,
1786
+ model: pr.model,
1787
+ attemptedModels: pr.attemptedModels,
1788
+ modelAttempts: pr.modelAttempts,
1789
+ artifactPaths: pr.artifactPaths,
1790
+ structuredOutput: pr.structuredOutput,
1791
+ structuredOutputPath: pr.structuredOutputPath,
1792
+ structuredOutputSchemaPath: pr.structuredOutputSchemaPath,
1793
+ acceptance: pr.acceptance,
1794
+ resourceLimitExceeded: pr.resourceLimitExceeded,
1795
+ });
1796
+ }
1797
+ const collection = collectDynamicResults(step as Parameters<typeof collectDynamicResults>[0], materialized.items, parallelResults);
1798
+ const failures = parallelResults.filter((result) => result.exitCode !== 0 && result.exitCode !== -1);
1799
+ if (failures.length === 0) {
1800
+ try {
1801
+ validateDynamicCollection(step.collect.outputSchema, collection);
1802
+ outputs[step.collect.as] = {
1803
+ text: JSON.stringify(collection),
1804
+ structured: collection,
1805
+ agent: step.parallel.agent,
1806
+ stepIndex,
1807
+ };
1808
+ statusPayload.outputs = outputs;
1809
+ markDynamicGraphGroup(stepIndex, "completed");
1810
+ } catch (error) {
1811
+ const message = error instanceof DynamicFanoutError ? error.message : error instanceof Error ? error.message : String(error);
1812
+ results.push({ agent: step.parallel.agent, output: message, error: message, success: false, exitCode: 1, structuredOutput: collection });
1813
+ statusPayload.error = message;
1814
+ markDynamicGraphGroup(stepIndex, "failed", message);
1815
+ }
1816
+ }
1817
+ previousOutput = aggregateParallelOutputs(
1818
+ parallelResults.map((r, i) => ({
1819
+ agent: r.agent,
1820
+ taskIndex: i,
1821
+ output: r.output,
1822
+ exitCode: r.exitCode,
1823
+ error: r.error,
1824
+ })),
1825
+ (i, agent) => `=== Dynamic Item ${i + 1} (${agent}, key ${materialized.items[i]?.key ?? i}) ===`,
1826
+ );
1827
+ appendJsonl(eventsPath, JSON.stringify({
1828
+ type: "subagent.dynamic.completed",
1829
+ ts: Date.now(),
1830
+ runId: id,
1831
+ stepIndex,
1832
+ success: failures.length === 0,
1833
+ }));
1834
+ if (failures.length > 0) markDynamicGraphGroup(stepIndex, "failed", failures[0]?.error ?? "Dynamic fanout child failed.");
1835
+ statusPayload.lastUpdate = Date.now();
1836
+ writeStatusPayload();
1837
+ if (failures.length > 0 || statusPayload.error) break;
1838
+ continue;
1839
+ }
1840
+
1265
1841
  if (isParallelGroup(step)) {
1266
1842
  const group = step;
1267
1843
  const concurrency = group.concurrency ?? MAX_PARALLEL_CONCURRENCY;
@@ -1379,6 +1955,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1379
1955
 
1380
1956
  const singleResult = await runSingleStep(taskForRun, {
1381
1957
  previousOutput, placeholder, cwd: taskCwd, sessionEnabled,
1958
+ outputs,
1382
1959
  sessionDir: taskSessionDir,
1383
1960
  artifactsDir, artifactConfig, id,
1384
1961
  flatIndex: fi, flatStepCount: flatSteps.length,
@@ -1410,6 +1987,11 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1410
1987
  statusPayload.steps[fi].attemptedModels = singleResult.attemptedModels;
1411
1988
  statusPayload.steps[fi].modelAttempts = singleResult.modelAttempts;
1412
1989
  statusPayload.steps[fi].error = singleResult.error;
1990
+ statusPayload.steps[fi].structuredOutput = singleResult.structuredOutput;
1991
+ statusPayload.steps[fi].structuredOutputPath = singleResult.structuredOutputPath;
1992
+ statusPayload.steps[fi].structuredOutputSchemaPath = singleResult.structuredOutputSchemaPath;
1993
+ statusPayload.steps[fi].acceptance = singleResult.acceptance;
1994
+ statusPayload.steps[fi].resourceLimitExceeded = singleResult.resourceLimitExceeded;
1413
1995
  statusPayload.lastUpdate = taskEndTime;
1414
1996
  writeStatusPayload();
1415
1997
 
@@ -1417,6 +1999,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1417
1999
  type: singleResult.exitCode === 0 ? "subagent.step.completed" : "subagent.step.failed",
1418
2000
  ts: taskEndTime, runId: id, stepIndex: fi, agent: task.agent,
1419
2001
  exitCode: singleResult.exitCode, durationMs: taskDuration,
2002
+ resourceLimitExceeded: singleResult.resourceLimitExceeded,
1420
2003
  }));
1421
2004
  if (singleResult.completionGuardTriggered) {
1422
2005
  const event = buildControlEvent({
@@ -1463,6 +2046,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1463
2046
  output: pr.output,
1464
2047
  error: pr.error,
1465
2048
  success: pr.exitCode === 0,
2049
+ exitCode: pr.exitCode,
1466
2050
  skipped: pr.skipped,
1467
2051
  sessionFile: pr.sessionFile,
1468
2052
  intercomTarget: pr.intercomTarget,
@@ -1470,8 +2054,22 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1470
2054
  attemptedModels: pr.attemptedModels,
1471
2055
  modelAttempts: pr.modelAttempts,
1472
2056
  artifactPaths: pr.artifactPaths,
1473
- });
2057
+ structuredOutput: pr.structuredOutput,
2058
+ structuredOutputPath: pr.structuredOutputPath,
2059
+ structuredOutputSchemaPath: pr.structuredOutputSchemaPath,
2060
+ acceptance: pr.acceptance,
2061
+ resourceLimitExceeded: pr.resourceLimitExceeded,
2062
+ });
2063
+ }
2064
+ for (let t = 0; t < group.parallel.length; t++) {
2065
+ const outputName = group.parallel[t]?.outputName;
2066
+ if (outputName) outputs[outputName] = outputEntryFromAsyncResult({
2067
+ agent: parallelResults[t]!.agent,
2068
+ output: parallelResults[t]!.output,
2069
+ structuredOutput: parallelResults[t]!.structuredOutput,
2070
+ }, stepIndex);
1474
2071
  }
2072
+ statusPayload.outputs = outputs;
1475
2073
 
1476
2074
  previousOutput = aggregateParallelOutputs(
1477
2075
  parallelResults.map((r) => ({
@@ -1525,6 +2123,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1525
2123
 
1526
2124
  const singleResult = await runSingleStep(seqStep, {
1527
2125
  previousOutput, placeholder, cwd, sessionEnabled,
2126
+ outputs,
1528
2127
  sessionDir: config.sessionDir,
1529
2128
  artifactsDir, artifactConfig, id,
1530
2129
  flatIndex, flatStepCount: flatSteps.length,
@@ -1550,13 +2149,27 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1550
2149
  output: singleResult.output,
1551
2150
  error: singleResult.error,
1552
2151
  success: singleResult.exitCode === 0,
2152
+ exitCode: singleResult.exitCode,
1553
2153
  sessionFile: singleResult.sessionFile,
1554
2154
  intercomTarget: singleResult.intercomTarget,
1555
2155
  model: singleResult.model,
1556
2156
  attemptedModels: singleResult.attemptedModels,
1557
2157
  modelAttempts: singleResult.modelAttempts,
1558
2158
  artifactPaths: singleResult.artifactPaths,
2159
+ structuredOutput: singleResult.structuredOutput,
2160
+ structuredOutputPath: singleResult.structuredOutputPath,
2161
+ structuredOutputSchemaPath: singleResult.structuredOutputSchemaPath,
2162
+ acceptance: singleResult.acceptance,
2163
+ resourceLimitExceeded: singleResult.resourceLimitExceeded,
1559
2164
  });
2165
+ if (seqStep.outputName) {
2166
+ outputs[seqStep.outputName] = outputEntryFromAsyncResult({
2167
+ agent: singleResult.agent,
2168
+ output: singleResult.output,
2169
+ structuredOutput: singleResult.structuredOutput,
2170
+ }, stepIndex);
2171
+ }
2172
+ statusPayload.outputs = outputs;
1560
2173
 
1561
2174
  const cumulativeTokens = config.sessionDir ? parseSessionTokens(config.sessionDir) : null;
1562
2175
  let stepTokens: TokenUsage | null = cumulativeTokens
@@ -1589,6 +2202,11 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1589
2202
  statusPayload.steps[flatIndex].attemptedModels = singleResult.attemptedModels;
1590
2203
  statusPayload.steps[flatIndex].modelAttempts = singleResult.modelAttempts;
1591
2204
  statusPayload.steps[flatIndex].error = singleResult.error;
2205
+ statusPayload.steps[flatIndex].structuredOutput = singleResult.structuredOutput;
2206
+ statusPayload.steps[flatIndex].structuredOutputPath = singleResult.structuredOutputPath;
2207
+ statusPayload.steps[flatIndex].structuredOutputSchemaPath = singleResult.structuredOutputSchemaPath;
2208
+ statusPayload.steps[flatIndex].acceptance = singleResult.acceptance;
2209
+ statusPayload.steps[flatIndex].resourceLimitExceeded = singleResult.resourceLimitExceeded;
1592
2210
  if (stepTokens) {
1593
2211
  statusPayload.steps[flatIndex].tokens = stepTokens;
1594
2212
  statusPayload.totalTokens = { ...previousCumulativeTokens };
@@ -1605,6 +2223,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1605
2223
  exitCode: singleResult.exitCode,
1606
2224
  durationMs: stepEndTime - stepStartTime,
1607
2225
  tokens: stepTokens,
2226
+ resourceLimitExceeded: singleResult.resourceLimitExceeded,
1608
2227
  }));
1609
2228
  if (singleResult.completionGuardTriggered) {
1610
2229
  const event = buildControlEvent({
@@ -1690,7 +2309,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1690
2309
  statusPayload.shareUrl = shareUrl;
1691
2310
  statusPayload.gistUrl = gistUrl;
1692
2311
  statusPayload.shareError = shareError;
1693
- if (statusPayload.state === "failed") {
2312
+ if (statusPayload.state === "failed" && !statusPayload.error) {
1694
2313
  const failedStep = statusPayload.steps.find((s) => s.status === "failed");
1695
2314
  if (failedStep?.agent) {
1696
2315
  statusPayload.error = `Step failed: ${failedStep.agent}`;
@@ -1747,7 +2366,14 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1747
2366
  modelAttempts: r.modelAttempts,
1748
2367
  artifactPaths: r.artifactPaths,
1749
2368
  truncated: r.truncated,
2369
+ structuredOutput: r.structuredOutput,
2370
+ structuredOutputPath: r.structuredOutputPath,
2371
+ structuredOutputSchemaPath: r.structuredOutputSchemaPath,
2372
+ acceptance: r.acceptance,
2373
+ resourceLimitExceeded: r.resourceLimitExceeded,
1750
2374
  })),
2375
+ outputs,
2376
+ workflowGraph: statusPayload.workflowGraph,
1751
2377
  exitCode: interrupted || results.every((r) => r.success) ? 0 : 1,
1752
2378
  timestamp: runEndedAt,
1753
2379
  durationMs: runEndedAt - overallStartTime,