pi-subagents 0.25.0 → 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 (38) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +129 -17
  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 +32 -17
  7. package/src/agents/agent-management.ts +57 -15
  8. package/src/agents/agent-serializer.ts +3 -2
  9. package/src/agents/agents.ts +47 -16
  10. package/src/agents/chain-serializer.ts +120 -0
  11. package/src/extension/fanout-child.ts +1 -0
  12. package/src/extension/index.ts +1 -0
  13. package/src/extension/schemas.ts +138 -5
  14. package/src/runs/background/async-execution.ts +84 -6
  15. package/src/runs/background/async-status.ts +11 -1
  16. package/src/runs/background/run-status.ts +10 -1
  17. package/src/runs/background/subagent-runner.ts +600 -31
  18. package/src/runs/foreground/chain-execution.ts +325 -118
  19. package/src/runs/foreground/execution.ts +222 -10
  20. package/src/runs/foreground/subagent-executor.ts +67 -0
  21. package/src/runs/shared/acceptance-contract.ts +291 -0
  22. package/src/runs/shared/acceptance-evaluation.ts +221 -0
  23. package/src/runs/shared/acceptance-finalization.ts +161 -0
  24. package/src/runs/shared/acceptance-reports.ts +127 -0
  25. package/src/runs/shared/acceptance.ts +22 -0
  26. package/src/runs/shared/chain-outputs.ts +101 -0
  27. package/src/runs/shared/completion-guard.ts +26 -3
  28. package/src/runs/shared/dynamic-fanout.ts +293 -0
  29. package/src/runs/shared/parallel-utils.ts +31 -1
  30. package/src/runs/shared/pi-args.ts +11 -0
  31. package/src/runs/shared/structured-output.ts +77 -0
  32. package/src/runs/shared/subagent-prompt-runtime.ts +53 -3
  33. package/src/runs/shared/workflow-graph.ts +206 -0
  34. package/src/shared/formatters.ts +2 -2
  35. package/src/shared/settings.ts +53 -4
  36. package/src/shared/types.ts +250 -0
  37. package/src/slash/slash-commands.ts +41 -3
  38. package/src/tui/render.ts +162 -34
@@ -4,6 +4,8 @@ import * as path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { encodeNestedPathEnv, parseNestedPathEnv, type NestedPathEntry } from "./nested-path.ts";
6
6
  import { resolveMcpDirectToolNames } from "./mcp-direct-tool-allowlist.ts";
7
+ import { STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV } from "./structured-output.ts";
8
+ import type { JsonSchemaObject } from "../../shared/types.ts";
7
9
 
8
10
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
9
11
  const TASK_ARG_LIMIT = 8000;
@@ -54,6 +56,11 @@ interface BuildPiArgsInput {
54
56
  parentDepth?: number;
55
57
  parentPath?: NestedPathEntry[];
56
58
  parentCapabilityToken?: string;
59
+ structuredOutput?: {
60
+ schema: JsonSchemaObject;
61
+ schemaPath: string;
62
+ outputPath: string;
63
+ };
57
64
  }
58
65
 
59
66
  interface BuildPiArgsResult {
@@ -204,6 +211,10 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
204
211
  } else {
205
212
  env.MCP_DIRECT_TOOLS = "__none__";
206
213
  }
214
+ if (input.structuredOutput) {
215
+ env[STRUCTURED_OUTPUT_CAPTURE_ENV] = input.structuredOutput.outputPath;
216
+ env[STRUCTURED_OUTPUT_SCHEMA_ENV] = input.structuredOutput.schemaPath;
217
+ }
207
218
 
208
219
  return { args, env, tempDir };
209
220
  }
