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
@@ -35,9 +35,73 @@ const ReadsOverride = Type.Unsafe({
35
35
  description: "Files to read before running (array of filenames), or false to disable",
36
36
  });
37
37
 
38
+ const JsonSchemaObject = Type.Unsafe({
39
+ type: "object",
40
+ additionalProperties: true,
41
+ description: "JSON Schema object for strict structured output. Non-object roots are rejected.",
42
+ });
43
+
44
+ const AcceptanceEvidenceKind = Type.String({
45
+ enum: [
46
+ "changed-files",
47
+ "tests-added",
48
+ "commands-run",
49
+ "validation-output",
50
+ "residual-risks",
51
+ "no-staged-files",
52
+ "diff-summary",
53
+ "review-findings",
54
+ "manual-notes",
55
+ ],
56
+ });
57
+
58
+ const AcceptanceGateSchema = Type.Object({
59
+ id: Type.String(),
60
+ must: Type.String(),
61
+ evidence: Type.Optional(Type.Array(AcceptanceEvidenceKind)),
62
+ severity: Type.Optional(Type.String({ enum: ["required", "recommended"] })),
63
+ }, { additionalProperties: false });
64
+
65
+ const AcceptanceVerifyCommandSchema = Type.Object({
66
+ id: Type.String(),
67
+ command: Type.String(),
68
+ timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
69
+ cwd: Type.Optional(Type.String()),
70
+ env: Type.Optional(Type.Unsafe({ type: "object", additionalProperties: { type: "string" } })),
71
+ allowFailure: Type.Optional(Type.Boolean()),
72
+ }, { additionalProperties: false });
73
+
74
+ const AcceptanceReviewGateSchema = Type.Object({
75
+ agent: Type.Optional(Type.String()),
76
+ focus: Type.Optional(Type.String()),
77
+ required: Type.Optional(Type.Boolean()),
78
+ }, { additionalProperties: false });
79
+
80
+ const AcceptanceOverride = Type.Unsafe({
81
+ type: "object",
82
+ properties: {
83
+ criteria: {
84
+ type: "array",
85
+ items: {
86
+ anyOf: [
87
+ { type: "string" },
88
+ AcceptanceGateSchema,
89
+ ],
90
+ },
91
+ },
92
+ evidence: { type: "array", items: AcceptanceEvidenceKind },
93
+ verify: { type: "array", items: AcceptanceVerifyCommandSchema },
94
+ review: AcceptanceReviewGateSchema,
95
+ stopRules: { type: "array", items: { type: "string" } },
96
+ maxFinalizationTurns: { type: "integer", minimum: 1, maximum: 10 },
97
+ },
98
+ additionalProperties: false,
99
+ description: "Optional acceptance contract. Use this for goal-style requests and for implementation handoffs from plans, PRDs, specs, issues, or broad fixes. Put implementation instructions and plan paths in task; put the definition of done in criteria, proof in evidence/verify, constraints in stopRules, and the bounded loop budget in maxFinalizationTurns. Runtime validation still requires at least one of criteria, evidence, verify, review, or stopRules. When present, the child must complete a same-session self-review/repair loop before acceptance is evaluated.",
100
+ });
101
+
38
102
  const TaskItem = Type.Object({
39
- agent: Type.String(),
40
- task: Type.String(),
103
+ agent: Type.String(),
104
+ task: Type.String(),
41
105
  cwd: Type.Optional(Type.String()),
42
106
  count: Type.Optional(Type.Integer({ minimum: 1, description: "Repeat this parallel task N times with the same settings." })),
43
107
  output: Type.Optional(OutputOverride),
@@ -46,12 +110,17 @@ const TaskItem = Type.Object({
46
110
  progress: Type.Optional(Type.Boolean({ description: "Enable progress.md tracking for this task" })),
47
111
  model: Type.Optional(Type.String({ description: "Override model for this task (e.g. 'google/gemini-3-pro')" })),
48
112
  skill: Type.Optional(SkillOverride),
113
+ acceptance: Type.Optional(AcceptanceOverride),
49
114
  });
50
115
 
51
116
  // Parallel task item (within a parallel step)
52
117
  const ParallelTaskSchema = Type.Object({
53
118
  agent: Type.String(),
54
119
  task: Type.Optional(Type.String({ description: "Task template with {task}, {previous}, {chain_dir} variables. Defaults to {previous}." })),
120
+ phase: Type.Optional(Type.String({ description: "Optional phase/group label for status and graph rendering." })),
121
+ label: Type.Optional(Type.String({ description: "Optional user-facing label for this parallel task." })),
122
+ as: Type.Optional(Type.String({ description: "Optional safe identifier used as {outputs.name} in later chain steps." })),
123
+ outputSchema: Type.Optional(JsonSchemaObject),
55
124
  cwd: Type.Optional(Type.String()),
56
125
  count: Type.Optional(Type.Integer({ minimum: 1, description: "Repeat this parallel task N times with the same settings." })),
57
126
  output: Type.Optional(OutputOverride),
@@ -60,14 +129,51 @@ const ParallelTaskSchema = Type.Object({
60
129
  progress: Type.Optional(Type.Boolean({ description: "Enable progress.md tracking in {chain_dir}" })),
61
130
  skill: Type.Optional(SkillOverride),
62
131
  model: Type.Optional(Type.String({ description: "Override model for this task" })),
132
+ acceptance: Type.Optional(AcceptanceOverride),
63
133
  });
64
134
 
135
+ const DynamicExpandSchema = Type.Object({
136
+ from: Type.Object({
137
+ output: Type.String({ description: "Prior named structured output to expand from." }),
138
+ path: Type.String({ description: "JSON Pointer into the structured output, e.g. /items." }),
139
+ }, { additionalProperties: false }),
140
+ item: Type.Optional(Type.String({ description: "Template variable name for each item. Defaults to item." })),
141
+ key: Type.Optional(Type.String({ description: "JSON Pointer relative to each item for stable child ids." })),
142
+ maxItems: Type.Optional(Type.Integer({ minimum: 0, description: "Required fanout bound unless configured globally." })),
143
+ onEmpty: Type.Optional(Type.String({ enum: ["skip", "fail"], description: "Empty input behavior. Defaults to skip." })),
144
+ }, { additionalProperties: false });
145
+
146
+ const DynamicParallelTemplateSchema = Type.Object({
147
+ agent: Type.String(),
148
+ task: Type.Optional(Type.String({ description: "Task template with {item}, {item.path}, {task}, {previous}, {chain_dir}, and {outputs.name} variables." })),
149
+ phase: Type.Optional(Type.String({ description: "Optional phase/group label for status and graph rendering." })),
150
+ label: Type.Optional(Type.String({ description: "Optional user-facing label; item templates are supported." })),
151
+ outputSchema: Type.Optional(JsonSchemaObject),
152
+ cwd: Type.Optional(Type.String()),
153
+ output: Type.Optional(OutputOverride),
154
+ outputMode: Type.Optional(OutputModeOverride),
155
+ reads: Type.Optional(ReadsOverride),
156
+ progress: Type.Optional(Type.Boolean({ description: "Enable progress.md tracking in {chain_dir}" })),
157
+ skill: Type.Optional(SkillOverride),
158
+ model: Type.Optional(Type.String({ description: "Override model for this task" })),
159
+ acceptance: Type.Optional(AcceptanceOverride),
160
+ }, { additionalProperties: false });
161
+
162
+ const DynamicCollectSchema = Type.Object({
163
+ as: Type.String({ description: "Safe output name for the ordered collected result array." }),
164
+ outputSchema: Type.Optional(JsonSchemaObject),
165
+ }, { additionalProperties: false });
166
+
65
167
  // Flattened so chain steps do not need an object-shape anyOf/oneOf union.
66
168
  const ChainItem = Type.Object({
67
169
  agent: Type.Optional(Type.String({ description: "Sequential step agent name" })),
68
170
  task: Type.Optional(Type.String({
69
- description: "Task template with variables: {task}=original request, {previous}=prior step's text response, {chain_dir}=shared folder. Required for first step, defaults to '{previous}' for subsequent steps."
171
+ description: "Task template with variables: {task}=original request, {previous}=prior step's text response, {chain_dir}=shared folder, {outputs.name}=prior named output. Required for first step, defaults to '{previous}' for subsequent steps."
70
172
  })),
173
+ phase: Type.Optional(Type.String({ description: "Optional phase/group label for status and graph rendering." })),
174
+ label: Type.Optional(Type.String({ description: "Optional user-facing label for this chain step." })),
175
+ as: Type.Optional(Type.String({ description: "Optional safe identifier used as {outputs.name} in later chain steps." })),
176
+ outputSchema: Type.Optional(JsonSchemaObject),
71
177
  cwd: Type.Optional(Type.String()),
72
178
  output: Type.Optional(OutputOverride),
73
179
  outputMode: Type.Optional(OutputModeOverride),
@@ -75,13 +181,30 @@ const ChainItem = Type.Object({
75
181
  progress: Type.Optional(Type.Boolean({ description: "Enable progress.md tracking in {chain_dir}" })),
76
182
  skill: Type.Optional(SkillOverride),
77
183
  model: Type.Optional(Type.String({ description: "Override model for this step" })),
78
- parallel: Type.Optional(Type.Array(ParallelTaskSchema, { minItems: 1, description: "Tasks to run in parallel" })),
184
+ acceptance: Type.Optional(AcceptanceOverride),
185
+ parallel: Type.Optional(Type.Unsafe({
186
+ anyOf: [
187
+ Type.Array(ParallelTaskSchema, { minItems: 1, description: "Tasks to run in parallel" }),
188
+ DynamicParallelTemplateSchema,
189
+ ],
190
+ description: "Static parallel tasks array, or a single dynamic fanout child template when expand/collect are present.",
191
+ })),
192
+ expand: Type.Optional(DynamicExpandSchema),
193
+ collect: Type.Optional(DynamicCollectSchema),
79
194
  concurrency: Type.Optional(Type.Number({ description: "Max concurrent tasks (default: 4)" })),
80
195
  failFast: Type.Optional(Type.Boolean({ description: "Stop on first failure (default: false)" })),
81
196
  worktree: Type.Optional(Type.Boolean({
82
197
  description: "Create isolated git worktrees for each parallel task."
83
198
  })),
84
- }, { description: "Chain step: use {agent, task?, ...} for sequential or {parallel: [...]} for concurrent execution" });
199
+ }, {
200
+ description: "Chain step: use {agent, task?, ...} for sequential, {parallel: [...]} for static concurrent execution, or {expand, parallel: {...}, collect} for dynamic fanout.",
201
+ additionalProperties: false,
202
+ allOf: [
203
+ { if: { required: ["expand"] }, then: { required: ["parallel", "collect"], properties: { parallel: { type: "object" } } } },
204
+ { if: { required: ["collect"] }, then: { required: ["expand", "parallel"], properties: { parallel: { type: "object" } } } },
205
+ { not: { required: ["expand"], properties: { parallel: { type: "array", items: {} } } } },
206
+ ],
207
+ });
85
208
 
86
209
  const ControlOverrides = Type.Object({
87
210
  enabled: Type.Optional(Type.Boolean({ description: "Enable/disable subagent control attention tracking for this run" })),
@@ -127,10 +250,12 @@ export const SubagentParams = Type.Object({
127
250
  { type: "object", additionalProperties: true },
128
251
  { type: "string" },
129
252
  ],
130
- description: "Agent or chain config for create/update. Agent: name, package (optional namespace; runtime name becomes package.name), description, scope ('user'|'project', default 'user'), systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, defaultContext ('fresh'|'fork'), model, tools (comma-separated), extensions (comma-separated), skills (comma-separated), thinking, output, reads, progress, maxSubagentDepth. Chain: name, package, description, scope, steps (array of {agent, task?, output?, outputMode?, reads?, model?, skill?, progress?}). Presence of 'steps' creates a chain instead of an agent. String values must be valid JSON."
253
+ description: "Agent or chain config for create/update. Agent: name, package (optional namespace; runtime name becomes package.name), description, scope ('user'|'project', default 'user'), systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, defaultContext ('fresh'|'fork'), model, tools (comma-separated), extensions (comma-separated), skills (comma-separated), thinking, output, reads, progress, maxSubagentDepth, maxExecutionTimeMs, maxTokens. Chain: name, package, description, scope, steps (array of {agent, task?, output?, outputMode?, reads?, model?, skill?, progress?}). Presence of 'steps' creates a chain instead of an agent. String values must be valid JSON."
131
254
  })),
132
255
  tasks: Type.Optional(Type.Array(TaskItem, { description: "PARALLEL mode: [{agent, task, count?, output?, outputMode?, reads?, progress?}, ...]" })),
133
256
  concurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Top-level PARALLEL mode only: max concurrent tasks. Defaults to config.parallel.concurrency or 4." })),
257
+ timeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "Foreground execution wall-clock timeout in milliseconds. When it expires, running children are soft-interrupted and timed-out results are returned. Foreground only; async/background runs ignore this field." })),
258
+ maxRuntimeMs: Type.Optional(Type.Integer({ minimum: 1, description: "Alias for timeoutMs. Use only one unless both values are identical." })),
134
259
  worktree: Type.Optional(Type.Boolean({
135
260
  description: "Create isolated git worktrees for each parallel task. " +
136
261
  "Prevents filesystem conflicts. Requires clean git state. " +
@@ -165,4 +290,5 @@ export const SubagentParams = Type.Object({
165
290
  outputMode: Type.Optional(OutputModeOverride),
166
291
  skill: Type.Optional(SkillOverride),
167
292
  model: Type.Optional(Type.String({ description: "Override model for single agent (e.g. 'anthropic/claude-sonnet-4')" })),
293
+ acceptance: Type.Optional(AcceptanceOverride),
168
294
  });
@@ -20,8 +20,10 @@ export function resolveSubagentResultStatus(input: {
20
20
  state?: string;
21
21
  interrupted?: boolean;
22
22
  detached?: boolean;
23
+ timedOut?: boolean;
23
24
  }): SubagentResultStatus {
24
25
  if (input.detached) return "detached";
26
+ if (input.timedOut || input.state === "timed-out") return "timed-out";
25
27
  if (input.interrupted || input.state === "paused") return "paused";
26
28
  if (typeof input.success === "boolean") return input.success ? "completed" : "failed";
27
29
  if (input.state === "complete") return "completed";
@@ -36,6 +38,7 @@ function countStatuses(children: SubagentResultIntercomChild[]): Record<Subagent
36
38
  failed: 0,
37
39
  paused: 0,
38
40
  detached: 0,
41
+ "timed-out": 0,
39
42
  };
40
43
  for (const child of children) {
41
44
  counts[child.status] += 1;
@@ -49,6 +52,7 @@ function formatStatusCounts(counts: Record<SubagentResultStatus, number>): strin
49
52
  counts.failed ? `${counts.failed} failed` : undefined,
50
53
  counts.paused ? `${counts.paused} paused` : undefined,
51
54
  counts.detached ? `${counts.detached} detached` : undefined,
55
+ counts["timed-out"] ? `${counts["timed-out"]} timed out` : undefined,
52
56
  ].filter((part): part is string => Boolean(part));
53
57
  return parts.length ? parts.join(", ") : "0 results";
54
58
  }
@@ -56,6 +60,7 @@ function formatStatusCounts(counts: Record<SubagentResultStatus, number>): strin
56
60
  function resolveGroupedStatus(children: SubagentResultIntercomChild[]): SubagentResultStatus {
57
61
  const counts = countStatuses(children);
58
62
  if (counts.failed > 0) return "failed";
63
+ if (counts["timed-out"] > 0) return "timed-out";
59
64
  if (counts.paused > 0) return "paused";
60
65
  if (counts.completed > 0) return "completed";
61
66
  if (counts.detached > 0) return "detached";
@@ -12,7 +12,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
  import type { AgentConfig } from "../../agents/agents.ts";
13
13
  import { applyThinkingSuffix } from "../shared/pi-args.ts";
14
14
  import { injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
15
- import { buildChainInstructions, isParallelStep, resolveStepBehavior, suppressProgressForReadOnlyTask, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
15
+ import { buildChainInstructions, isDynamicParallelStep, isParallelStep, resolveStepBehavior, suppressProgressForReadOnlyTask, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
16
16
  import type { RunnerStep } from "../shared/parallel-utils.ts";
17
17
  import { resolvePiPackageRoot } from "../shared/pi-spawn.ts";
18
18
  import { buildSkillInjection, normalizeSkillInput, resolveSkillsWithFallback } from "../../agents/skills.ts";
@@ -20,7 +20,12 @@ import { resolveChildCwd } from "../../shared/utils.ts";
20
20
  import { buildModelCandidates, resolveModelCandidate, type AvailableModelInfo } from "../shared/model-fallback.ts";
21
21
  import { resolveEffectiveThinking } from "../../shared/model-info.ts";
22
22
  import { resolveExpectedWorktreeAgentCwd } from "../shared/worktree.ts";
23
+ import { buildWorkflowGraphSnapshot } from "../shared/workflow-graph.ts";
24
+ import { ChainOutputValidationError, validateChainOutputBindings } from "../shared/chain-outputs.ts";
25
+ import { createStructuredOutputRuntime } from "../shared/structured-output.ts";
26
+ import { resolveEffectiveAcceptance } from "../shared/acceptance.ts";
23
27
  import {
28
+ type AcceptanceInput,
24
29
  type ArtifactConfig,
25
30
  type Details,
26
31
  type MaxOutputConfig,
@@ -107,6 +112,7 @@ interface AsyncChainParams {
107
112
  sessionRoot?: string;
108
113
  chainSkills?: string[];
109
114
  sessionFilesByFlatIndex?: (string | undefined)[];
115
+ dynamicFanoutMaxItems?: number;
110
116
  maxSubagentDepth: number;
111
117
  worktreeSetupHook?: string;
112
118
  worktreeSetupHookTimeoutMs?: number;
@@ -114,6 +120,7 @@ interface AsyncChainParams {
114
120
  controlIntercomTarget?: string;
115
121
  childIntercomTarget?: (agent: string, index: number) => string | undefined;
116
122
  nestedRoute?: NestedRouteInfo;
123
+ acceptance?: AcceptanceInput;
117
124
  }
118
125
 
119
126
  interface AsyncSingleParams {
@@ -140,6 +147,7 @@ interface AsyncSingleParams {
140
147
  controlIntercomTarget?: string;
141
148
  childIntercomTarget?: (agent: string, index: number) => string | undefined;
142
149
  nestedRoute?: NestedRouteInfo;
150
+ acceptance?: AcceptanceInput;
143
151
  }
144
152
 
145
153
  interface AsyncExecutionResult {
@@ -248,12 +256,25 @@ export function executeAsyncChain(
248
256
  const runnerCwd = resolveChildCwd(ctx.cwd, cwd);
249
257
  const firstStep = chain[0];
250
258
  const originalTask = params.task ?? (firstStep
251
- ? (isParallelStep(firstStep) ? firstStep.parallel[0]?.task : (firstStep as SequentialStep).task)
259
+ ? (isParallelStep(firstStep)
260
+ ? firstStep.parallel[0]?.task
261
+ : isDynamicParallelStep(firstStep)
262
+ ? firstStep.parallel.task
263
+ : (firstStep as SequentialStep).task)
252
264
  : undefined);
265
+ try {
266
+ validateChainOutputBindings(chain, { maxItems: params.dynamicFanoutMaxItems });
267
+ } catch (error) {
268
+ if (error instanceof ChainOutputValidationError) return formatAsyncStartError(resultMode, error.message);
269
+ throw error;
270
+ }
271
+ const workflowGraph = buildWorkflowGraphSnapshot({ runId: id, mode: resultMode, steps: chain });
253
272
 
254
273
  for (const s of chain) {
255
274
  const stepAgents = isParallelStep(s)
256
275
  ? s.parallel.map((t) => t.agent)
276
+ : isDynamicParallelStep(s)
277
+ ? [s.parallel.agent]
257
278
  : [(s as SequentialStep).agent];
258
279
  for (const agentName of stepAgents) {
259
280
  if (!agents.find((x) => x.name === agentName)) {
@@ -316,13 +337,20 @@ export function executeAsyncChain(
316
337
  const outputPath = resolveSingleOutputPath(behavior.output, ctx.cwd, instructionCwd);
317
338
  const validationError = validateFileOnlyOutputMode(behavior.outputMode, outputPath, `Async step (${s.agent})`);
318
339
  if (validationError) throw new AsyncStartValidationError(validationError);
319
- const task = injectSingleOutputInstruction(`${readInstructions.prefix}${s.task ?? "{previous}"}${progressInstructions.suffix}`, outputPath);
340
+ let taskTemplate = s.task ?? "{previous}";
341
+ taskTemplate = taskTemplate.replace(/\{task\}/g, originalTask ?? "");
342
+ taskTemplate = taskTemplate.replace(/\{chain_dir\}/g, runnerCwd);
343
+ const task = injectSingleOutputInstruction(`${readInstructions.prefix}${taskTemplate}${progressInstructions.suffix}`, outputPath);
320
344
 
321
345
  const primaryModel = resolveModelCandidate(behavior.model ?? a.model, availableModels, ctx.currentModelProvider);
322
346
  const model = applyThinkingSuffix(primaryModel, a.thinking);
323
347
  return {
324
348
  agent: s.agent,
325
349
  task,
350
+ phase: s.phase,
351
+ label: s.label,
352
+ outputName: s.as,
353
+ structured: Boolean(s.outputSchema),
326
354
  cwd: stepCwd,
327
355
  model,
328
356
  thinking: resolveEffectiveThinking(model, a.thinking),
@@ -342,6 +370,18 @@ export function executeAsyncChain(
342
370
  outputMode: behavior.outputMode,
343
371
  sessionFile,
344
372
  maxSubagentDepth: resolveChildMaxSubagentDepth(maxSubagentDepth, a.maxSubagentDepth),
373
+ maxExecutionTimeMs: a.maxExecutionTimeMs,
374
+ maxTokens: a.maxTokens,
375
+ effectiveAcceptance: resolveEffectiveAcceptance({
376
+ explicit: s.acceptance,
377
+ agentName: s.agent,
378
+ task: s.task,
379
+ mode: resultMode,
380
+ async: true,
381
+ dynamic: false,
382
+ }),
383
+ ...(s.outputSchema ? { structuredOutputSchema: s.outputSchema } : {}),
384
+ ...(s.outputSchema ? { structuredOutput: createStructuredOutputRuntime(s.outputSchema, path.join(asyncDir, "structured-output")) } : {}),
345
385
  };
346
386
  };
347
387
 
@@ -382,6 +422,24 @@ export function executeAsyncChain(
382
422
  worktree: s.worktree,
383
423
  };
384
424
  }
425
+ if (isDynamicParallelStep(s)) {
426
+ const agent = agents.find((candidate) => candidate.name === s.parallel.agent)!;
427
+ const behavior = suppressProgressForReadOnlyTask(resolveStepBehavior(agent, buildStepOverrides(s.parallel), chainSkills), s.parallel.task, originalTask);
428
+ const progressPrecreated = behavior.progress;
429
+ if (progressPrecreated) {
430
+ writeInitialProgressFile(runnerCwd);
431
+ progressInstructionCreated = true;
432
+ }
433
+ return {
434
+ expand: s.expand,
435
+ parallel: buildSeqStep(s.parallel as SequentialStep, undefined, undefined, progressPrecreated, behavior),
436
+ collect: s.collect,
437
+ concurrency: s.concurrency,
438
+ failFast: s.failFast,
439
+ phase: s.phase,
440
+ label: s.label,
441
+ };
442
+ }
385
443
  return buildSeqStep(s as SequentialStep, nextSessionFile());
386
444
  });
387
445
  } catch (error) {
@@ -391,6 +449,10 @@ export function executeAsyncChain(
391
449
  let childTargetIndex = 0;
392
450
  const childIntercomTargets = childIntercomTarget ? steps.flatMap((step) => {
393
451
  if ("parallel" in step) {
452
+ if (!Array.isArray(step.parallel)) {
453
+ childTargetIndex++;
454
+ return [undefined];
455
+ }
394
456
  return step.parallel.map((task) => childIntercomTarget(task.agent, childTargetIndex++));
395
457
  }
396
458
  return [childIntercomTarget(step.agent, childTargetIndex++)];
@@ -420,6 +482,8 @@ export function executeAsyncChain(
420
482
  controlIntercomTarget,
421
483
  childIntercomTargets,
422
484
  resultMode,
485
+ dynamicFanoutMaxItems: params.dynamicFanoutMaxItems,
486
+ workflowGraph,
423
487
  nestedRoute: nestedRoute ?? inheritedNestedRoute,
424
488
  nestedSelf: inheritedNestedRoute && nestedAddress ? {
425
489
  parentRunId: nestedAddress.parentRunId,
@@ -444,6 +508,8 @@ export function executeAsyncChain(
444
508
  const firstStep = chain[0];
445
509
  const firstAgents = isParallelStep(firstStep)
446
510
  ? firstStep.parallel.map((t) => t.agent)
511
+ : isDynamicParallelStep(firstStep)
512
+ ? [firstStep.parallel.agent]
447
513
  : [(firstStep as SequentialStep).agent];
448
514
  const parallelGroups: Array<{ start: number; count: number; stepIndex: number }> = [];
449
515
  const flatAgents: string[] = [];
@@ -454,6 +520,10 @@ export function executeAsyncChain(
454
520
  parallelGroups.push({ start: flatStepStart, count: step.parallel.length, stepIndex });
455
521
  flatAgents.push(...step.parallel.map((task) => task.agent));
456
522
  flatStepStart += step.parallel.length;
523
+ } else if (isDynamicParallelStep(step)) {
524
+ parallelGroups.push({ start: flatStepStart, count: 1, stepIndex });
525
+ flatAgents.push(step.parallel.agent);
526
+ flatStepStart++;
457
527
  } else {
458
528
  flatAgents.push((step as SequentialStep).agent);
459
529
  flatStepStart++;
@@ -502,12 +572,15 @@ export function executeAsyncChain(
502
572
  agents: flatAgents,
503
573
  task: isParallelStep(firstStep)
504
574
  ? firstStep.parallel[0]?.task?.slice(0, 50)
575
+ : isDynamicParallelStep(firstStep)
576
+ ? firstStep.parallel.task?.slice(0, 50)
505
577
  : (firstStep as SequentialStep).task?.slice(0, 50),
506
578
  chain: chain.map((s) =>
507
- isParallelStep(s) ? `[${s.parallel.map((t) => t.agent).join("+")}]` : (s as SequentialStep).agent,
579
+ isParallelStep(s) ? `[${s.parallel.map((t) => t.agent).join("+")}]` : isDynamicParallelStep(s) ? `expand:${s.parallel.agent}` : (s as SequentialStep).agent,
508
580
  ),
509
581
  chainStepCount: chain.length,
510
582
  parallelGroups,
583
+ workflowGraph,
511
584
  cwd: runnerCwd,
512
585
  asyncDir,
513
586
  nestedRoute,
@@ -516,13 +589,13 @@ export function executeAsyncChain(
516
589
 
517
590
  const chainDesc = chain
518
591
  .map((s) =>
519
- isParallelStep(s) ? `[${s.parallel.map((t) => t.agent).join("+")}]` : (s as SequentialStep).agent,
592
+ isParallelStep(s) ? `[${s.parallel.map((t) => t.agent).join("+")}]` : isDynamicParallelStep(s) ? `expand:${s.parallel.agent}` : (s as SequentialStep).agent,
520
593
  )
521
594
  .join(" -> ");
522
595
 
523
596
  return {
524
597
  content: [{ type: "text", text: formatAsyncStartedMessage(`Async ${resultMode}: ${chainDesc} [${id}]`) }],
525
- details: { mode: resultMode, runId: id, results: [], asyncId: id, asyncDir },
598
+ details: { mode: resultMode, runId: id, results: [], asyncId: id, asyncDir, workflowGraph },
526
599
  };
527
600
  }
528
601
 
@@ -618,6 +691,15 @@ export function executeAsyncSingle(
618
691
  outputMode,
619
692
  sessionFile,
620
693
  maxSubagentDepth: resolveChildMaxSubagentDepth(maxSubagentDepth, agentConfig.maxSubagentDepth),
694
+ maxExecutionTimeMs: agentConfig.maxExecutionTimeMs,
695
+ maxTokens: agentConfig.maxTokens,
696
+ effectiveAcceptance: resolveEffectiveAcceptance({
697
+ explicit: params.acceptance,
698
+ agentName: agent,
699
+ task,
700
+ mode: "single",
701
+ async: true,
702
+ }),
621
703
  },
622
704
  ],
623
705
  resultPath: inheritedNestedRoute ? nestedResultsPath(inheritedNestedRoute.rootRunId, id) : path.join(RESULTS_DIR, `${id}.json`),
@@ -12,6 +12,10 @@ import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-
12
12
  interface AsyncRunStepSummary {
13
13
  index: number;
14
14
  agent: string;
15
+ label?: string;
16
+ phase?: string;
17
+ outputName?: string;
18
+ structured?: boolean;
15
19
  status: AsyncJobStep["status"];
16
20
  activityState?: ActivityState;
17
21
  lastActivityAt?: number;
@@ -139,6 +143,10 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
139
143
  return {
140
144
  index,
141
145
  agent: step.agent,
146
+ ...(step.label ? { label: step.label } : {}),
147
+ ...(step.phase ? { phase: step.phase } : {}),
148
+ ...(step.outputName ? { outputName: step.outputName } : {}),
149
+ ...(step.structured ? { structured: step.structured } : {}),
142
150
  status: step.status,
143
151
  ...(stepActivityState ? { activityState: stepActivityState } : {}),
144
152
  ...(stepLastActivityAt ? { lastActivityAt: stepLastActivityAt } : {}),
@@ -259,7 +267,9 @@ function formatActivityFacts(input: { activityState?: ActivityState; lastActivit
259
267
  }
260
268
 
261
269
  function formatStepLine(step: AsyncRunStepSummary): string {
262
- const parts = [`${step.index + 1}. ${step.agent}`, step.status];
270
+ const display = step.label ? `${step.label} (${step.agent})` : step.agent;
271
+ const phase = step.phase ? `[${step.phase}] ` : "";
272
+ const parts = [`${step.index + 1}. ${phase}${display}`, step.status];
263
273
  const activity = formatActivityFacts(step);
264
274
  if (activity) parts.push(activity);
265
275
  const modelThinking = formatModelThinking(step.model, step.thinking);
@@ -49,6 +49,11 @@ function formatResumeGuidance(runId: string | undefined, children: Array<{ agent
49
49
  return "Resume: unavailable; no child session file was persisted.";
50
50
  }
51
51
 
52
+ function formatAcceptanceFinalizationSummary(finalization: NonNullable<NonNullable<AsyncStatus["steps"]>[number]["acceptance"]>["finalization"] | undefined): string {
53
+ if (!finalization) return "";
54
+ return `, finalization: ${finalization.status} after ${finalization.turns.length}/${finalization.maxTurns} turns`;
55
+ }
56
+
52
57
  function stepLineLabel(status: AsyncStatus, index: number): string {
53
58
  const steps = status.steps ?? [];
54
59
  if (status.mode === "parallel") return `Agent ${index + 1}/${steps.length || 1}`;
@@ -217,7 +222,11 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
217
222
  const modelThinking = formatModelThinking(step.model, step.thinking);
218
223
  const modelText = modelThinking ? ` (${modelThinking})` : "";
219
224
  const errorText = step.error ? `, error: ${step.error}` : "";
220
- lines.push(`${stepLineLabel(status, index)}: ${step.agent} ${step.status}${modelText}${stepActivityText ? `, ${stepActivityText}` : ""}${errorText}`);
225
+ const finalizationText = formatAcceptanceFinalizationSummary(step.acceptance?.finalization);
226
+ const acceptanceText = step.acceptance?.status ? `, acceptance: ${step.acceptance.status}${finalizationText}` : "";
227
+ const display = step.label ? `${step.label} (${step.agent})` : step.agent;
228
+ const phase = step.phase ? `[${step.phase}] ` : "";
229
+ lines.push(`${stepLineLabel(status, index)}: ${phase}${display} ${step.status}${modelText}${stepActivityText ? `, ${stepActivityText}` : ""}${acceptanceText}${errorText}`);
221
230
  lines.push(...formatNestedRunStatusLines(step.children, { indent: " ", commandHints: true, maxLines: 20 }));
222
231
  const stepOutputPath = path.join(asyncDir, `output-${index}.log`);
223
232
  if (stepOutputPath !== outputPath && fs.existsSync(stepOutputPath)) lines.push(` Output: ${stepOutputPath}`);