pi-subagents 0.24.4 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +145 -27
  3. package/package.json +1 -1
  4. package/prompts/parallel-context-build.md +3 -1
  5. package/prompts/parallel-handoff-plan.md +3 -1
  6. package/prompts/review-loop.md +1 -1
  7. package/skills/pi-subagents/SKILL.md +71 -20
  8. package/src/agents/agent-management.ts +57 -15
  9. package/src/agents/agent-serializer.ts +3 -2
  10. package/src/agents/agents.ts +47 -16
  11. package/src/agents/chain-serializer.ts +120 -0
  12. package/src/extension/fanout-child.ts +171 -0
  13. package/src/extension/index.ts +7 -2
  14. package/src/extension/schemas.ts +138 -5
  15. package/src/intercom/result-intercom.ts +108 -0
  16. package/src/runs/background/async-execution.ts +185 -10
  17. package/src/runs/background/async-job-tracker.ts +41 -6
  18. package/src/runs/background/async-resume.ts +28 -15
  19. package/src/runs/background/async-status.ts +71 -31
  20. package/src/runs/background/result-watcher.ts +111 -54
  21. package/src/runs/background/run-id-resolver.ts +83 -0
  22. package/src/runs/background/run-status.ts +89 -4
  23. package/src/runs/background/stale-run-reconciler.ts +46 -1
  24. package/src/runs/background/subagent-runner.ts +648 -42
  25. package/src/runs/foreground/chain-execution.ts +331 -118
  26. package/src/runs/foreground/execution.ts +226 -10
  27. package/src/runs/foreground/subagent-executor.ts +377 -14
  28. package/src/runs/shared/acceptance-contract.ts +291 -0
  29. package/src/runs/shared/acceptance-evaluation.ts +221 -0
  30. package/src/runs/shared/acceptance-finalization.ts +161 -0
  31. package/src/runs/shared/acceptance-reports.ts +127 -0
  32. package/src/runs/shared/acceptance.ts +22 -0
  33. package/src/runs/shared/chain-outputs.ts +101 -0
  34. package/src/runs/shared/completion-guard.ts +26 -3
  35. package/src/runs/shared/dynamic-fanout.ts +293 -0
  36. package/src/runs/shared/nested-events.ts +819 -0
  37. package/src/runs/shared/nested-path.ts +52 -0
  38. package/src/runs/shared/nested-render.ts +115 -0
  39. package/src/runs/shared/parallel-utils.ts +31 -1
  40. package/src/runs/shared/pi-args.ts +73 -5
  41. package/src/runs/shared/structured-output.ts +77 -0
  42. package/src/runs/shared/subagent-prompt-runtime.ts +77 -7
  43. package/src/runs/shared/workflow-graph.ts +206 -0
  44. package/src/shared/formatters.ts +2 -2
  45. package/src/shared/settings.ts +53 -4
  46. package/src/shared/types.ts +345 -0
  47. package/src/slash/slash-commands.ts +41 -3
  48. package/src/tui/render.ts +268 -43
@@ -1,6 +1,9 @@
1
1
  import type { ChainConfig, ChainStepConfig } from "./agents.ts";
2
2
  import { buildRuntimeName, frontmatterNameForConfig, parsePackageName } from "./identity.ts";
3
3
  import { parseFrontmatter } from "./frontmatter.ts";
4
+ import { ChainOutputValidationError, validateChainOutputBindings } from "../runs/shared/chain-outputs.ts";
5
+ import { validateAcceptanceInput } from "../runs/shared/acceptance.ts";
6
+ import type { ChainStep } from "../shared/settings.ts";
4
7
 