@@ -0,0 +1,77 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { Compile } from "typebox/compile";
5
+ import type { JsonSchemaObject } from "../../shared/types.ts";
6
+
7
+ export const STRUCTURED_OUTPUT_SCHEMA_ENV = "PI_SUBAGENT_STRUCTURED_OUTPUT_SCHEMA";
8
+ export const STRUCTURED_OUTPUT_CAPTURE_ENV = "PI_SUBAGENT_STRUCTURED_OUTPUT_CAPTURE";
9
+
10
+ export interface StructuredOutputRuntime {
11
+ schema: JsonSchemaObject;
12
+ schemaPath: string;
13
+ outputPath: string;
14
+ }
15
+
16
+ interface CompiledJsonSchema {
17
+ Check(value: unknown): boolean;
18
+ Errors(value: unknown): Iterable<{ instancePath?: string; message?: string }>;
19
+ }
20
+
21
+ export function assertJsonSchemaObject(schema: unknown, label = "outputSchema"): asserts schema is JsonSchemaObject {
22
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
23
+ throw new Error(`${label} must be a JSON Schema object.`);
24
+ }
25
+ }
26
+
27
+ export function createStructuredOutputRuntime(schema: JsonSchemaObject, baseDir?: string): StructuredOutputRuntime {
28
+ assertJsonSchemaObject(schema);
29
+ const rootDir = baseDir ?? os.tmpdir();
30
+ fs.mkdirSync(rootDir, { recursive: true });
31
+ const dir = fs.mkdtempSync(path.join(rootDir, "pi-subagent-structured-"));
32
+ const schemaPath = path.join(dir, "schema.json");
33
+ const outputPath = path.join(dir, "output.json");
34
+ fs.writeFileSync(schemaPath, JSON.stringify(schema), { mode: 0o600 });
35
+ return { schema, schemaPath, outputPath };
36
+ }
37
+
38
+ export function validateStructuredOutputValue(schema: JsonSchemaObject, value: unknown): { status: "valid" } | { status: "invalid"; message: string } {
39
+ let validator: CompiledJsonSchema;
40
+ try {
41
+ validator = (Compile as (schema: unknown) => CompiledJsonSchema)(schema);
42
+ } catch (error) {
43
+ return { status: "invalid", message: `invalid outputSchema: ${error instanceof Error ? error.message : String(error)}` };
44
+ }
45
+ if (validator.Check(value)) return { status: "valid" };
46
+ const errors = [...validator.Errors(value)]
47
+ .slice(0, 8)
48
+ .map((error) => {
49
+ const pathText = error.instancePath ? error.instancePath.replace(/^\//, "").replace(/\//g, ".") : "root";
50
+ return `${pathText}: ${error.message}`;
51
+ });
52
+ return { status: "invalid", message: errors.join("; ") || "schema validation failed" };
53
+ }
54
+
55
+ export function readStructuredOutput(runtime: StructuredOutputRuntime): { value?: unknown; error?: string } {
56
+ if (!fs.existsSync(runtime.outputPath)) {
57
+ return { error: "Missing structured_output call; this step has outputSchema and must finish by calling structured_output." };
58
+ }
59
+ let value: unknown;
60
+ try {
61
+ value = JSON.parse(fs.readFileSync(runtime.outputPath, "utf-8"));
62
+ } catch (error) {
63
+ return { error: `Failed to read structured output: ${error instanceof Error ? error.message : String(error)}` };
64
+ }
65
+ const validation = validateStructuredOutputValue(runtime.schema, value);
66
+ if (validation.status === "invalid") return { error: `Structured output validation failed: ${validation.message}` };
67
+ return { value };
68
+ }
69
+
70
+ export function cleanupStructuredOutputRuntime(runtime: StructuredOutputRuntime | undefined): void {
71
+ if (!runtime) return;
72
+ try {
73
+ fs.rmSync(path.dirname(runtime.schemaPath), { recursive: true, force: true });
74
+ } catch {
75
+ // Best-effort temp cleanup.
76
+ }
77
+ }
@@ -1,10 +1,20 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
1
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
4
  import { SUBAGENT_FANOUT_CHILD_ENV } from "./pi-args.ts";
5
+ import { STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV, validateStructuredOutputValue } from "./structured-output.ts";
6
+ import type { JsonSchemaObject } from "../../shared/types.ts";
3
7
 
4
8
  const SUBAGENT_INHERIT_PROJECT_CONTEXT_ENV = "PI_SUBAGENT_INHERIT_PROJECT_CONTEXT";
5
9
  const SUBAGENT_INHERIT_SKILLS_ENV = "PI_SUBAGENT_INHERIT_SKILLS";
6
10
  export const SUBAGENT_INTERCOM_SESSION_NAME_ENV = "PI_SUBAGENT_INTERCOM_SESSION_NAME";
7
11
 
12
+ const STRUCTURED_OUTPUT_INSTRUCTIONS = [
13
+ "This subagent step has a strict structured output contract.",
14
+ "Your final action must be to call the `structured_output` tool with JSON matching the provided schema.",
15
+ "Do not rely on prose-only completion; if you do not call `structured_output`, the parent will fail this step.",
16
+ ].join("\n");
17
+
8
18
  export const CHILD_SUBAGENT_BOUNDARY_INSTRUCTIONS = [
9
19
  "You are a child subagent, not the parent orchestrator.",
10
20
  "The parent session owns delegation, orchestration, review fanout, and follow-up worker launches.",
@@ -94,7 +104,8 @@ export function rewriteSubagentPrompt(
94
104
  rewritten = stripSubagentOrchestrationSkill(rewritten);
95
105
  rewritten = stripChildBoundaryInstructions(rewritten);
96
106
  const boundary = options.fanoutChild ? CHILD_FANOUT_BOUNDARY_INSTRUCTIONS : CHILD_SUBAGENT_BOUNDARY_INSTRUCTIONS;
97
- return `${boundary}\n\n${rewritten}`;
107
+ const structured = process.env[STRUCTURED_OUTPUT_CAPTURE_ENV] ? `\n\n${STRUCTURED_OUTPUT_INSTRUCTIONS}` : "";
108
+ return `${boundary}${structured}\n\n${rewritten}`;
98
109
  }
99
110
 
100
111
  function isParentOnlySubagentMessage(message: unknown): boolean {
@@ -143,13 +154,52 @@ export function stripParentOnlySubagentMessages(messages: unknown[]): unknown[]
143
154
  }
144
155
 
145
156
  export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
146
- pi.on("context", (event) => {
157
+ const structuredOutputPath = process.env[STRUCTURED_OUTPUT_CAPTURE_ENV];
158
+ const structuredSchemaPath = process.env[STRUCTURED_OUTPUT_SCHEMA_ENV];
159
+ if (structuredOutputPath && structuredSchemaPath) {
160
+ const schema = JSON.parse(fs.readFileSync(structuredSchemaPath, "utf-8")) as JsonSchemaObject;
161
+ const parameters = {
162
+ type: "object",
163
+ properties: { value: schema },
164
+ required: ["value"],
165
+ additionalProperties: false,
166
+ };
167
+ const registerTool = pi.registerTool as unknown as (tool: {
168
+ name: string;
169
+ label: string;
170
+ description: string;
171
+ parameters: unknown;
172
+ execute: (_id: string, params: { value: unknown }) => Promise<unknown>;
173
+ }) => void;
174
+ registerTool({
175
+ name: "structured_output",
176
+ label: "Structured Output",
177
+ description: "Submit the required final structured output for this subagent step. This terminates the step.",
178
+ parameters: parameters as never,
179
+ async execute(_id: string, params: { value: unknown }) {
180
+ const validation = validateStructuredOutputValue(schema, params.value);
181
+ if (validation.status === "invalid") {
182
+ throw new Error(`Structured output validation failed: ${validation.message}`);
183
+ }
184
+ fs.mkdirSync(path.dirname(structuredOutputPath), { recursive: true });
185
+ fs.writeFileSync(structuredOutputPath, JSON.stringify(params.value), { mode: 0o600 });
186
+ return {
187
+ content: [{ type: "text", text: "Structured output captured." }],
188
+ details: { path: structuredOutputPath },
189
+ terminate: true,
190
+ };
191
+ },
192
+ });
193
+ }
194
+
195
+ const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown) => unknown) => void;
196
+ onRuntimeEvent("context", (event: { messages: unknown[] }) => {
147
197
  const messages = stripParentOnlySubagentMessages(event.messages);
148
198
  if (messages === event.messages) return undefined;
149
199
  return { messages };
150
200
  });
151
201
 
152
- pi.on("before_agent_start", async (event) => {
202
+ onRuntimeEvent("before_agent_start", async (event: { systemPrompt: string }) => {
153
203
  const intercomSessionName = process.env[SUBAGENT_INTERCOM_SESSION_NAME_ENV]?.trim();
154
204
  if (intercomSessionName && typeof pi.setSessionName === "function") {
155
205
  pi.setSessionName(intercomSessionName);
@@ -0,0 +1,206 @@
1
+ import { isDynamicParallelStep, isParallelStep, type ChainStep, type SequentialStep } from "../../shared/settings.ts";
2
+ import type { SingleResult, SubagentRunMode, WorkflowGraphNode, WorkflowGraphSnapshot, WorkflowNodeStatus } from "../../shared/types.ts";
3
+
4
+ export interface WorkflowGraphBuildInput {
5
+ runId: string;
6
+ mode?: SubagentRunMode;
7
+ steps: ChainStep[];
8
+ results?: Array<Pick<SingleResult, "exitCode" | "detached" | "interrupted" | "error" | "acceptance">>;
9
+ currentFlatIndex?: number;
10
+ currentStepIndex?: number;
11
+ stepStatuses?: Array<{ status?: string; error?: string }>;
12
+ dynamicChildren?: Record<number, Array<{ agent: string; label?: string; flatIndex: number; itemKey: string; outputName?: string; structured?: boolean; error?: string }>>;
13
+ dynamicGroupStatuses?: Record<number, { status: WorkflowNodeStatus; error?: string; acceptance?: SingleResult["acceptance"] }>;
14
+ }
15
+
16
+ function normalizeStatus(status: string | undefined): WorkflowNodeStatus | undefined {
17
+ switch (status) {
18
+ case "complete":
19
+ case "completed":
20
+ return "completed";
21
+ case "running":
22
+ return "running";
23
+ case "failed":
24
+ return "failed";
25
+ case "paused":
26
+ return "paused";
27
+ case "detached":
28
+ return "detached";
29
+ case "pending":
30
+ return "pending";
31
+ default:
32
+ return undefined;
33
+ }
34
+ }
35
+
36
+ function resultStatus(result: Pick<SingleResult, "exitCode" | "detached" | "interrupted"> | undefined): WorkflowNodeStatus | undefined {
37
+ if (!result) return undefined;
38
+ if (result.detached) return "detached";
39
+ if (result.interrupted) return "paused";
40
+ return result.exitCode === 0 ? "completed" : "failed";
41
+ }
42
+
43
+ function nodeStatus(input: WorkflowGraphBuildInput, flatIndex: number): WorkflowNodeStatus {
44
+ return normalizeStatus(input.stepStatuses?.[flatIndex]?.status)
45
+ ?? resultStatus(input.results?.[flatIndex])
46
+ ?? (input.currentFlatIndex === flatIndex ? "running" : "pending");
47
+ }
48
+
49
+ function pushPhase(phases: WorkflowGraphSnapshot["phases"], phase: string | undefined, nodeId: string): void {
50
+ if (!phase) return;
51
+ let group = phases.find((candidate) => candidate.title === phase);
52
+ if (!group) {
53
+ group = { title: phase, nodeIds: [] };
54
+ phases.push(group);
55
+ }
56
+ group.nodeIds.push(nodeId);
57
+ }
58
+
59
+ function seqLabel(step: SequentialStep, stepIndex: number): string {
60
+ return step.label?.trim() || step.agent || `Step ${stepIndex + 1}`;
61
+ }
62
+
63
+ function summarizeParallelStatuses(statuses: WorkflowNodeStatus[]): WorkflowNodeStatus {
64
+ if (statuses.some((status) => status === "running")) return "running";
65
+ if (statuses.some((status) => status === "failed")) return "failed";
66
+ if (statuses.some((status) => status === "paused")) return "paused";
67
+ if (statuses.some((status) => status === "detached")) return "detached";
68
+ if (statuses.length > 0 && statuses.every((status) => status === "completed")) return "completed";
69
+ if (statuses.some((status) => status === "completed")) return "running";
70
+ return "pending";
71
+ }
72
+
73
+ export function buildWorkflowGraphSnapshot(input: WorkflowGraphBuildInput): WorkflowGraphSnapshot {
74
+ const nodes: WorkflowGraphNode[] = [];
75
+ const phases: WorkflowGraphSnapshot["phases"] = [];
76
+ let flatIndex = 0;
77
+ let currentNodeId: string | undefined;
78
+
79
+ for (let stepIndex = 0; stepIndex < input.steps.length; stepIndex++) {
80
+ const step = input.steps[stepIndex]!;
81
+ if (isParallelStep(step)) {
82
+ const groupId = `step-${stepIndex}`;
83
+ const children: WorkflowGraphNode[] = [];
84
+ const childStatuses: WorkflowNodeStatus[] = [];
85
+ for (let taskIndex = 0; taskIndex < step.parallel.length; taskIndex++) {
86
+ const task = step.parallel[taskIndex]!;
87
+ const status = nodeStatus(input, flatIndex);
88
+ childStatuses.push(status);
89
+ const childId = `step-${stepIndex}-agent-${taskIndex}`;
90
+ const child: WorkflowGraphNode = {
91
+ id: childId,
92
+ kind: "agent",
93
+ agent: task.agent,
94
+ phase: task.phase,
95
+ label: task.label?.trim() || task.agent || `Agent ${taskIndex + 1}`,
96
+ status,
97
+ flatIndex,
98
+ stepIndex,
99
+ outputName: task.as,
100
+ structured: Boolean(task.outputSchema),
101
+ acceptanceStatus: input.results?.[flatIndex]?.acceptance?.status,
102
+ error: input.stepStatuses?.[flatIndex]?.error ?? input.results?.[flatIndex]?.error,
103
+ };
104
+ children.push(child);
105
+ pushPhase(phases, task.phase, childId);
106
+ if (status === "running" || input.currentFlatIndex === flatIndex) currentNodeId = childId;
107
+ flatIndex++;
108
+ }
109
+ const groupStatus = summarizeParallelStatuses(childStatuses);
110
+ if (input.currentStepIndex === stepIndex && !currentNodeId) currentNodeId = groupId;
111
+ nodes.push({
112
+ id: groupId,
113
+ kind: "parallel-group",
114
+ label: step.parallel.length === 1 ? "Parallel task" : `Parallel group (${step.parallel.length})`,
115
+ status: groupStatus,
116
+ stepIndex,
117
+ children,
118
+ });
119
+ continue;
120
+ }
121
+
122
+ if (isDynamicParallelStep(step)) {
123
+ const groupId = `step-${stepIndex}`;
124
+ const materialized = input.dynamicChildren?.[stepIndex] ?? [];
125
+ const groupOverride = input.dynamicGroupStatuses?.[stepIndex];
126
+ const children: WorkflowGraphNode[] = [];
127
+ const childStatuses: WorkflowNodeStatus[] = [];
128
+ for (let taskIndex = 0; taskIndex < materialized.length; taskIndex++) {
129
+ const task = materialized[taskIndex]!;
130
+ const status = nodeStatus(input, task.flatIndex);
131
+ childStatuses.push(status);
132
+ const childId = `step-${stepIndex}-item-${task.itemKey.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
133
+ const child: WorkflowGraphNode = {
134
+ id: childId,
135
+ kind: "agent",
136
+ agent: task.agent,
137
+ phase: step.parallel.phase ?? step.phase,
138
+ label: task.label?.trim() || step.parallel.label?.trim() || `${task.agent} ${task.itemKey}`,
139
+ status,
140
+ flatIndex: task.flatIndex,
141
+ stepIndex,
142
+ itemKey: task.itemKey,
143
+ outputName: task.outputName,
144
+ structured: task.structured,
145
+ acceptanceStatus: input.results?.[task.flatIndex]?.acceptance?.status,
146
+ error: input.stepStatuses?.[task.flatIndex]?.error ?? input.results?.[task.flatIndex]?.error ?? task.error,
147
+ };
148
+ children.push(child);
149
+ pushPhase(phases, child.phase, childId);
150
+ if (status === "running" || input.currentFlatIndex === task.flatIndex) currentNodeId = childId;
151
+ }
152
+ const groupStatus = groupOverride?.status ?? (children.length > 0 ? summarizeParallelStatuses(childStatuses) : (input.currentStepIndex === stepIndex ? "running" : "pending"));
153
+ if (input.currentStepIndex === stepIndex && !currentNodeId) currentNodeId = groupId;
154
+ nodes.push({
155
+ id: groupId,
156
+ kind: "dynamic-parallel-group",
157
+ label: step.label?.trim() || step.parallel.label?.trim() || `Dynamic fanout (${step.collect.as})`,
158
+ status: groupStatus,
159
+ stepIndex,
160
+ outputName: step.collect.as,
161
+ structured: Boolean(step.collect.outputSchema),
162
+ acceptanceStatus: groupOverride?.acceptance?.status,
163
+ error: groupOverride?.error,
164
+ dynamic: {
165
+ sourceOutput: step.expand.from.output,
166
+ sourcePath: step.expand.from.path,
167
+ itemName: step.expand.item ?? "item",
168
+ maxItems: step.expand.maxItems,
169
+ collectAs: step.collect.as,
170
+ },
171
+ children,
172
+ });
173
+ if (materialized.length > 0) flatIndex = Math.max(flatIndex, ...materialized.map((child) => child.flatIndex + 1));
174
+ continue;
175
+ }
176
+
177
+ const seq = step as SequentialStep;
178
+ const status = nodeStatus(input, flatIndex);
179
+ const id = `step-${stepIndex}`;
180
+ nodes.push({
181
+ id,
182
+ kind: "step",
183
+ agent: seq.agent,
184
+ phase: seq.phase,
185
+ label: seqLabel(seq, stepIndex),
186
+ status,
187
+ flatIndex,
188
+ stepIndex,
189
+ outputName: seq.as,
190
+ structured: Boolean(seq.outputSchema),
191
+ acceptanceStatus: input.results?.[flatIndex]?.acceptance?.status,
192
+ error: input.stepStatuses?.[flatIndex]?.error ?? input.results?.[flatIndex]?.error,
193
+ });
194
+ pushPhase(phases, seq.phase, id);
195
+ if (status === "running" || input.currentFlatIndex === flatIndex || input.currentStepIndex === stepIndex) currentNodeId = id;
196
+ flatIndex++;
197
+ }
198
+
199
+ return {
200
+ runId: input.runId,
201
+ mode: input.mode ?? "chain",
202
+ phases,
203
+ nodes,
204
+ currentNodeId,
205
+ };
206
+ }
@@ -6,7 +6,7 @@ import * as fs from "node:fs";
6
6
  import * as path from "node:path";
7
7
  import type { Usage, SingleResult } from "./types.ts";
8
8
  import type { ChainStep } from "./settings.ts";
9
- import { isParallelStep } from "./settings.ts";
9
+ import { isDynamicParallelStep, isParallelStep } from "./settings.ts";
10
10
  import { splitKnownThinkingSuffix, THINKING_LEVELS } from "./model-info.ts";
11
11
 
12
12
  /**
@@ -63,7 +63,7 @@ export function buildChainSummary(
63
63
  failedStep?: { index: number; error: string },
64
64
  ): string {
65
65
  const stepNames = steps
66
- .map((step) => (isParallelStep(step) ? `parallel[${step.parallel.length}]` : step.agent))
66
+ .map((step) => (isParallelStep(step) ? `parallel[${step.parallel.length}]` : isDynamicParallelStep(step) ? `expand:${step.parallel.agent}` : step.agent))
67
67
  .join(" → ");
68
68
 
69
69
  const totalDuration = results.reduce((sum, r) => sum + (r.progress?.durationMs || 0), 0);
@@ -6,7 +6,7 @@ import * as fs from "node:fs";
6
6
  import * as path from "node:path";
7
7
  import type { AgentConfig } from "../agents/agents.ts";
8
8
  import { normalizeSkillInput } from "../agents/skills.ts";
9
- import { CHAIN_RUNS_DIR, type OutputMode } from "./types.ts";
9
+ import { CHAIN_RUNS_DIR, type AcceptanceInput, type JsonSchemaObject, type OutputMode } from "./types.ts";
10
10
  const CHAIN_DIR_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
11
11
  const INITIAL_PROGRESS_CONTENT = "# Progress\n\n## Status\nIn Progress\n\n## Tasks\n\n## Files Changed\n\n## Notes\n";
12
12
 
@@ -44,6 +44,10 @@ function normalizeOutputOverride(output: string | false | undefined): string | f
44
44
  export interface SequentialStep {
45
45
  agent: string;
46
46
  task?: string;
47
+ phase?: string;
48
+ label?: string;
49
+ as?: string;
50
+ outputSchema?: JsonSchemaObject;
47
51
  cwd?: string;
48
52
  output?: string | false;
49
53
  outputMode?: OutputMode;
@@ -51,12 +55,17 @@ export interface SequentialStep {
51
55
  progress?: boolean;
52
56
  skill?: string | string[] | false;
53
57
  model?: string;
58
+ acceptance?: AcceptanceInput;
54
59
  }
55
60
 
56
61
  /** Parallel task item within a parallel step */
57
- interface ParallelTaskItem {
62
+ export interface ParallelTaskItem {
58
63
  agent: string;
59
64
  task?: string;
65
+ phase?: string;
66
+ label?: string;
67
+ as?: string;
68
+ outputSchema?: JsonSchemaObject;
60
69
  cwd?: string;
61
70
  count?: number;
62
71
  output?: string | false;
@@ -65,18 +74,48 @@ interface ParallelTaskItem {
65
74
  progress?: boolean;
66
75
  skill?: string | string[] | false;
67
76
  model?: string;
77
+ acceptance?: AcceptanceInput;
78
+ }
79
+
80
+ export interface DynamicExpandSpec {
81
+ from: {
82
+ output: string;
83
+ path: string;
84
+ };
85
+ item?: string;
86
+ key?: string;
87
+ maxItems?: number;
88
+ onEmpty?: "skip" | "fail";
89
+ }
90
+
91
+ export type DynamicParallelTemplate = Omit<ParallelTaskItem, "as" | "count">;
92
+
93
+ export interface DynamicCollectSpec {
94
+ as: string;
95
+ outputSchema?: JsonSchemaObject;
96
+ }
97
+
98
+ export interface DynamicParallelStep {
99
+ expand: DynamicExpandSpec;
100
+ parallel: DynamicParallelTemplate;
101
+ collect: DynamicCollectSpec;
102
+ concurrency?: number;
103
+ failFast?: boolean;
104
+ phase?: string;
105
+ label?: string;
68
106
  }
69
107
 
70
108
  /** Parallel step: multiple agents running concurrently */
71
- interface ParallelStep {
109
+ export interface ParallelStep {
72
110
  parallel: ParallelTaskItem[];
73
111
  concurrency?: number;
74
112
  failFast?: boolean;
75
113
  worktree?: boolean;
114
+ cwd?: string;
76
115
  }
77
116
 
78
117
  /** Union type for chain steps */
79
- export type ChainStep = SequentialStep | ParallelStep;
118
+ export type ChainStep = SequentialStep | ParallelStep | DynamicParallelStep;
80
119
 
81
120
  // =============================================================================
82
121
  // Type Guards
@@ -86,11 +125,18 @@ export function isParallelStep(step: ChainStep): step is ParallelStep {
86
125
  return "parallel" in step && Array.isArray((step as ParallelStep).parallel);
87
126
  }
88
127
 
128
+ export function isDynamicParallelStep(step: ChainStep): step is DynamicParallelStep {
129
+ return "expand" in step && "collect" in step && "parallel" in step && !Array.isArray((step as { parallel?: unknown }).parallel);
130
+ }
131
+
89
132
  /** Get all agent names in a step (single for sequential, multiple for parallel) */
90
133
  export function getStepAgents(step: ChainStep): string[] {
91
134
  if (isParallelStep(step)) {
92
135
  return step.parallel.map((t) => t.agent);
93
136
  }
137
+ if (isDynamicParallelStep(step)) {
138
+ return [step.parallel.agent];
139
+ }
94
140
  return [step.agent];
95
141
  }
96
142
 
@@ -160,6 +206,9 @@ export function resolveChainTemplates(
160
206
  return "{previous}";
161
207
  });
162
208
  }
209
+ if (isDynamicParallelStep(step)) {
210
+ return step.parallel.task ?? "{previous}";
211
+ }
163
212
  // Sequential step: existing logic
164
213
  const seq = step as SequentialStep;
165
214
  if (seq.task) return seq.task;