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
@@ -17,7 +17,7 @@ import {
17
17
  parsePackageName,
18
18
  } from "./agents.ts";
19
19
  import { serializeAgent } from "./agent-serializer.ts";
20
- import { serializeChain } from "./chain-serializer.ts";
20
+ import { serializeChain, serializeJsonChain } from "./chain-serializer.ts";
21
21
  import { discoverAvailableSkills } from "./skills.ts";
22
22
  import type { Details } from "../shared/types.ts";
23
23
 
@@ -169,6 +169,22 @@ function parseStepList(raw: unknown): { steps?: ChainStepConfig[]; error?: strin
169
169
  const s = item as Record<string, unknown>;
170
170
  if (typeof s.agent !== "string" || !s.agent.trim()) return { error: `config.steps[${i}].agent must be a non-empty string.` };
171
171
  const step: ChainStepConfig = { agent: s.agent.trim(), task: typeof s.task === "string" ? s.task : "" };
172
+ if (hasKey(s, "phase")) {
173
+ if (typeof s.phase === "string") step.phase = s.phase;
174
+ else return { error: `config.steps[${i}].phase must be a string.` };
175
+ }
176
+ if (hasKey(s, "label")) {
177
+ if (typeof s.label === "string") step.label = s.label;
178
+ else return { error: `config.steps[${i}].label must be a string.` };
179
+ }
180
+ if (hasKey(s, "as")) {
181
+ if (typeof s.as === "string") step.as = s.as;
182
+ else return { error: `config.steps[${i}].as must be a string.` };
183
+ }
184
+ if (hasKey(s, "outputSchema")) {
185
+ if (typeof s.outputSchema === "string") step.outputSchema = s.outputSchema;
186
+ else return { error: `config.steps[${i}].outputSchema must be a schema file path string for saved chains.` };
187
+ }
172
188
  if (hasKey(s, "output")) {
173
189
  if (s.output === false) step.output = false;
174
190
  else if (typeof s.output === "string") step.output = s.output;
@@ -297,6 +313,18 @@ function applyAgentConfig(target: AgentConfig, cfg: Record<string, unknown>): st
297
313
  target.maxSubagentDepth = cfg.maxSubagentDepth;
298
314
  } else return "config.maxSubagentDepth must be an integer >= 0 or false when provided.";
299
315
  }