5
8
  function parseStepBody(agent: string, sectionBody: string): ChainStepConfig {
6
9
  const lines = sectionBody.split("\n");
@@ -20,6 +23,25 @@ function parseStepBody(agent: string, sectionBody: string): ChainStepConfig {
20
23
  else if (rawValue) step.output = rawValue;
21
24
  continue;
22
25
  }
26
+ if (key === "phase") {
27
+ if (rawValue) step.phase = rawValue;
28
+ continue;
29
+ }
30
+ if (key === "label") {
31
+ if (rawValue) step.label = rawValue;
32
+ continue;
33
+ }
34
+ if (key === "as") {
35
+ if (rawValue) step.as = rawValue;
36
+ continue;
37
+ }
38
+ if (key === "outputschema") {
39
+ if (rawValue.startsWith("{") || rawValue.startsWith("[")) {
40
+ throw new Error("Inline outputSchema values are not supported in .chain.md files; use a schema file path.");
41
+ }
42
+ if (rawValue) step.outputSchema = rawValue;
43
+ continue;
44
+ }
23
45
  if (key === "outputmode") {
24
46
  if (rawValue === "inline" || rawValue === "file-only") step.outputMode = rawValue;
25
47
  continue;
@@ -102,6 +124,100 @@ export function parseChain(content: string, source: "user" | "project", filePath
102
124
  };
103
125
  }
104
126
 
127
+ export function parseJsonChain(content: string, source: "user" | "project", filePath: string): ChainConfig {
128
+ let parsed: unknown;
129
+ try {
130
+ parsed = JSON.parse(content);
131
+ } catch (error) {
132
+ const message = error instanceof Error ? error.message : String(error);
133
+ throw new Error(`Invalid JSON chain '${filePath}': ${message}`);
134
+ }
135
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
136
+ throw new Error(`JSON chain '${filePath}' must contain an object root.`);
137
+ }
138
+ const input = parsed as Record<string, unknown>;
139
+ if (typeof input.name !== "string" || !input.name.trim()) {
140
+ throw new Error(`JSON chain '${filePath}' must include string name.`);
141
+ }
142
+ if (typeof input.description !== "string" || !input.description.trim()) {
143
+ throw new Error(`JSON chain '${filePath}' must include string description.`);
144
+ }
145
+ if (!Array.isArray(input.chain)) {
146
+ throw new Error(`JSON chain '${filePath}' must include array chain.`);
147
+ }
148
+ for (let i = 0; i < input.chain.length; i++) {
149
+ const step = input.chain[i];
150
+ if (!step || typeof step !== "object" || Array.isArray(step)) {
151
+ throw new Error(`JSON chain '${filePath}' step ${i + 1} must be an object.`);
152
+ }
153
+ const stepRecord = step as Record<string, unknown>;
154
+ const parallel = stepRecord.parallel;
155
+ if (Array.isArray(parallel) && Object.hasOwn(stepRecord, "acceptance")) {
156
+ throw new Error(`Invalid JSON chain '${filePath}': step ${i + 1} acceptance is not supported on static parallel groups; set acceptance on each parallel task.`);
157
+ }
158
+ if (parallel && typeof parallel === "object" && !Array.isArray(parallel) && Object.hasOwn(stepRecord, "acceptance")) {
159
+ throw new Error(`Invalid JSON chain '${filePath}': step ${i + 1} acceptance is not supported on dynamic fanout groups; set acceptance on the dynamic template.`);
160
+ }
161
+ const acceptanceErrors = validateAcceptanceInput(stepRecord.acceptance, `step ${i + 1} acceptance`);
162
+ if (acceptanceErrors.length > 0) {
163
+ throw new Error(`Invalid JSON chain '${filePath}': ${acceptanceErrors.join(" ")}`);
164
+ }
165
+ if (Array.isArray(parallel)) {
166
+ for (let taskIndex = 0; taskIndex < parallel.length; taskIndex++) {
167
+ const task = parallel[taskIndex];
168
+ if (!task || typeof task !== "object" || Array.isArray(task)) continue;
169
+ const taskErrors = validateAcceptanceInput((task as Record<string, unknown>).acceptance, `step ${i + 1} parallel task ${taskIndex + 1} acceptance`);
170
+ if (taskErrors.length > 0) {
171
+ throw new Error(`Invalid JSON chain '${filePath}': ${taskErrors.join(" ")}`);
172
+ }
173
+ }
174
+ } else if (parallel && typeof parallel === "object") {
175
+ const templateErrors = validateAcceptanceInput((parallel as Record<string, unknown>).acceptance, `step ${i + 1} dynamic template acceptance`);
176
+ if (templateErrors.length > 0) {
177
+ throw new Error(`Invalid JSON chain '${filePath}': ${templateErrors.join(" ")}`);
178
+ }
179
+ }
180
+ }
181
+ try {
182
+ validateChainOutputBindings(input.chain as ChainStep[], { maxItems: Number.MAX_SAFE_INTEGER });
183
+ } catch (error) {
184
+ if (error instanceof ChainOutputValidationError) throw new Error(`Invalid JSON chain '${filePath}': ${error.message}`);
185
+ throw error;
186
+ }
187
+ const parsedPackage = parsePackageName(typeof input.package === "string" ? input.package : undefined, `Chain '${input.name}' package`);
188
+ if (parsedPackage.error) throw new Error(parsedPackage.error);
189
+ const extraFields: Record<string, string> = {};
190
+ for (const [key, value] of Object.entries(input)) {
191
+ if (key === "name" || key === "package" || key === "description" || key === "chain") continue;
192
+ if (typeof value === "string") extraFields[key] = value;
193
+ }
194
+ return {
195
+ name: buildRuntimeName(input.name.trim(), parsedPackage.packageName),
196
+ localName: input.name.trim(),
197
+ packageName: parsedPackage.packageName,
198
+ description: input.description.trim(),
199
+ source,
200
+ filePath,
201
+ steps: input.chain as ChainStepConfig[],
202
+ extraFields: Object.keys(extraFields).length > 0 ? extraFields : undefined,
203
+ };
204
+ }
205
+
206
+ export function serializeJsonChain(config: ChainConfig): string {
207
+ const root: Record<string, unknown> = {
208
+ name: frontmatterNameForConfig(config),
209
+ description: config.description,
210
+ chain: config.steps,
211
+ };
212
+ if (config.packageName) root.package = config.packageName;
213
+ if (config.extraFields) {
214
+ for (const [key, value] of Object.entries(config.extraFields)) {
215
+ if (key !== "name" && key !== "description" && key !== "package" && key !== "chain") root[key] = value;
216
+ }
217
+ }
218
+ return `${JSON.stringify(root, null, 2)}\n`;
219
+ }
220
+
105
221
  export function serializeChain(config: ChainConfig): string {
106
222
  const lines: string[] = [];
107
223
  lines.push("---");
@@ -121,6 +237,10 @@ export function serializeChain(config: ChainConfig): string {
121
237
  lines.push(`## ${step.agent}`);
122
238
  if (step.output === false) lines.push("output: false");
123
239
  else if (step.output) lines.push(`output: ${step.output}`);
240
+ if (step.phase) lines.push(`phase: ${step.phase}`);
241
+ if (step.label) lines.push(`label: ${step.label}`);
242
+ if (step.as) lines.push(`as: ${step.as}`);
243
+ if (step.outputSchema) lines.push(`outputSchema: ${step.outputSchema}`);
124
244
  if (step.outputMode) lines.push(`outputMode: ${step.outputMode}`);
125
245
  if (step.reads === false) lines.push("reads: false");
126
246
  else if (Array.isArray(step.reads) && step.reads.length > 0) lines.push(`reads: ${step.reads.join(", ")}`);
@@ -0,0 +1,171 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
5
+ import { discoverAgents } from "../agents/agents.ts";
6
+ import { getArtifactsDir } from "../shared/artifacts.ts";
7
+ import { createSubagentExecutor, type SubagentParamsLike } from "../runs/foreground/subagent-executor.ts";
8
+ import { SUBAGENT_CHILD_ENV, SUBAGENT_FANOUT_CHILD_ENV } from "../runs/shared/pi-args.ts";
9
+ import { readNestedControlRequests, resolveNestedRouteFromEnv, writeNestedControlResult } from "../runs/shared/nested-events.ts";
10
+ import { deliverSubagentIntercomMessageEvent } from "../intercom/result-intercom.ts";
11
+ import { resolveSubagentIntercomTarget } from "../intercom/intercom-bridge.ts";
12
+ import { SubagentParams } from "./schemas.ts";
13
+ import { loadConfig } from "./config.ts";
14
+ import { type Details, type SubagentState } from "../shared/types.ts";
15
+
16
+ function getSubagentSessionRoot(parentSessionFile: string | null): string {
17
+ if (parentSessionFile) {
18
+ const baseName = path.basename(parentSessionFile, ".jsonl");
19
+ const sessionsDir = path.dirname(parentSessionFile);
20
+ return path.join(sessionsDir, baseName);
21
+ }
22
+ return fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-session-"));
23
+ }
24
+
25
+ function expandTilde(p: string): string {
26
+ return p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p;
27
+ }
28
+
29
+ function createChildSafeState(): SubagentState {
30
+ return {
31
+ baseCwd: "",
32
+ currentSessionId: null,
33
+ asyncJobs: new Map(),
34
+ foregroundRuns: new Map(),
35
+ foregroundControls: new Map(),
36
+ lastForegroundControlId: null,
37
+ pendingForegroundControlNotices: new Map(),
38
+ cleanupTimers: new Map(),
39
+ lastUiContext: null,
40
+ poller: null,
41
+ completionSeen: new Map(),
42
+ watcher: null,
43
+ watcherRestartTimer: null,
44
+ resultFileCoalescer: {
45
+ schedule: () => false,
46
+ clear: () => {},
47
+ },
48
+ };
49
+ }
50
+
51
+ function startNestedControlInboxListener(pi: ExtensionAPI, state: SubagentState): NodeJS.Timeout | undefined {
52
+ let route;
53
+ try {
54
+ route = resolveNestedRouteFromEnv();
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ if (!route) return undefined;
59
+ const seen = new Set<string>();
60
+ const inFlight = new Set<string>();
61
+ const pendingResults = new Map<string, Parameters<typeof writeNestedControlResult>[1]>();
62
+ const timer = setInterval(() => {
63
+ try {
64
+ for (const request of readNestedControlRequests(route)) {
65
+ if (seen.has(request.requestId) || inFlight.has(request.requestId)) continue;
66
+ inFlight.add(request.requestId);
67
+ void (async () => {
68
+ try {
69
+ let result = pendingResults.get(request.requestId);
70
+ if (!result) {
71
+ let ok = false;
72
+ let message = "Control request failed.";
73
+ try {
74
+ const control = state.foregroundControls.get(request.targetRunId);
75
+ if (!control) {
76
+ message = `Nested run ${request.targetRunId} is not active in this fanout child.`;
77
+ } else if (request.action === "interrupt") {
78
+ ok = control.interrupt?.() === true;
79
+ message = ok
80
+ ? `Interrupt requested for nested run ${request.targetRunId}.`
81
+ : `Nested run ${request.targetRunId} has no active child step to interrupt.`;
82
+ } else if (!request.message?.trim()) {
83
+ message = "Nested resume requires message.";
84
+ } else if (!control.currentAgent) {
85
+ message = `Nested run ${request.targetRunId} has no active child message route.`;
86
+ } else {
87
+ const index = control.currentIndex ?? 0;
88
+ const target = resolveSubagentIntercomTarget(request.targetRunId, control.currentAgent, index);
89
+ ok = await deliverSubagentIntercomMessageEvent(
90
+ pi.events,
91
+ target,
92
+ `Follow-up for nested run ${request.targetRunId} (${control.currentAgent}):\n\n${request.message.trim()}`,
93
+ 500,
94
+ { source: "nested-resume", runId: request.targetRunId, agent: control.currentAgent, index },
95
+ );
96
+ message = ok
97
+ ? `Delivered follow-up to live nested run ${request.targetRunId}.`
98
+ : `Nested child intercom target is not registered: ${target}`;
99
+ }
100
+ } catch (error) {
101
+ message = error instanceof Error ? error.message : String(error);
102
+ }
103
+ result = { ts: Date.now(), requestId: request.requestId, targetRunId: request.targetRunId, ok, message };
104
+ }
105
+ try {
106
+ writeNestedControlResult(route, result);
107
+ } catch (error) {
108
+ pendingResults.set(request.requestId, result);
109
+ console.error(`Failed to write nested control result for request '${request.requestId}' targeting '${request.targetRunId}' via inbox '${route.controlInbox}'; keeping request for retry:`, error);
110
+ return;
111
+ }
112
+ pendingResults.delete(request.requestId);
113
+ seen.add(request.requestId);
114
+ try { fs.unlinkSync(request.filePath); } catch {}
115
+ } finally {
116
+ inFlight.delete(request.requestId);
117
+ }
118
+ })();
119
+ }
120
+ } catch (error) {
121
+ console.error(`Failed to poll nested control inbox '${route.controlInbox}' for root '${route.rootRunId}':`, error);
122
+ }
123
+ }, 200);
124
+ timer.unref?.();
125
+ return timer;
126
+ }
127
+
128
+ export default function registerFanoutChildSubagentExtension(pi: ExtensionAPI): void {
129
+ if (process.env[SUBAGENT_CHILD_ENV] !== "1" || process.env[SUBAGENT_FANOUT_CHILD_ENV] !== "1") return;
130
+
131
+ const globalStore = globalThis as Record<string, unknown>;
132
+ const registeredKey = "__piSubagentFanoutChildRegisteredApis";
133
+ const registeredApis = globalStore[registeredKey] instanceof WeakSet
134
+ ? globalStore[registeredKey] as WeakSet<ExtensionAPI>
135
+ : new WeakSet<ExtensionAPI>();
136
+ globalStore[registeredKey] = registeredApis;
137
+ if (registeredApis.has(pi)) return;
138
+ registeredApis.add(pi);
139
+
140
+ const config = loadConfig();
141
+ const state = createChildSafeState();
142
+ const executor = createSubagentExecutor({
143
+ pi,
144
+ state,
145
+ config,
146
+ asyncByDefault: config.asyncByDefault === true,
147
+ tempArtifactsDir: getArtifactsDir(null),
148
+ getSubagentSessionRoot,
149
+ expandTilde,
150
+ discoverAgents,
151
+ allowMutatingManagementActions: false,
152
+ });
153
+
154
+ const tool: ToolDefinition<typeof SubagentParams, Details> = {
155
+ name: "subagent",
156
+ label: "Subagent",
157
+ description: [
158
+ "Delegate to subagents from child-safe fanout mode.",
159
+ "For goal-style requests such as /goal, goal, active goal, or work until evidence says done, use explicit acceptance on the delegated run: criteria for the target, evidence/verify for proof, stopRules for constraints, and maxFinalizationTurns for the bounded loop.",
160
+ "Allowed management/control actions: list, get, status, interrupt, resume, doctor.",
161
+ "Agent config mutation actions create, update, and delete are blocked in this mode.",
162
+ ].join("\n"),
163
+ parameters: SubagentParams,
164
+ execute(id, params, signal, onUpdate, ctx) {
165
+ return executor.execute(id, params as SubagentParamsLike, signal, onUpdate, ctx);
166
+ },
167
+ };
168
+
169
+ pi.registerTool(tool);
170
+ startNestedControlInboxListener(pi, state);
171
+ }
@@ -33,7 +33,8 @@ import { registerSlashSubagentBridge } from "../slash/slash-bridge.ts";
33
33
  import { clearSlashSnapshots, getSlashRenderableSnapshot, resolveSlashMessageDetails, restoreSlashFinalSnapshots, type SlashMessageDetails } from "../slash/slash-live-state.ts";
34
34
  import { inspectSubagentStatus } from "../runs/background/run-status.ts";
35
35
  import registerSubagentNotify, { type SubagentNotifyDetails } from "../runs/background/notify.ts";
36
- import { SUBAGENT_CHILD_ENV } from "../runs/shared/pi-args.ts";
36
+ import { SUBAGENT_CHILD_ENV, SUBAGENT_FANOUT_CHILD_ENV } from "../runs/shared/pi-args.ts";
37
+ import registerFanoutChildSubagentExtension from "./fanout-child.ts";
37
38
  import { formatDuration, shortenPath } from "../shared/formatters.ts";
38
39
  import { loadConfig } from "./config.ts";
39
40
  import {
@@ -207,7 +208,10 @@ class SubagentControlNoticeComponent implements Component {
207
208
  }
208
209
 
209
210
  export default function registerSubagentExtension(pi: ExtensionAPI): void {
210
- if (process.env[SUBAGENT_CHILD_ENV] === "1") return;
211
+ if (process.env[SUBAGENT_CHILD_ENV] === "1") {
212
+ if (process.env[SUBAGENT_FANOUT_CHILD_ENV] === "1") registerFanoutChildSubagentExtension(pi);
213
+ return;
214
+ }
211
215
  const globalStore = globalThis as Record<string, unknown>;
212
216
  const runtimeCleanupStoreKey = "__piSubagentRuntimeCleanup";
213
217
  const previousRuntimeCleanup = globalStore[runtimeCleanupStoreKey];
@@ -391,6 +395,7 @@ EXECUTION (use exactly ONE mode):
391
395
  • CHAIN: { chain: [{agent:"agent-a"}, {parallel:[{agent:"agent-b",count:3}]}] } - sequential pipeline with optional parallel fan-out
392
396
  • PARALLEL: { tasks: [{agent,task,count?,output?,reads?,progress?}, ...], concurrency?: number, worktree?: true } - concurrent execution (worktree: isolate each task in a git worktree)
393
397
  • Optional context: { context: "fresh" | "fork" } (default: if any requested agent has defaultContext: "fork", the whole invocation uses fork; otherwise "fresh"; inspect agent defaults via { action: "list" })
398
+ • Goal-style requests: when the user says “/goal”, “goal”, “active goal”, “work until evidence says done”, or “verify against a goal”, model that as explicit acceptance. Use acceptance.criteria for the target, acceptance.evidence/verify for proof, acceptance.stopRules for constraints, and acceptance.maxFinalizationTurns for the bounded loop.
394
399
 
395
400
  CHAIN TEMPLATE VARIABLES (use in task strings):
396
401
  • {task} - The original task/request from the user
@@ -35,9 +35,82 @@ 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
+ allOf: [{
100
+ anyOf: [
101
+ { required: ["criteria"] },
102
+ { required: ["evidence"] },
103
+ { required: ["verify"] },
104
+ { required: ["review"] },
105
+ { required: ["stopRules"] },
106
+ ],
107
+ }],
108
+ description: "Optional acceptance contract. Use this for goal-style requests such as /goal, goal, active goal, or work until evidence says done: criteria define the target, evidence/verify define proof, stopRules define constraints, and maxFinalizationTurns defines the bounded loop. When present, the child must complete a same-session self-review/repair loop before acceptance is evaluated.",
109
+ });
110
+
38
111
  const TaskItem = Type.Object({
39
- agent: Type.String(),
40
- task: Type.String(),
112
+ agent: Type.String(),
113
+ task: Type.String(),
41
114
  cwd: Type.Optional(Type.String()),
42
115
  count: Type.Optional(Type.Integer({ minimum: 1, description: "Repeat this parallel task N times with the same settings." })),
43
116
  output: Type.Optional(OutputOverride),
@@ -46,12 +119,17 @@ const TaskItem = Type.Object({
46
119
  progress: Type.Optional(Type.Boolean({ description: "Enable progress.md tracking for this task" })),
47
120
  model: Type.Optional(Type.String({ description: "Override model for this task (e.g. 'google/gemini-3-pro')" })),
48
121
  skill: Type.Optional(SkillOverride),
122
+ acceptance: Type.Optional(AcceptanceOverride),
49
123
  });
50
124
 
51
125
  // Parallel task item (within a parallel step)
52
126
  const ParallelTaskSchema = Type.Object({
53
127
  agent: Type.String(),
54
128
  task: Type.Optional(Type.String({ description: "Task template with {task}, {previous}, {chain_dir} variables. Defaults to {previous}." })),
129
+ phase: Type.Optional(Type.String({ description: "Optional phase/group label for status and graph rendering." })),
130
+ label: Type.Optional(Type.String({ description: "Optional user-facing label for this parallel task." })),
131
+ as: Type.Optional(Type.String({ description: "Optional safe identifier used as {outputs.name} in later chain steps." })),
132
+ outputSchema: Type.Optional(JsonSchemaObject),
55
133
  cwd: Type.Optional(Type.String()),
56
134
  count: Type.Optional(Type.Integer({ minimum: 1, description: "Repeat this parallel task N times with the same settings." })),
57
135
  output: Type.Optional(OutputOverride),
@@ -60,14 +138,51 @@ const ParallelTaskSchema = Type.Object({
60
138
  progress: Type.Optional(Type.Boolean({ description: "Enable progress.md tracking in {chain_dir}" })),
61
139
  skill: Type.Optional(SkillOverride),
62
140
  model: Type.Optional(Type.String({ description: "Override model for this task" })),
141
+ acceptance: Type.Optional(AcceptanceOverride),
63
142
  });
64
143
 
144
+ const DynamicExpandSchema = Type.Object({
145
+ from: Type.Object({
146
+ output: Type.String({ description: "Prior named structured output to expand from." }),
147
+ path: Type.String({ description: "JSON Pointer into the structured output, e.g. /items." }),
148
+ }, { additionalProperties: false }),
149
+ item: Type.Optional(Type.String({ description: "Template variable name for each item. Defaults to item." })),
150
+ key: Type.Optional(Type.String({ description: "JSON Pointer relative to each item for stable child ids." })),
151
+ maxItems: Type.Optional(Type.Integer({ minimum: 0, description: "Required fanout bound unless configured globally." })),
152
+ onEmpty: Type.Optional(Type.String({ enum: ["skip", "fail"], description: "Empty input behavior. Defaults to skip." })),
153
+ }, { additionalProperties: false });
154
+
155
+ const DynamicParallelTemplateSchema = Type.Object({
156
+ agent: Type.String(),
157
+ task: Type.Optional(Type.String({ description: "Task template with {item}, {item.path}, {task}, {previous}, {chain_dir}, and {outputs.name} variables." })),
158
+ phase: Type.Optional(Type.String({ description: "Optional phase/group label for status and graph rendering." })),
159
+ label: Type.Optional(Type.String({ description: "Optional user-facing label; item templates are supported." })),
160
+ outputSchema: Type.Optional(JsonSchemaObject),
161
+ cwd: Type.Optional(Type.String()),
162
+ output: Type.Optional(OutputOverride),
163
+ outputMode: Type.Optional(OutputModeOverride),
164
+ reads: Type.Optional(ReadsOverride),
165
+ progress: Type.Optional(Type.Boolean({ description: "Enable progress.md tracking in {chain_dir}" })),
166
+ skill: Type.Optional(SkillOverride),
167
+ model: Type.Optional(Type.String({ description: "Override model for this task" })),
168
+ acceptance: Type.Optional(AcceptanceOverride),
169
+ }, { additionalProperties: false });
170
+
171
+ const DynamicCollectSchema = Type.Object({
172
+ as: Type.String({ description: "Safe output name for the ordered collected result array." }),
173
+ outputSchema: Type.Optional(JsonSchemaObject),
174
+ }, { additionalProperties: false });
175
+
65
176
  // Flattened so chain steps do not need an object-shape anyOf/oneOf union.
66
177
  const ChainItem = Type.Object({
67
178
  agent: Type.Optional(Type.String({ description: "Sequential step agent name" })),
68
179
  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."
180
+ 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
181
  })),
182
+ phase: Type.Optional(Type.String({ description: "Optional phase/group label for status and graph rendering." })),
183
+ label: Type.Optional(Type.String({ description: "Optional user-facing label for this chain step." })),
184
+ as: Type.Optional(Type.String({ description: "Optional safe identifier used as {outputs.name} in later chain steps." })),
185
+ outputSchema: Type.Optional(JsonSchemaObject),
71
186
  cwd: Type.Optional(Type.String()),
72
187
  output: Type.Optional(OutputOverride),
73
188
  outputMode: Type.Optional(OutputModeOverride),
@@ -75,13 +190,30 @@ const ChainItem = Type.Object({
75
190
  progress: Type.Optional(Type.Boolean({ description: "Enable progress.md tracking in {chain_dir}" })),
76
191
  skill: Type.Optional(SkillOverride),
77
192
  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" })),
193
+ acceptance: Type.Optional(AcceptanceOverride),
194
+ parallel: Type.Optional(Type.Unsafe({
195
+ anyOf: [
196
+ Type.Array(ParallelTaskSchema, { minItems: 1, description: "Tasks to run in parallel" }),
197
+ DynamicParallelTemplateSchema,
198
+ ],
199
+ description: "Static parallel tasks array, or a single dynamic fanout child template when expand/collect are present.",
200
+ })),
201
+ expand: Type.Optional(DynamicExpandSchema),
202
+ collect: Type.Optional(DynamicCollectSchema),
79
203
  concurrency: Type.Optional(Type.Number({ description: "Max concurrent tasks (default: 4)" })),
80
204
  failFast: Type.Optional(Type.Boolean({ description: "Stop on first failure (default: false)" })),
81
205
  worktree: Type.Optional(Type.Boolean({
82
206
  description: "Create isolated git worktrees for each parallel task."
83
207
  })),
84
- }, { description: "Chain step: use {agent, task?, ...} for sequential or {parallel: [...]} for concurrent execution" });
208
+ }, {
209
+ description: "Chain step: use {agent, task?, ...} for sequential, {parallel: [...]} for static concurrent execution, or {expand, parallel: {...}, collect} for dynamic fanout.",
210
+ additionalProperties: false,
211
+ allOf: [
212
+ { if: { required: ["expand"] }, then: { required: ["parallel", "collect"], properties: { parallel: { type: "object" } } } },
213
+ { if: { required: ["collect"] }, then: { required: ["expand", "parallel"], properties: { parallel: { type: "object" } } } },
214
+ { not: { required: ["expand"], properties: { parallel: { type: "array", items: {} } } } },
215
+ ],
216
+ });
85
217
 
86
218
  const ControlOverrides = Type.Object({
87
219
  enabled: Type.Optional(Type.Boolean({ description: "Enable/disable subagent control attention tracking for this run" })),
@@ -165,4 +297,5 @@ export const SubagentParams = Type.Object({
165
297
  outputMode: Type.Optional(OutputModeOverride),
166
298
  skill: Type.Optional(SkillOverride),
167
299
  model: Type.Optional(Type.String({ description: "Override model for single agent (e.g. 'anthropic/claude-sonnet-4')" })),
300
+ acceptance: Type.Optional(AcceptanceOverride),
168
301
  });
@@ -3,6 +3,8 @@ import * as fs from "node:fs";
3
3
  import {
4
4
  type Details,
5
5
  type IntercomEventBus,
6
+ type NestedRunSummary,
7
+ type PublicNestedRunSummary,
6
8
  type SingleResult,
7
9
  type SubagentResultIntercomChild,
8
10
  type SubagentResultIntercomPayload,
@@ -60,6 +62,110 @@ function resolveGroupedStatus(children: SubagentResultIntercomChild[]): Subagent
60
62
  return "failed";
61
63
  }
62
64
 
65
+ function compactNestedRun(run: NestedRunSummary | PublicNestedRunSummary, depth = 0): PublicNestedRunSummary {
66
+ return {
67
+ id: run.id,
68
+ parentRunId: run.parentRunId,
69
+ ...(run.parentStepIndex !== undefined ? { parentStepIndex: run.parentStepIndex } : {}),
70
+ ...(run.parentAgent ? { parentAgent: run.parentAgent } : {}),
71
+ depth: run.depth,
72
+ path: run.path.slice(0, 4).map((part) => ({
73
+ runId: part.runId,
74
+ ...(part.stepIndex !== undefined ? { stepIndex: part.stepIndex } : {}),
75
+ ...(part.agent ? { agent: part.agent } : {}),
76
+ })),
77
+ ...(run.asyncDir ? { asyncDir: run.asyncDir } : {}),
78
+ ...(run.sessionId ? { sessionId: run.sessionId } : {}),
79
+ ...(run.sessionFile ? { sessionFile: run.sessionFile } : {}),
80
+ ...(run.intercomTarget ? { intercomTarget: run.intercomTarget } : {}),
81
+ ...(run.ownerIntercomTarget ? { ownerIntercomTarget: run.ownerIntercomTarget } : {}),
82
+ ...(run.leafIntercomTarget ? { leafIntercomTarget: run.leafIntercomTarget } : {}),
83
+ ...(run.ownerState ? { ownerState: run.ownerState } : {}),
84
+ ...(run.mode ? { mode: run.mode } : {}),
85
+ state: run.state,
86
+ ...(run.agent ? { agent: run.agent } : {}),
87
+ ...(run.agents?.length ? { agents: run.agents.slice(0, 12) } : {}),
88
+ ...(run.currentStep !== undefined ? { currentStep: run.currentStep } : {}),
89
+ ...(run.chainStepCount !== undefined ? { chainStepCount: run.chainStepCount } : {}),
90
+ ...(run.parallelGroups?.length ? { parallelGroups: run.parallelGroups.slice(0, 8) } : {}),
91
+ ...(run.activityState ? { activityState: run.activityState } : {}),
92
+ ...(run.lastActivityAt !== undefined ? { lastActivityAt: run.lastActivityAt } : {}),
93
+ ...(run.currentTool ? { currentTool: run.currentTool } : {}),
94
+ ...(run.currentToolStartedAt !== undefined ? { currentToolStartedAt: run.currentToolStartedAt } : {}),
95
+ ...(run.currentPath ? { currentPath: run.currentPath } : {}),
96
+ ...(run.turnCount !== undefined ? { turnCount: run.turnCount } : {}),
97
+ ...(run.toolCount !== undefined ? { toolCount: run.toolCount } : {}),
98
+ ...(run.totalTokens ? { totalTokens: run.totalTokens } : {}),
99
+ ...(run.startedAt !== undefined ? { startedAt: run.startedAt } : {}),
100
+ ...(run.endedAt !== undefined ? { endedAt: run.endedAt } : {}),
101
+ ...(run.lastUpdate !== undefined ? { lastUpdate: run.lastUpdate } : {}),
102
+ ...(run.error ? { error: run.error } : {}),
103
+ ...(run.steps?.length ? { steps: run.steps.slice(0, 12).map((step) => ({
104
+ agent: step.agent,
105
+ status: step.status,
106
+ ...(step.sessionFile ? { sessionFile: step.sessionFile } : {}),
107
+ ...(step.activityState ? { activityState: step.activityState } : {}),
108
+ ...(step.lastActivityAt !== undefined ? { lastActivityAt: step.lastActivityAt } : {}),
109
+ ...(step.currentTool ? { currentTool: step.currentTool } : {}),
110
+ ...(step.currentToolStartedAt !== undefined ? { currentToolStartedAt: step.currentToolStartedAt } : {}),
111
+ ...(step.currentPath ? { currentPath: step.currentPath } : {}),
112
+ ...(step.turnCount !== undefined ? { turnCount: step.turnCount } : {}),
113
+ ...(step.toolCount !== undefined ? { toolCount: step.toolCount } : {}),
114
+ ...(step.startedAt !== undefined ? { startedAt: step.startedAt } : {}),
115
+ ...(step.endedAt !== undefined ? { endedAt: step.endedAt } : {}),
116
+ ...(step.error ? { error: step.error } : {}),
117
+ ...(depth < 2 && step.children?.length ? { children: step.children.slice(0, 8).map((child) => compactNestedRun(child, depth + 1)) } : {}),
118
+ })) } : {}),
119
+ ...(depth < 2 && run.children?.length ? { children: run.children.slice(0, 8).map((child) => compactNestedRun(child, depth + 1)) } : {}),
120
+ };
121
+ }
122
+
123
+ export function compactNestedResultChildren(children: Array<NestedRunSummary | PublicNestedRunSummary> | undefined): PublicNestedRunSummary[] | undefined {
124
+ if (!children?.length) return undefined;
125
+ return children.slice(0, 16).map((child) => compactNestedRun(child));
126
+ }
127
+
128
+ export function attachNestedChildrenToResultChildren(
129
+ runId: string,
130
+ children: SubagentResultIntercomChild[],
131
+ nestedChildren: NestedRunSummary[] | undefined,
132
+ ): SubagentResultIntercomChild[] {
133
+ const compact = compactNestedResultChildren(nestedChildren);
134
+ if (!compact?.length) return children.map((child) => ({ ...child, children: compactNestedResultChildren(child.children) }));
135
+ return children.map((child, index) => {
136
+ const childIndex = child.index ?? index;
137
+ const alreadyAttachedIds = new Set(child.children?.map((nested) => nested.id) ?? []);
138
+ const attached = compact.filter((nested) => nested.parentRunId === runId && nested.parentStepIndex === childIndex && !alreadyAttachedIds.has(nested.id));
139
+ const fallbackAttached = children.length === 1
140
+ ? compact.filter((nested) => nested.parentRunId === runId && nested.parentStepIndex === undefined && !alreadyAttachedIds.has(nested.id))
141
+ : [];
142
+ const merged = compactNestedResultChildren([...(child.children ?? []), ...attached, ...fallbackAttached]);
143
+ return merged?.length ? { ...child, children: merged } : { ...child, children: undefined };
144
+ });
145
+ }
146
+
147
+ function formatNestedResultLines(children: PublicNestedRunSummary[] | undefined): string[] {
148
+ if (!children?.length) return [];
149
+ const lines = ["Nested subagents:"];
150
+ let remaining = 10;
151
+ const append = (runs: PublicNestedRunSummary[] | undefined, indent: string): void => {
152
+ for (const run of runs ?? []) {
153
+ if (remaining <= 0) {
154
+ lines.push(`${indent}↳ +more nested runs; inspect status for full tree`);
155
+ return;
156
+ }
157
+ remaining--;
158
+ const label = run.agent ?? run.agents?.join("+") ?? run.id;
159
+ lines.push(`${indent}↳ ${label} — ${run.state} [${run.id}]`);
160
+ if (run.sessionFile) lines.push(`${indent} Session: ${run.sessionFile}`);
161
+ append(run.children, `${indent} `);
162
+ for (const step of run.steps ?? []) append(step.children, `${indent} `);
163
+ }
164
+ };
165
+ append(children, "");
166
+ return lines;
167
+ }
168
+
63
169
  interface GroupedResultIntercomMessageInput {
64
170
  to: string;
65
171
  runId: string;
@@ -128,6 +234,7 @@ function formatSubagentResultIntercomMessage(input: {
128
234
  if (child.intercomTarget) lines.push(`${input.source === "async" ? "Previous intercom target" : "Run intercom target"}: ${child.intercomTarget}`);
129
235
  if (child.artifactPath) lines.push(`Output artifact: ${child.artifactPath}`);
130
236
  if (child.sessionPath) lines.push(`Session: ${child.sessionPath}`);
237
+ lines.push(...formatNestedResultLines(child.children));
131
238
  lines.push("Summary:");
132
239
  lines.push(child.summary);
133
240
  }
@@ -139,6 +246,7 @@ export function buildSubagentResultIntercomPayload(input: GroupedResultIntercomM
139
246
  const children = input.children.map((child) => ({
140
247
  ...child,
141
248
  summary: child.summary.trim() || "(no output)",
249
+ children: compactNestedResultChildren(child.children),
142
250
  }));
143
251
  const status = resolveGroupedStatus(children);
144
252
  const summary = formatStatusCounts(countStatuses(children));