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
@@ -0,0 +1,52 @@
1
+ import * as path from "node:path";
2
+
3
+ const MAX_NESTED_ID_LENGTH = 128;
4
+ export const MAX_NESTED_PATH_ENTRIES = 4;
5
+
6
+ export type NestedPathEntry = { runId: string; stepIndex?: number; agent?: string };
7
+
8
+ export function isSafeNestedPathId(value: unknown): value is string {
9
+ return typeof value === "string"
10
+ && value.length > 0
11
+ && value.length <= MAX_NESTED_ID_LENGTH
12
+ && !path.isAbsolute(value)
13
+ && !value.includes("/")
14
+ && !value.includes("\\")
15
+ && !value.includes("..");
16
+ }
17
+
18
+ function finiteNumber(value: unknown): number | undefined {
19
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
20
+ }
21
+
22
+ function nonEmptyString(value: unknown, max: number): string | undefined {
23
+ return typeof value === "string" && value.length > 0 ? value.slice(0, max) : undefined;
24
+ }
25
+
26
+ export function sanitizeNestedPath(value: unknown): NestedPathEntry[] {
27
+ if (!Array.isArray(value)) return [];
28
+ return value.map((part) => {
29
+ if (!part || typeof part !== "object") return undefined;
30
+ const record = part as Record<string, unknown>;
31
+ if (!isSafeNestedPathId(record.runId)) return undefined;
32
+ return {
33
+ runId: record.runId,
34
+ ...(finiteNumber(record.stepIndex) !== undefined ? { stepIndex: finiteNumber(record.stepIndex) } : {}),
35
+ ...(nonEmptyString(record.agent, 128) ? { agent: nonEmptyString(record.agent, 128) } : {}),
36
+ };
37
+ }).filter((part): part is NestedPathEntry => Boolean(part)).slice(0, MAX_NESTED_PATH_ENTRIES);
38
+ }
39
+
40
+ export function parseNestedPathEnv(value: string | undefined): NestedPathEntry[] {
41
+ if (!value) return [];
42
+ try {
43
+ return sanitizeNestedPath(JSON.parse(value) as unknown);
44
+ } catch {
45
+ return [];
46
+ }
47
+ }
48
+
49
+ export function encodeNestedPathEnv(value: NestedPathEntry[]): string {
50
+ const sanitized = sanitizeNestedPath(value);
51
+ return sanitized.length ? JSON.stringify(sanitized) : "";
52
+ }
@@ -0,0 +1,115 @@
1
+ import { formatDuration, formatTokens, shortenPath } from "../../shared/formatters.ts";
2
+ import { formatActivityLabel } from "../../shared/status-format.ts";
3
+ import type { ActivityState, NestedRunSummary, NestedStepSummary } from "../../shared/types.ts";
4
+
5
+ export interface NestedRunCounts {
6
+ total: number;
7
+ running: number;
8
+ paused: number;
9
+ complete: number;
10
+ failed: number;
11
+ queued: number;
12
+ }
13
+
14
+ export function countNestedRuns(children: NestedRunSummary[] | undefined): NestedRunCounts {
15
+ const counts: NestedRunCounts = { total: 0, running: 0, paused: 0, complete: 0, failed: 0, queued: 0 };
16
+ for (const child of children ?? []) {
17
+ counts.total++;
18
+ counts[child.state]++;
19
+ const nested = countNestedRuns([...(child.children ?? []), ...(child.steps?.flatMap((step) => step.children ?? []) ?? [])]);
20
+ counts.total += nested.total;
21
+ counts.running += nested.running;
22
+ counts.paused += nested.paused;
23
+ counts.complete += nested.complete;
24
+ counts.failed += nested.failed;
25
+ counts.queued += nested.queued;
26
+ }
27
+ return counts;
28
+ }
29
+
30
+ export function formatNestedAggregate(children: NestedRunSummary[] | undefined): string | undefined {
31
+ const counts = countNestedRuns(children);
32
+ if (counts.total === 0) return undefined;
33
+ const parts = [
34
+ counts.running > 0 ? `${counts.running} running` : "",
35
+ counts.paused > 0 ? `${counts.paused} paused` : "",
36
+ counts.failed > 0 ? `${counts.failed} failed` : "",
37
+ counts.complete > 0 ? `${counts.complete} complete` : "",
38
+ counts.queued > 0 ? `${counts.queued} queued` : "",
39
+ ].filter(Boolean);
40
+ return `+${counts.total} nested run${counts.total === 1 ? "" : "s"}${parts.length ? ` (${parts.join(", ")})` : ""}`;
41
+ }
42
+
43
+ function nestedRunLabel(run: NestedRunSummary): string {
44
+ if (run.agent) return run.agent;
45
+ if (run.agents?.length) return run.agents.length === 1 ? run.agents[0]! : `${run.agents.slice(0, 2).join(", ")}${run.agents.length > 2 ? ` +${run.agents.length - 2}` : ""}`;
46
+ return run.id;
47
+ }
48
+
49
+ function formatNestedActivity(input: {
50
+ activityState?: ActivityState;
51
+ lastActivityAt?: number;
52
+ currentTool?: string;
53
+ currentToolStartedAt?: number;
54
+ currentPath?: string;
55
+ turnCount?: number;
56
+ toolCount?: number;
57
+ totalTokens?: NestedRunSummary["totalTokens"];
58
+ }): string | undefined {
59
+ const facts: string[] = [];
60
+ if (input.currentTool && input.currentToolStartedAt !== undefined) facts.push(`tool ${input.currentTool} ${formatDuration(Math.max(0, Date.now() - input.currentToolStartedAt))}`);
61
+ else if (input.currentTool) facts.push(`tool ${input.currentTool}`);
62
+ if (input.currentPath) facts.push(shortenPath(input.currentPath));
63
+ if (input.turnCount !== undefined) facts.push(`${input.turnCount} turns`);
64
+ if (input.toolCount !== undefined) facts.push(`${input.toolCount} tools`);
65
+ if (input.totalTokens) facts.push(`${formatTokens(input.totalTokens.total)} tok`);
66
+ const activity = formatActivityLabel(input.lastActivityAt, input.activityState as ActivityState | undefined);
67
+ return activity || facts.length ? [activity, ...facts].filter(Boolean).join(" | ") : undefined;
68
+ }
69
+
70
+ function formatNestedRunLines(children: NestedRunSummary[] | undefined, options: { indent: string; maxDepth: number; maxLines: number; commandHints?: boolean }): string[] {
71
+ const lines: string[] = [];
72
+ const append = (items: NestedRunSummary[] | undefined, depth: number, indent: string): void => {
73
+ if (!items?.length || lines.length >= options.maxLines) return;
74
+ if (depth > options.maxDepth) {
75
+ const aggregate = formatNestedAggregate(items);
76
+ if (aggregate && lines.length < options.maxLines) lines.push(`${indent}↳ ${aggregate}`);
77
+ return;
78
+ }
79
+ for (let index = 0; index < items.length; index++) {
80
+ const child = items[index]!;
81
+ if (lines.length >= options.maxLines) {
82
+ const aggregate = formatNestedAggregate(items.slice(index));
83
+ if (aggregate) lines[lines.length - 1] = `${indent}↳ ${aggregate}`;
84
+ return;
85
+ }
86
+ const activity = child.state === "running" ? formatNestedActivity(child) : undefined;
87
+ const error = child.error ? ` | error: ${child.error}` : "";
88
+ lines.push(`${indent}↳ ${nestedRunLabel(child)} [${child.id}] ${child.state}${activity ? ` | ${activity}` : ""}${error}`);
89
+ if (options.commandHints && lines.length < options.maxLines) lines.push(`${indent} Status: subagent({ action: "status", id: "${child.id}" })`);
90
+ if (depth === options.maxDepth) {
91
+ const aggregate = formatNestedAggregate([...(child.steps?.flatMap((step) => step.children ?? []) ?? []), ...(child.children ?? [])]);
92
+ if (aggregate && lines.length < options.maxLines) lines.push(`${indent} ↳ ${aggregate}`);
93
+ continue;
94
+ }
95
+ for (const [stepIndex, step] of (child.steps ?? []).entries()) {
96
+ if (lines.length >= options.maxLines) return;
97
+ const stepActivity = step.status === "running" ? formatNestedActivity(step) : undefined;
98
+ lines.push(`${indent} ${stepIndex + 1}. ${step.agent} ${step.status}${stepActivity ? ` | ${stepActivity}` : ""}${step.error ? ` | error: ${step.error}` : ""}`);
99
+ append(step.children, depth + 1, `${indent} `);
100
+ }
101
+ append(child.children, depth + 1, `${indent} `);
102
+ }
103
+ };
104
+ append(children, 0, options.indent);
105
+ return lines;
106
+ }
107
+
108
+ export function formatNestedRunStatusLines(children: NestedRunSummary[] | undefined, options: { indent?: string; maxDepth?: number; maxLines?: number; commandHints?: boolean } = {}): string[] {
109
+ return formatNestedRunLines(children, {
110
+ indent: options.indent ?? " ",
111
+ maxDepth: options.maxDepth ?? 2,
112
+ maxLines: options.maxLines ?? 40,
113
+ commandHints: options.commandHints ?? false,
114
+ });
115
+ }
@@ -1,6 +1,13 @@
1
+ import type { DynamicCollectSpec, DynamicExpandSpec } from "../../shared/settings.ts";
2
+ import type { JsonSchemaObject, ResolvedAcceptanceConfig } from "../../shared/types.ts";
3
+
1
4
  export interface RunnerSubagentStep {
2
5
  agent: string;
3
6
  task: string;
7
+ phase?: string;
8
+ label?: string;
9
+ outputName?: string;
10
+ structured?: boolean;
4
11
  cwd?: string;
5
12
  model?: string;
6
13
  thinking?: string;
@@ -18,6 +25,13 @@ export interface RunnerSubagentStep {
18
25
  outputMode?: "inline" | "file-only";
19
26
  sessionFile?: string;
20
27
  maxSubagentDepth?: number;
28
+ structuredOutput?: {
29
+ schema: JsonSchemaObject;
30
+ schemaPath: string;
31
+ outputPath: string;
32
+ };
33
+ structuredOutputSchema?: JsonSchemaObject;
34
+ effectiveAcceptance?: ResolvedAcceptanceConfig;
21
35
  }
22
36
 
23
37
  export interface ParallelStepGroup {
@@ -27,17 +41,33 @@ export interface ParallelStepGroup {
27
41
  worktree?: boolean;
28
42
  }
29
43
 
30
- export type RunnerStep = RunnerSubagentStep | ParallelStepGroup;
44
+ export interface DynamicRunnerGroup {
45
+ expand: DynamicExpandSpec;
46
+ parallel: RunnerSubagentStep;
47
+ collect: DynamicCollectSpec;
48
+ concurrency?: number;
49
+ failFast?: boolean;
50
+ phase?: string;
51
+ label?: string;
52
+ }
53
+
54
+ export type RunnerStep = RunnerSubagentStep | ParallelStepGroup | DynamicRunnerGroup;
31
55
 
32
56
  export function isParallelGroup(step: RunnerStep): step is ParallelStepGroup {
33
57
  return "parallel" in step && Array.isArray(step.parallel);
34
58
  }
35
59
 
60
+ export function isDynamicRunnerGroup(step: RunnerStep): step is DynamicRunnerGroup {
61
+ return "expand" in step && "collect" in step && "parallel" in step && !Array.isArray((step as { parallel?: unknown }).parallel);
62
+ }
63
+
36
64
  export function flattenSteps(steps: RunnerStep[]): RunnerSubagentStep[] {
37
65
  const flat: RunnerSubagentStep[] = [];
38
66
  for (const step of steps) {
39
67
  if (isParallelGroup(step)) {
40
68
  for (const task of step.parallel) flat.push(task);
69
+ } else if (isDynamicRunnerGroup(step)) {
70
+ continue;
41
71
  } else {
42
72
  flat.push(step);
43
73
  }
@@ -2,16 +2,29 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { encodeNestedPathEnv, parseNestedPathEnv, type NestedPathEntry } from "./nested-path.ts";
5
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";
6
9
 
7
10
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
8
11
  const TASK_ARG_LIMIT = 8000;
9
12
  const PROMPT_RUNTIME_EXTENSION_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "subagent-prompt-runtime.ts");
13
+ const FANOUT_CHILD_EXTENSION_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "extension", "fanout-child.ts");
10
14
  export const SUBAGENT_CHILD_ENV = "PI_SUBAGENT_CHILD";
11
15
  export const SUBAGENT_ORCHESTRATOR_TARGET_ENV = "PI_SUBAGENT_ORCHESTRATOR_TARGET";
12
16
  export const SUBAGENT_RUN_ID_ENV = "PI_SUBAGENT_RUN_ID";
13
17
  export const SUBAGENT_CHILD_AGENT_ENV = "PI_SUBAGENT_CHILD_AGENT";
14
18
  export const SUBAGENT_CHILD_INDEX_ENV = "PI_SUBAGENT_CHILD_INDEX";
19
+ export const SUBAGENT_FANOUT_CHILD_ENV = "PI_SUBAGENT_FANOUT_CHILD";
20
+ export const SUBAGENT_PARENT_EVENT_SINK_ENV = "PI_SUBAGENT_PARENT_EVENT_SINK";
21
+ export const SUBAGENT_PARENT_CONTROL_INBOX_ENV = "PI_SUBAGENT_PARENT_CONTROL_INBOX";
22
+ export const SUBAGENT_PARENT_ROOT_RUN_ID_ENV = "PI_SUBAGENT_PARENT_ROOT_RUN_ID";
23
+ export const SUBAGENT_PARENT_RUN_ID_ENV = "PI_SUBAGENT_PARENT_RUN_ID";
24
+ export const SUBAGENT_PARENT_CHILD_INDEX_ENV = "PI_SUBAGENT_PARENT_CHILD_INDEX";
25
+ export const SUBAGENT_PARENT_DEPTH_ENV = "PI_SUBAGENT_PARENT_DEPTH";
26
+ export const SUBAGENT_PARENT_PATH_ENV = "PI_SUBAGENT_PARENT_PATH";
27
+ export const SUBAGENT_PARENT_CAPABILITY_TOKEN_ENV = "PI_SUBAGENT_PARENT_CAPABILITY_TOKEN";
15
28
 
16
29
  interface BuildPiArgsInput {
17
30
  baseArgs: string[];
@@ -35,6 +48,19 @@ interface BuildPiArgsInput {
35
48
  runId?: string;
36
49
  childAgentName?: string;
37
50
  childIndex?: number;
51
+ parentEventSink?: string;
52
+ parentControlInbox?: string;
53
+ parentRootRunId?: string;
54
+ parentRunId?: string;
55
+ parentChildIndex?: number;
56
+ parentDepth?: number;
57
+ parentPath?: NestedPathEntry[];
58
+ parentCapabilityToken?: string;
59
+ structuredOutput?: {
60
+ schema: JsonSchemaObject;
61
+ schemaPath: string;
62
+ outputPath: string;
63
+ };
38
64
  }
39
65
 
40
66
  interface BuildPiArgsResult {
@@ -71,14 +97,14 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
71
97
  args.push("--model", modelArg);
72
98
  }
73
99
 
100
+ const declaredBuiltinTools = input.tools?.filter((tool) => !(tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js"))) ?? [];
101
+ const fanoutAuthorized = declaredBuiltinTools.includes("subagent");
74
102
  const toolExtensionPaths: string[] = [];
75
103
  if (input.tools?.length) {
76
- const builtinTools: string[] = [];
104
+ const builtinTools = [...declaredBuiltinTools];
77
105
  for (const tool of input.tools) {
78
- if (tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js")) {
106
+ if (!declaredBuiltinTools.includes(tool) && (tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js"))) {
79
107
  toolExtensionPaths.push(tool);
80
- } else {
81
- builtinTools.push(tool);
82
108
  }
83
109
  }
84
110
  if (builtinTools.length > 0) {
@@ -89,7 +115,9 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
89
115
  }
90
116
  }
91
117
 
92
- const runtimeExtensions = [PROMPT_RUNTIME_EXTENSION_PATH];
118
+ const runtimeExtensions = fanoutAuthorized
119
+ ? [PROMPT_RUNTIME_EXTENSION_PATH, FANOUT_CHILD_EXTENSION_PATH]
120
+ : [PROMPT_RUNTIME_EXTENSION_PATH];
93
121
  if (input.extensions !== undefined) {
94
122
  args.push("--no-extensions");
95
123
  for (const extPath of [...new Set([...runtimeExtensions, ...toolExtensionPaths, ...input.extensions])]) {
@@ -127,6 +155,40 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
127
155
 
128
156
  const env: Record<string, string | undefined> = {};
129
157
  env[SUBAGENT_CHILD_ENV] = "1";
158
+ env[SUBAGENT_FANOUT_CHILD_ENV] = fanoutAuthorized ? "1" : "0";
159
+ const inheritedNestedRoute = Boolean(process.env[SUBAGENT_PARENT_EVENT_SINK_ENV] && process.env[SUBAGENT_PARENT_ROOT_RUN_ID_ENV] && process.env[SUBAGENT_PARENT_CAPABILITY_TOKEN_ENV]);
160
+ const parentRunId = input.parentRunId ?? input.runId ?? (inheritedNestedRoute ? process.env[SUBAGENT_RUN_ID_ENV] : undefined) ?? process.env[SUBAGENT_PARENT_RUN_ID_ENV] ?? "";
161
+ const parentChildIndex = input.parentChildIndex !== undefined
162
+ ? String(input.parentChildIndex)
163
+ : input.childIndex !== undefined
164
+ ? String(input.childIndex)
165
+ : process.env[SUBAGENT_PARENT_CHILD_INDEX_ENV] ?? "";
166
+ const inheritedDepth = Number(process.env[SUBAGENT_PARENT_DEPTH_ENV]);
167
+ const parentDepth = input.parentDepth ?? (inheritedNestedRoute && Number.isFinite(inheritedDepth) ? inheritedDepth + 1 : 1);
168
+ const parentPath = input.parentPath ?? [
169
+ ...parseNestedPathEnv(process.env[SUBAGENT_PARENT_PATH_ENV]),
170
+ ...(parentRunId ? [{
171
+ runId: parentRunId,
172
+ ...(parentChildIndex && /^\d+$/.test(parentChildIndex) ? { stepIndex: Number(parentChildIndex) } : {}),
173
+ ...(input.childAgentName ? { agent: input.childAgentName } : {}),
174
+ }] : []),
175
+ ];
176
+ env[SUBAGENT_PARENT_EVENT_SINK_ENV] = fanoutAuthorized
177
+ ? input.parentEventSink ?? process.env[SUBAGENT_PARENT_EVENT_SINK_ENV] ?? ""
178
+ : "";
179
+ env[SUBAGENT_PARENT_CONTROL_INBOX_ENV] = fanoutAuthorized
180
+ ? input.parentControlInbox ?? process.env[SUBAGENT_PARENT_CONTROL_INBOX_ENV] ?? ""
181
+ : "";
182
+ env[SUBAGENT_PARENT_ROOT_RUN_ID_ENV] = fanoutAuthorized
183
+ ? input.parentRootRunId ?? process.env[SUBAGENT_PARENT_ROOT_RUN_ID_ENV] ?? input.runId ?? ""
184
+ : "";
185
+ env[SUBAGENT_PARENT_RUN_ID_ENV] = fanoutAuthorized ? parentRunId : "";
186
+ env[SUBAGENT_PARENT_CHILD_INDEX_ENV] = fanoutAuthorized ? parentChildIndex : "";
187
+ env[SUBAGENT_PARENT_DEPTH_ENV] = fanoutAuthorized ? String(parentDepth) : "";
188
+ env[SUBAGENT_PARENT_PATH_ENV] = fanoutAuthorized ? encodeNestedPathEnv(parentPath) : "";
189
+ env[SUBAGENT_PARENT_CAPABILITY_TOKEN_ENV] = fanoutAuthorized
190
+ ? input.parentCapabilityToken ?? process.env[SUBAGENT_PARENT_CAPABILITY_TOKEN_ENV] ?? ""
191
+ : "";
130
192
  env.PI_SUBAGENT_INHERIT_PROJECT_CONTEXT = input.inheritProjectContext ? "1" : "0";
131
193
  env.PI_SUBAGENT_INHERIT_SKILLS = input.inheritSkills ? "1" : "0";
132
194
  if (input.intercomSessionName) {
@@ -149,10 +211,16 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
149
211
  } else {
150
212
  env.MCP_DIRECT_TOOLS = "__none__";
151
213
  }
214
+ if (input.structuredOutput) {
215
+ env[STRUCTURED_OUTPUT_CAPTURE_ENV] = input.structuredOutput.outputPath;
216
+ env[STRUCTURED_OUTPUT_SCHEMA_ENV] = input.structuredOutput.schemaPath;
217
+ }
152
218
 
153
219
  return { args, env, tempDir };
154
220
  }
155
221
 
222
+ export const parseParentPathEnv = parseNestedPathEnv;
223
+
156
224
  export function cleanupTempDir(tempDir: string | null | undefined): void {
157
225
  if (!tempDir) return;
158
226
  try {
@@ -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,9 +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";
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";
2
7
 
3
8
  const SUBAGENT_INHERIT_PROJECT_CONTEXT_ENV = "PI_SUBAGENT_INHERIT_PROJECT_CONTEXT";
4
9
  const SUBAGENT_INHERIT_SKILLS_ENV = "PI_SUBAGENT_INHERIT_SKILLS";
5
10
  export const SUBAGENT_INTERCOM_SESSION_NAME_ENV = "PI_SUBAGENT_INTERCOM_SESSION_NAME";
6
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
+
7
18
  export const CHILD_SUBAGENT_BOUNDARY_INSTRUCTIONS = [
8
19
  "You are a child subagent, not the parent orchestrator.",
9
20
  "The parent session owns delegation, orchestration, review fanout, and follow-up worker launches.",
@@ -12,6 +23,15 @@ export const CHILD_SUBAGENT_BOUNDARY_INSTRUCTIONS = [
12
23
  "If you need to edit files, call the actual edit/write tools. Do not print tool-call syntax, patches, or pseudo-tool calls as text.",
13
24
  ].join("\n");
14
25
 
26
+ export const CHILD_FANOUT_BOUNDARY_INSTRUCTIONS = [
27
+ "You are a child subagent with explicit fanout responsibility for this assigned task.",
28
+ "The parent session owns final orchestration, acceptance, and follow-up implementation launches.",
29
+ "You may use the `subagent` tool only for the fanout work explicitly requested in this task.",
30
+ "Do not broaden yourself into general parent orchestration. Do not launch follow-up workers unless the task explicitly asks for that.",
31
+ "The maxSubagentDepth cap still applies and may block further fanout.",
32
+ "If you need to edit files, call the actual edit/write tools. Do not print tool-call syntax, patches, or pseudo-tool calls as text.",
33
+ ].join("\n");
34
+
15
35
  const PARENT_ONLY_CUSTOM_MESSAGE_TYPES = new Set([
16
36
  "subagent-orchestration-instructions",
17
37
  "subagent-slash-result",
@@ -62,9 +82,17 @@ export function stripSubagentOrchestrationSkill(prompt: string): string {
62
82
  .replace(/[ \t]*<skill>\s*[\s\S]*?<\/skill>\s*/g, (block) => SUBAGENT_ORCHESTRATION_SKILL_NAME_PATTERN.test(block) ? "" : block);
63
83
  }
64
84
 
85
+ function stripChildBoundaryInstructions(prompt: string): string {
86
+ let rewritten = prompt;
87
+ for (const boundary of [CHILD_SUBAGENT_BOUNDARY_INSTRUCTIONS, CHILD_FANOUT_BOUNDARY_INSTRUCTIONS]) {
88
+ rewritten = rewritten.split(boundary).join("");
89
+ }
90
+ return rewritten.replace(/^(?:[ \t]*\r?\n)+/, "");
91
+ }
92
+
65
93
  export function rewriteSubagentPrompt(
66
94
  prompt: string,
67
- options: { inheritProjectContext: boolean; inheritSkills: boolean },
95
+ options: { inheritProjectContext: boolean; inheritSkills: boolean; fanoutChild?: boolean },
68
96
  ): string {
69
97
  let rewritten = prompt;
70
98
  if (!options.inheritProjectContext) {
@@ -74,9 +102,10 @@ export function rewriteSubagentPrompt(
74
102
  rewritten = stripInheritedSkills(rewritten);
75
103
  }
76
104
  rewritten = stripSubagentOrchestrationSkill(rewritten);
77
- return rewritten.includes(CHILD_SUBAGENT_BOUNDARY_INSTRUCTIONS)
78
- ? rewritten
79
- : `${CHILD_SUBAGENT_BOUNDARY_INSTRUCTIONS}\n\n${rewritten}`;
105
+ rewritten = stripChildBoundaryInstructions(rewritten);
106
+ const boundary = options.fanoutChild ? CHILD_FANOUT_BOUNDARY_INSTRUCTIONS : CHILD_SUBAGENT_BOUNDARY_INSTRUCTIONS;
107
+ const structured = process.env[STRUCTURED_OUTPUT_CAPTURE_ENV] ? `\n\n${STRUCTURED_OUTPUT_INSTRUCTIONS}` : "";
108
+ return `${boundary}${structured}\n\n${rewritten}`;
80
109
  }
81
110
 
82
111
  function isParentOnlySubagentMessage(message: unknown): boolean {
@@ -125,13 +154,52 @@ export function stripParentOnlySubagentMessages(messages: unknown[]): unknown[]
125
154
  }
126
155
 
127
156
  export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
128
- 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[] }) => {
129
197
  const messages = stripParentOnlySubagentMessages(event.messages);
130
198
  if (messages === event.messages) return undefined;
131
199
  return { messages };
132
200
  });
133
201
 
134
- pi.on("before_agent_start", async (event) => {
202
+ onRuntimeEvent("before_agent_start", async (event: { systemPrompt: string }) => {
135
203
  const intercomSessionName = process.env[SUBAGENT_INTERCOM_SESSION_NAME_ENV]?.trim();
136
204
  if (intercomSessionName && typeof pi.setSessionName === "function") {
137
205
  pi.setSessionName(intercomSessionName);
@@ -139,10 +207,12 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
139
207
 
140
208
  const inheritProjectContext = readBooleanEnv(SUBAGENT_INHERIT_PROJECT_CONTEXT_ENV);
141
209
  const inheritSkills = readBooleanEnv(SUBAGENT_INHERIT_SKILLS_ENV);
142
- if (inheritProjectContext === undefined && inheritSkills === undefined) return;
210
+ const fanoutChild = readBooleanEnv(SUBAGENT_FANOUT_CHILD_ENV);
211
+ if (inheritProjectContext === undefined && inheritSkills === undefined && fanoutChild === undefined) return;
143
212
  const rewritten = rewriteSubagentPrompt(event.systemPrompt, {
144
213
  inheritProjectContext: inheritProjectContext ?? true,
145
214
  inheritSkills: inheritSkills ?? true,
215
+ fanoutChild: fanoutChild === true,
146
216
  });
147
217
  if (rewritten === event.systemPrompt) return;
148
218
  return { systemPrompt: rewritten };