316
+ if (hasKey(cfg, "maxExecutionTimeMs")) {
317
+ if (cfg.maxExecutionTimeMs === false || cfg.maxExecutionTimeMs === "") target.maxExecutionTimeMs = undefined;
318
+ else if (typeof cfg.maxExecutionTimeMs === "number" && Number.isInteger(cfg.maxExecutionTimeMs) && cfg.maxExecutionTimeMs >= 1) {
319
+ target.maxExecutionTimeMs = cfg.maxExecutionTimeMs;
320
+ } else return "config.maxExecutionTimeMs must be an integer >= 1 or false when provided.";
321
+ }
322
+ if (hasKey(cfg, "maxTokens")) {
323
+ if (cfg.maxTokens === false || cfg.maxTokens === "") target.maxTokens = undefined;
324
+ else if (typeof cfg.maxTokens === "number" && Number.isInteger(cfg.maxTokens) && cfg.maxTokens >= 1) {
325
+ target.maxTokens = cfg.maxTokens;
326
+ } else return "config.maxTokens must be an integer >= 1 or false when provided.";
327
+ }
300
328
  if (hasKey(cfg, "completionGuard")) {
301
329
  if (typeof cfg.completionGuard !== "boolean") return "config.completionGuard must be a boolean when provided.";
302
330
  target.completionGuard = cfg.completionGuard;
@@ -339,7 +367,7 @@ function renamePath(
339
367
  cwd: string,
340
368
  ): { filePath?: string; error?: string } {
341
369
  if (nameExistsInScope(cwd, scope, newName, currentPath)) return { error: `Name '${newName}' already exists in ${scope} scope.` };
342
- const ext = kind === "agent" ? ".md" : ".chain.md";
370
+ const ext = kind === "agent" ? ".md" : currentPath.endsWith(".chain.json") ? ".chain.json" : ".chain.md";
343
371
  const filePath = path.join(path.dirname(currentPath), `${newName}${ext}`);
344
372
  if (fs.existsSync(filePath) && filePath !== currentPath) {
345
373
  return { error: `File already exists at ${filePath} but is not a valid ${kind} definition. Remove or rename it first.` };
@@ -370,11 +398,48 @@ function formatAgentDetail(agent: AgentConfig): string {
370
398
  if (agent.defaultReads?.length) lines.push(`Reads: ${agent.defaultReads.join(", ")}`);
371
399
  if (agent.defaultProgress) lines.push("Progress: true");
372
400
  if (agent.maxSubagentDepth !== undefined) lines.push(`Max subagent depth: ${agent.maxSubagentDepth}`);
401
+ if (agent.maxExecutionTimeMs !== undefined) lines.push(`Max execution time: ${agent.maxExecutionTimeMs}ms`);
402
+ if (agent.maxTokens !== undefined) lines.push(`Max tokens: ${agent.maxTokens}`);
373
403
  if (agent.completionGuard === false) lines.push("Completion guard: false");
374
404
  if (agent.systemPrompt.trim()) lines.push("", "System Prompt:", agent.systemPrompt);
375
405
  return lines.join("\n");
376
406
  }
377
407
 
408
+ function formatChainStepDetail(step: ChainStepConfig, index: number): string[] {
409
+ const lines: string[] = [];
410
+ if (step.expand || step.collect) {
411
+ const parallel = step.parallel && !Array.isArray(step.parallel) && typeof step.parallel === "object" ? step.parallel as { agent?: unknown; task?: unknown; label?: unknown; outputSchema?: unknown } : undefined;
412
+ const expand = step.expand && typeof step.expand === "object" ? step.expand as { from?: { output?: unknown; path?: unknown }; item?: unknown; key?: unknown; maxItems?: unknown; onEmpty?: unknown } : undefined;
413
+ const collect = step.collect && typeof step.collect === "object" ? step.collect as { as?: unknown; outputSchema?: unknown } : undefined;
414
+ lines.push(`${index + 1}. Dynamic fanout${typeof collect?.as === "string" ? ` -> ${collect.as}` : ""}`);
415
+ if (expand?.from) lines.push(` Expand: ${String(expand.from.output ?? "?")}${String(expand.from.path ?? "")}`);
416
+ if (typeof expand?.item === "string") lines.push(` Item variable: ${expand.item}`);
417
+ if (typeof expand?.key === "string") lines.push(` Key: ${expand.key}`);
418
+ if (typeof expand?.maxItems === "number") lines.push(` Max items: ${expand.maxItems}`);
419
+ if (typeof expand?.onEmpty === "string") lines.push(` On empty: ${expand.onEmpty}`);
420
+ if (parallel?.agent) lines.push(` Agent: ${String(parallel.agent)}`);
421
+ if (typeof parallel?.label === "string") lines.push(` Label: ${parallel.label}`);
422
+ if (typeof parallel?.task === "string" && parallel.task.trim()) lines.push(` Task: ${parallel.task}`);
423
+ if (parallel?.outputSchema) lines.push(" Structured output: true");
424
+ if (collect?.outputSchema) lines.push(" Collect schema: true");
425
+ if (step.concurrency !== undefined) lines.push(` Concurrency: ${step.concurrency}`);
426
+ if (step.failFast !== undefined) lines.push(` Fail fast: ${step.failFast ? "true" : "false"}`);
427
+ return lines;
428
+ }
429
+ lines.push(`${index + 1}. ${step.agent}`);
430
+ if (step.task?.trim()) lines.push(` Task: ${step.task}`);
431
+ if (step.output === false) lines.push(" Output: false");
432
+ else if (step.output) lines.push(` Output: ${step.output}`);
433
+ if (step.outputMode) lines.push(` Output mode: ${step.outputMode}`);
434
+ if (step.reads === false) lines.push(" Reads: false");
435
+ else if (Array.isArray(step.reads) && step.reads.length > 0) lines.push(` Reads: ${step.reads.join(", ")}`);
436
+ if (step.model) lines.push(` Model: ${step.model}`);
437
+ if (step.skills === false) lines.push(" Skills: false");
438
+ else if (Array.isArray(step.skills) && step.skills.length > 0) lines.push(` Skills: ${step.skills.join(", ")}`);
439
+ if (step.progress !== undefined) lines.push(` Progress: ${step.progress ? "true" : "false"}`);
440
+ return lines;
441
+ }
442
+
378
443
  function formatChainDetail(chain: ChainConfig): string {
379
444
  const lines: string[] = [`Chain: ${chain.name} (${chain.source})`, `Path: ${chain.filePath}`, `Description: ${chain.description}`];
380
445
  if (chain.packageName) {
@@ -383,18 +448,7 @@ function formatChainDetail(chain: ChainConfig): string {
383
448
  }
384
449
  lines.push("", "Steps:");
385
450
  for (let i = 0; i < chain.steps.length; i++) {
386
- const s = chain.steps[i]!;
387
- lines.push(`${i + 1}. ${s.agent}`);
388
- if (s.task.trim()) lines.push(` Task: ${s.task}`);
389
- if (s.output === false) lines.push(" Output: false");
390
- else if (s.output) lines.push(` Output: ${s.output}`);
391
- if (s.outputMode) lines.push(` Output mode: ${s.outputMode}`);
392
- if (s.reads === false) lines.push(" Reads: false");
393
- else if (Array.isArray(s.reads) && s.reads.length > 0) lines.push(` Reads: ${s.reads.join(", ")}`);
394
- if (s.model) lines.push(` Model: ${s.model}`);
395
- if (s.skills === false) lines.push(" Skills: false");
396
- else if (Array.isArray(s.skills) && s.skills.length > 0) lines.push(` Skills: ${s.skills.join(", ")}`);
397
- if (s.progress !== undefined) lines.push(` Progress: ${s.progress ? "true" : "false"}`);
451
+ lines.push(...formatChainStepDetail(chain.steps[i]!, i));
398
452
  }
399
453
  return lines.join("\n");
400
454
  }
@@ -405,6 +459,7 @@ export function handleList(params: ManagementParams, ctx: ManagementContext): Ag
405
459
  const scopedAgents = allAgents(d).filter((a) => scope === "both" || a.source === "builtin" || a.source === scope).sort((a, b) => a.name.localeCompare(b.name));
406
460
  const agents = scopedAgents.filter((a) => !a.disabled);
407
461
  const chains = d.chains.filter((c) => scope === "both" || c.source === scope).sort((a, b) => a.name.localeCompare(b.name));
462
+ const diagnostics = d.chainDiagnostics.filter((entry) => scope === "both" || entry.source === scope);
408
463
  const lines = [
409
464
  "Executable agents:",
410
465
  ...(agents.length
@@ -413,6 +468,7 @@ export function handleList(params: ManagementParams, ctx: ManagementContext): Ag
413
468
  "",
414
469
  "Chains:",
415
470
  ...(chains.length ? chains.map((c) => `- ${c.name} (${c.source}): ${c.description}`) : ["- (none)"]),
471
+ ...(diagnostics.length ? ["", "Chain diagnostics:", ...diagnostics.map((entry) => `- ${entry.filePath}: ${entry.error}`)] : []),
416
472
  ];
417
473
  return result(lines.join("\n"));
418
474
  }
@@ -608,7 +664,7 @@ export function handleUpdate(params: ManagementParams, ctx: ManagementContext):
608
664
  if (renamed.error) return result(renamed.error, true);
609
665
  updated.filePath = renamed.filePath!;
610
666
  }
611
- fs.writeFileSync(updated.filePath, serializeChain(updated), "utf-8");
667
+ fs.writeFileSync(updated.filePath, updated.filePath.endsWith(".chain.json") ? serializeJsonChain(updated) : serializeChain(updated), "utf-8");
612
668
  const headline = updated.name === oldName
613
669
  ? `Updated chain '${updated.name}' at ${updated.filePath}.`
614
670
  : `Updated chain '${oldName}' to '${updated.name}' at ${updated.filePath}.`;
@@ -21,6 +21,8 @@ export const KNOWN_FIELDS = new Set([
21
21
  "defaultProgress",
22
22
  "interactive",
23
23
  "maxSubagentDepth",
24
+ "maxExecutionTimeMs",
25
+ "maxTokens",
24
26
  "completionGuard",
25
27
  ]);
26
28
 
@@ -67,8 +69,17 @@ export function serializeAgent(config: AgentConfig): string {
67
69
 
68
70
  if (config.defaultProgress) lines.push("defaultProgress: true");
69
71
  if (config.interactive) lines.push("interactive: true");
70
- if (Number.isInteger(config.maxSubagentDepth) && config.maxSubagentDepth >= 0) {
71
- lines.push(`maxSubagentDepth: ${config.maxSubagentDepth}`);
72
+ const maxSubagentDepth = config.maxSubagentDepth;
73
+ if (typeof maxSubagentDepth === "number" && Number.isInteger(maxSubagentDepth) && maxSubagentDepth >= 0) {
74
+ lines.push(`maxSubagentDepth: ${maxSubagentDepth}`);
75
+ }
76
+ const maxExecutionTimeMs = config.maxExecutionTimeMs;
77
+ if (typeof maxExecutionTimeMs === "number" && Number.isInteger(maxExecutionTimeMs) && maxExecutionTimeMs >= 1) {
78
+ lines.push(`maxExecutionTimeMs: ${maxExecutionTimeMs}`);
79
+ }
80
+ const maxTokens = config.maxTokens;
81
+ if (typeof maxTokens === "number" && Number.isInteger(maxTokens) && maxTokens >= 1) {
82
+ lines.push(`maxTokens: ${maxTokens}`);
72
83
  }
73
84
  if (config.completionGuard === false) lines.push("completionGuard: false");
74
85
 
@@ -6,10 +6,10 @@ import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
- import type { OutputMode } from "../shared/types.ts";
9
+ import type { AcceptanceInput, OutputMode } from "../shared/types.ts";
10
10
  import { getAgentDir } from "../shared/utils.ts";
11
11
  import { KNOWN_FIELDS } from "./agent-serializer.ts";
12
- import { parseChain } from "./chain-serializer.ts";
12
+ import { parseChain, parseJsonChain } from "./chain-serializer.ts";
13
13
  import { mergeAgentsForScope } from "./agent-selection.ts";
14
14
  import { parseFrontmatter } from "./frontmatter.ts";
15
15
  import { buildRuntimeName, parsePackageName } from "./identity.ts";
@@ -46,6 +46,8 @@ export interface BuiltinAgentOverrideBase {
46
46
  skills?: string[];
47
47
  tools?: string[];
48
48
  mcpDirectTools?: string[];
49
+ maxExecutionTimeMs?: number;
50
+ maxTokens?: number;
49
51
  completionGuard?: boolean;
50
52
  }
51
53
 
@@ -61,6 +63,8 @@ interface BuiltinAgentOverrideConfig {
61
63
  systemPrompt?: string;
62
64
  skills?: string[] | false;
63
65
  tools?: string[] | false;
66
+ maxExecutionTimeMs?: number | false;
67
+ maxTokens?: number | false;
64
68
  completionGuard?: boolean;
65
69
  }
66
70
 
@@ -94,6 +98,8 @@ export interface AgentConfig {
94
98
  defaultProgress?: boolean;
95
99
  interactive?: boolean;
96
100
  maxSubagentDepth?: number;
101
+ maxExecutionTimeMs?: number;
102
+ maxTokens?: number;
97
103
  completionGuard?: boolean;
98
104
  disabled?: boolean;
99
105
  extraFields?: Record<string, string>;
@@ -108,14 +114,25 @@ interface SubagentSettings {
108
114
  const EMPTY_SUBAGENT_SETTINGS: SubagentSettings = { overrides: {} };
109
115
 
110
116
  export interface ChainStepConfig {
111
- agent: string;
112
- task: string;
117
+ agent?: string;
118
+ task?: string;
119
+ phase?: string;
120
+ label?: string;
121
+ as?: string;
122
+ outputSchema?: string | Record<string, unknown>;
113
123
  output?: string | false;
114
124
  outputMode?: OutputMode;
115
125
  reads?: string[] | false;
116
126
  model?: string;
117
127
  skills?: string[] | false;
118
128
  progress?: boolean;
129
+ parallel?: unknown;
130
+ expand?: unknown;
131
+ collect?: unknown;
132
+ concurrency?: number;
133
+ failFast?: boolean;
134
+ worktree?: boolean;
135
+ acceptance?: AcceptanceInput;
119
136
  }
120
137
 
121
138
  export interface ChainConfig {
@@ -129,6 +146,12 @@ export interface ChainConfig {
129
146
  extraFields?: Record<string, string>;
130
147
  }
131
148
 
149
+ export interface ChainDiscoveryDiagnostic {
150
+ source: "user" | "project";
151
+ filePath: string;
152
+ error: string;
153
+ }
154
+
132
155
  interface AgentDiscoveryResult {
133
156
  agents: AgentConfig[];
134
157
  projectAgentsDir: string | null;
@@ -186,6 +209,8 @@ function cloneOverrideBase(agent: AgentConfig): BuiltinAgentOverrideBase {
186
209
  skills: agent.skills ? [...agent.skills] : undefined,
187
210
  tools: agent.tools ? [...agent.tools] : undefined,
188
211
  mcpDirectTools: agent.mcpDirectTools ? [...agent.mcpDirectTools] : undefined,
212
+ maxExecutionTimeMs: agent.maxExecutionTimeMs,
213
+ maxTokens: agent.maxTokens,
189
214
  completionGuard: agent.completionGuard,
190
215
  };
191
216
  }
@@ -205,6 +230,8 @@ function cloneOverrideValue(override: BuiltinAgentOverrideConfig): BuiltinAgentO
205
230
  ...(override.systemPrompt !== undefined ? { systemPrompt: override.systemPrompt } : {}),
206
231
  ...(override.skills !== undefined ? { skills: override.skills === false ? false : [...override.skills] } : {}),
207
232
  ...(override.tools !== undefined ? { tools: override.tools === false ? false : [...override.tools] } : {}),
233
+ ...(override.maxExecutionTimeMs !== undefined ? { maxExecutionTimeMs: override.maxExecutionTimeMs } : {}),
234
+ ...(override.maxTokens !== undefined ? { maxTokens: override.maxTokens } : {}),
208
235
  ...(override.completionGuard !== undefined ? { completionGuard: override.completionGuard } : {}),
209
236
  };
210
237
  }
@@ -342,6 +369,22 @@ function parseBuiltinOverrideEntry(
342
369
  }
343
370
  }
344
371
 
372
+ if ("maxExecutionTimeMs" in input) {
373
+ if (input.maxExecutionTimeMs === false || (typeof input.maxExecutionTimeMs === "number" && Number.isInteger(input.maxExecutionTimeMs) && input.maxExecutionTimeMs >= 1)) {
374
+ override.maxExecutionTimeMs = input.maxExecutionTimeMs;
375
+ } else {
376
+ throw new Error(`Builtin override '${name}' in '${filePath}' has invalid 'maxExecutionTimeMs'; expected an integer >= 1 or false.`);
377
+ }
378
+ }
379
+
380
+ if ("maxTokens" in input) {
381
+ if (input.maxTokens === false || (typeof input.maxTokens === "number" && Number.isInteger(input.maxTokens) && input.maxTokens >= 1)) {
382
+ override.maxTokens = input.maxTokens;
383
+ } else {
384
+ throw new Error(`Builtin override '${name}' in '${filePath}' has invalid 'maxTokens'; expected an integer >= 1 or false.`);
385
+ }
386
+ }
387
+
345
388
  if ("completionGuard" in input) {
346
389
  if (typeof input.completionGuard === "boolean") {
347
390
  override.completionGuard = input.completionGuard;
@@ -422,6 +465,8 @@ function applyBuiltinOverride(
422
465
  next.tools = tools;
423
466
  next.mcpDirectTools = mcpDirectTools;
424
467
  }
468
+ if (override.maxExecutionTimeMs !== undefined) next.maxExecutionTimeMs = override.maxExecutionTimeMs === false ? undefined : override.maxExecutionTimeMs;
469
+ if (override.maxTokens !== undefined) next.maxTokens = override.maxTokens === false ? undefined : override.maxTokens;
425
470
  if (override.completionGuard !== undefined) next.completionGuard = override.completionGuard;
426
471
 
427
472
  return next;
@@ -462,7 +507,7 @@ function applyBuiltinOverrides(
462
507
 
463
508
  export function buildBuiltinOverrideConfig(
464
509
  base: BuiltinAgentOverrideBase,
465
- draft: Pick<AgentConfig, "model" | "fallbackModels" | "thinking" | "systemPromptMode" | "inheritProjectContext" | "inheritSkills" | "defaultContext" | "disabled" | "systemPrompt" | "skills" | "tools" | "mcpDirectTools" | "completionGuard">,
510
+ draft: Pick<AgentConfig, "model" | "fallbackModels" | "thinking" | "systemPromptMode" | "inheritProjectContext" | "inheritSkills" | "defaultContext" | "disabled" | "systemPrompt" | "skills" | "tools" | "mcpDirectTools" | "maxExecutionTimeMs" | "maxTokens" | "completionGuard">,
466
511
  ): BuiltinAgentOverrideConfig | undefined {
467
512
  const override: BuiltinAgentOverrideConfig = {};
468
513
 
@@ -480,6 +525,8 @@ export function buildBuiltinOverrideConfig(
480
525
  const baseTools = joinToolList(base);
481
526
  const draftTools = joinToolList(draft);
482
527
  if (!arraysEqual(draftTools, baseTools)) override.tools = draftTools ? [...draftTools] : false;
528
+ if (draft.maxExecutionTimeMs !== base.maxExecutionTimeMs) override.maxExecutionTimeMs = draft.maxExecutionTimeMs ?? false;
529
+ if (draft.maxTokens !== base.maxTokens) override.maxTokens = draft.maxTokens ?? false;
483
530
  if ((draft.completionGuard !== false) !== (base.completionGuard !== false)) {
484
531
  override.completionGuard = draft.completionGuard !== false;
485
532
  }
@@ -535,7 +582,7 @@ export function removeBuiltinAgentOverride(cwd: string, name: string, scope: "us
535
582
  return filePath;
536
583
  }
537
584
 
538
- function listMarkdownFilesRecursive(dir: string, predicate: (fileName: string) => boolean): string[] {
585
+ function listFilesRecursive(dir: string, predicate: (fileName: string) => boolean): string[] {
539
586
  const files: string[] = [];
540
587
  if (!fs.existsSync(dir)) return files;
541
588
 
@@ -549,7 +596,7 @@ function listMarkdownFilesRecursive(dir: string, predicate: (fileName: string) =
549
596
  for (const entry of entries) {
550
597
  const filePath = path.join(dir, entry.name);
551
598
  if (entry.isDirectory()) {
552
- files.push(...listMarkdownFilesRecursive(filePath, predicate));
599
+ files.push(...listFilesRecursive(filePath, predicate));
553
600
  continue;
554
601
  }
555
602
  if (!entry.isFile() && !entry.isSymbolicLink()) continue;
@@ -562,7 +609,7 @@ function listMarkdownFilesRecursive(dir: string, predicate: (fileName: string) =
562
609
  function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
563
610
  const agents: AgentConfig[] = [];
564
611
 
565
- for (const filePath of listMarkdownFilesRecursive(dir, (fileName) => fileName.endsWith(".md") && !fileName.endsWith(".chain.md"))) {
612
+ for (const filePath of listFilesRecursive(dir, (fileName) => fileName.endsWith(".md") && !fileName.endsWith(".chain.md"))) {
566
613
  let content: string;
567
614
  try {
568
615
  content = fs.readFileSync(filePath, "utf-8");
@@ -648,6 +695,8 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
648
695
  }
649
696
 
650
697
  const parsedMaxSubagentDepth = Number(frontmatter.maxSubagentDepth);
698
+ const parsedMaxExecutionTimeMs = Number(frontmatter.maxExecutionTimeMs);
699
+ const parsedMaxTokens = Number(frontmatter.maxTokens);
651
700
  const completionGuard = frontmatter.completionGuard === "false"
652
701
  ? false
653
702
  : frontmatter.completionGuard === "true"
@@ -681,6 +730,14 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
681
730
  Number.isInteger(parsedMaxSubagentDepth) && parsedMaxSubagentDepth >= 0
682
731
  ? parsedMaxSubagentDepth
683
732
  : undefined,
733
+ maxExecutionTimeMs:
734
+ Number.isInteger(parsedMaxExecutionTimeMs) && parsedMaxExecutionTimeMs >= 1
735
+ ? parsedMaxExecutionTimeMs
736
+ : undefined,
737
+ maxTokens:
738
+ Number.isInteger(parsedMaxTokens) && parsedMaxTokens >= 1
739
+ ? parsedMaxTokens
740
+ : undefined,
684
741
  completionGuard,
685
742
  extraFields: Object.keys(extraFields).length > 0 ? extraFields : undefined,
686
743
  });
@@ -689,10 +746,11 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
689
746
  return agents;
690
747
  }
691
748
 
692
- function loadChainsFromDir(dir: string, source: AgentSource): ChainConfig[] {
693
- const chains: ChainConfig[] = [];
749
+ function loadChainsFromDir(dir: string, source: "user" | "project"): { chains: ChainConfig[]; diagnostics: ChainDiscoveryDiagnostic[] } {
750
+ const chains = new Map<string, ChainConfig>();
751
+ const diagnostics: ChainDiscoveryDiagnostic[] = [];
694
752
 
695
- for (const filePath of listMarkdownFilesRecursive(dir, (fileName) => fileName.endsWith(".chain.md"))) {
753
+ for (const filePath of listFilesRecursive(dir, (fileName) => fileName.endsWith(".chain.md") || fileName.endsWith(".chain.json"))) {
696
754
  let content: string;
697
755
  try {
698
756
  content = fs.readFileSync(filePath, "utf-8");
@@ -701,13 +759,17 @@ function loadChainsFromDir(dir: string, source: AgentSource): ChainConfig[] {
701
759
  }
702
760
 
703
761
  try {
704
- chains.push(parseChain(content, source, filePath));
705
- } catch {
762
+ const chain = filePath.endsWith(".chain.json") ? parseJsonChain(content, source, filePath) : parseChain(content, source, filePath);
763
+ const existing = chains.get(chain.name);
764
+ if (existing && existing.filePath.endsWith(".chain.json") && filePath.endsWith(".chain.md")) continue;
765
+ chains.set(chain.name, chain);
766
+ } catch (error) {
767
+ diagnostics.push({ source, filePath, error: error instanceof Error ? error.message : String(error) });
706
768
  continue;
707
769
  }
708
770
  }
709
771
 
710
- return chains;
772
+ return { chains: Array.from(chains.values()), diagnostics };
711
773
  }
712
774
 
713
775
  function isDirectory(p: string): boolean {
@@ -779,6 +841,7 @@ export function discoverAgentsAll(cwd: string): {
779
841
  user: AgentConfig[];
780
842
  project: AgentConfig[];
781
843
  chains: ChainConfig[];
844
+ chainDiagnostics: ChainDiscoveryDiagnostic[];
782
845
  userDir: string;
783
846
  projectDir: string | null;
784
847
  userChainDir: string;
@@ -816,17 +879,25 @@ export function discoverAgentsAll(cwd: string): {
816
879
  const project = Array.from(projectMap.values());
817
880
 
818
881
  const chainMap = new Map<string, ChainConfig>();
882
+ const projectChainDiagnostics: ChainDiscoveryDiagnostic[] = [];
819
883
  for (const dir of projectChainDirs) {
820
- for (const chain of loadChainsFromDir(dir, "project")) {
884
+ const loaded = loadChainsFromDir(dir, "project");
885
+ projectChainDiagnostics.push(...loaded.diagnostics);
886
+ for (const chain of loaded.chains) {
821
887
  chainMap.set(chain.name, chain);
822
888
  }
823
889
  }
890
+ const userChains = loadChainsFromDir(userChainDir, "user");
824
891
  const chains = [
825
- ...loadChainsFromDir(userChainDir, "user"),
892
+ ...userChains.chains,
826
893
  ...Array.from(chainMap.values()),
827
894
  ];
895
+ const chainDiagnostics = [
896
+ ...userChains.diagnostics,
897
+ ...projectChainDiagnostics,
898
+ ];
828
899
 
829
900
  const userDir = process.env.PI_CODING_AGENT_DIR ? userDirOld : fs.existsSync(userDirNew) ? userDirNew : userDirOld;
830
901
 
831
- return { builtin, user, project, chains, userDir, projectDir, userChainDir, projectChainDir, userSettingsPath, projectSettingsPath };
902
+ return { builtin, user, project, chains, chainDiagnostics, userDir, projectDir, userChainDir, projectChainDir, userSettingsPath, projectSettingsPath };
832
903
  }
@@ -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(", ")}`);
@@ -156,6 +156,8 @@ export default function registerFanoutChildSubagentExtension(pi: ExtensionAPI):
156
156
  label: "Subagent",
157
157
  description: [
158
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
+ "For implementation handoffs from a plan, PRD, spec, issue, or broad fix, put implementation instructions and plan paths in task, and put the definition of done, evidence, verification commands, constraints, and loop cap in acceptance.",
159
161
  "Allowed management/control actions: list, get, status, interrupt, resume, doctor.",
160
162
  "Agent config mutation actions create, update, and delete are blocked in this mode.",
161
163
  ].join("\n"),
@@ -394,7 +394,10 @@ EXECUTION (use exactly ONE mode):
394
394
  • SINGLE: { agent, task? } - one task; omit task for self-contained agents
395
395
  • CHAIN: { chain: [{agent:"agent-a"}, {parallel:[{agent:"agent-b",count:3}]}] } - sequential pipeline with optional parallel fan-out
396
396
  • PARALLEL: { tasks: [{agent,task,count?,output?,reads?,progress?}, ...], concurrency?: number, worktree?: true } - concurrent execution (worktree: isolate each task in a git worktree)
397
+ • Foreground timeout: { timeoutMs } or { maxRuntimeMs } - wall-clock limit for foreground single, parallel, and chain runs. Timed-out children return timedOut:true with completed sibling/prior results preserved. Not for async/background runs.
397
398
  • 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" })
399
+ • 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.
400
+ • Plan/spec implementation handoffs: when delegating a plan, PRD, spec, issue, or broad fix to an editing-capable child, prefer structured acceptance instead of burying validation requirements in task prose. Put the implementation instructions and plan paths in task; put the definition of done, evidence, verification commands, constraints, and loop cap in acceptance.
398
401
 
399
402
  CHAIN TEMPLATE VARIABLES (use in task strings):
400
403
  • {task} - The original task/request from the user
@@ -406,8 +409,8 @@ Example: { chain: [{agent:"agent-a", task:"Analyze {task}"}, {agent:"agent-b", t
406
409
  MANAGEMENT (use action field, omit agent/task/chain/tasks):
407
410
  • { action: "list" } - discover executable agents/chains
408
411
  • { action: "get", agent: "name" } - full detail; packaged agents use dotted runtime names like "package.agent"
409
- • { action: "create", config: { name: "custom-agent", package: "code-analysis", systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, defaultContext, ... } }
410
- • { action: "update", agent: "code-analysis.custom-agent", config: { package: "analysis", ... } } - merge
412
+ • { action: "create", config: { name: "custom-agent", package: "code-analysis", systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, defaultContext, maxExecutionTimeMs, maxTokens, ... } }
413
+ • { action: "update", agent: "code-analysis.custom-agent", config: { package: "analysis", maxExecutionTimeMs, maxTokens, ... } } - merge
411
414
  • { action: "delete", agent: "code-analysis.custom-agent" }
412
415
  • Use chainName for chain operations; packaged chains also use dotted runtime names
413
416