pi-subagents 0.34.0 → 0.35.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 (98) hide show
  1. package/CHANGELOG.md +78 -9
  2. package/README.md +213 -32
  3. package/index.ts +1 -0
  4. package/install.mjs +1 -1
  5. package/package.json +23 -8
  6. package/prompts/review-loop.md +3 -1
  7. package/skills/pi-subagents/SKILL.md +87 -25
  8. package/src/agents/agent-management.ts +82 -15
  9. package/src/agents/agent-serializer.ts +19 -0
  10. package/src/agents/agents.ts +91 -49
  11. package/src/agents/frontmatter.ts +67 -13
  12. package/src/agents/skills.ts +25 -12
  13. package/src/api/background-work.ts +197 -0
  14. package/src/api/delegation.ts +158 -0
  15. package/src/extension/chain-validation.ts +165 -0
  16. package/src/extension/doctor.ts +15 -0
  17. package/src/extension/fanout-child.ts +3 -1
  18. package/src/extension/index.ts +65 -124
  19. package/src/extension/rpc.ts +10 -2
  20. package/src/extension/schemas.ts +18 -14
  21. package/src/extension/steering-notices.ts +35 -0
  22. package/src/extension/tool-description.ts +20 -9
  23. package/src/intercom/intercom-bridge.ts +3 -2
  24. package/src/intercom/native-supervisor-channel.ts +9 -1
  25. package/src/intercom/result-intercom.ts +4 -0
  26. package/src/runs/background/async-execution.ts +293 -44
  27. package/src/runs/background/async-job-tracker.ts +56 -9
  28. package/src/runs/background/async-resume.ts +159 -52
  29. package/src/runs/background/async-status.ts +25 -18
  30. package/src/runs/background/auto-drain.ts +67 -0
  31. package/src/runs/background/chain-root-attachment.ts +16 -8
  32. package/src/runs/background/control-channel.ts +260 -13
  33. package/src/runs/background/fleet-view.ts +23 -2
  34. package/src/runs/background/notify.ts +79 -10
  35. package/src/runs/background/result-watcher.ts +12 -9
  36. package/src/runs/background/run-id-resolver.ts +14 -2
  37. package/src/runs/background/run-status.ts +23 -15
  38. package/src/runs/background/scheduled-runs.ts +3 -0
  39. package/src/runs/background/stale-run-reconciler.ts +32 -10
  40. package/src/runs/background/steering.ts +237 -0
  41. package/src/runs/background/subagent-runner.ts +898 -236
  42. package/src/runs/background/subagent-wait.ts +484 -0
  43. package/src/runs/background/top-level-async.ts +2 -1
  44. package/src/runs/background/wait-config.ts +36 -0
  45. package/src/runs/background/wait-tool.ts +26 -0
  46. package/src/runs/foreground/async-steering-action.ts +230 -0
  47. package/src/runs/foreground/chain-clarify.ts +22 -6
  48. package/src/runs/foreground/chain-execution.ts +50 -32
  49. package/src/runs/foreground/execution.ts +308 -94
  50. package/src/runs/foreground/subagent-executor.ts +592 -268
  51. package/src/runs/shared/acceptance.ts +355 -97
  52. package/src/runs/shared/child-protocol.ts +121 -0
  53. package/src/runs/shared/completion-guard.ts +8 -127
  54. package/src/runs/shared/dynamic-fanout.ts +6 -4
  55. package/src/runs/shared/model-fallback.ts +36 -0
  56. package/src/runs/shared/nested-events.ts +9 -4
  57. package/src/runs/shared/nested-render.ts +4 -1
  58. package/src/runs/shared/parallel-utils.ts +7 -0
  59. package/src/runs/shared/pi-args.ts +34 -7
  60. package/src/runs/shared/pi-spawn.ts +18 -12
  61. package/src/runs/shared/session-lease.ts +279 -0
  62. package/src/runs/shared/single-output.ts +61 -6
  63. package/src/runs/shared/spawn-budget.ts +128 -0
  64. package/src/runs/shared/subagent-control.ts +10 -6
  65. package/src/runs/shared/subagent-prompt-runtime.ts +127 -26
  66. package/src/runs/shared/task-intent.ts +176 -0
  67. package/src/runs/shared/tool-availability.ts +65 -0
  68. package/src/runs/shared/turn-budget.ts +49 -4
  69. package/src/shared/atomic-json.ts +4 -1
  70. package/src/shared/fork-context.ts +28 -3
  71. package/src/shared/model-info.ts +7 -4
  72. package/src/shared/status-format.ts +7 -1
  73. package/src/shared/types.ts +203 -25
  74. package/src/shared/utils.ts +35 -7
  75. package/src/slash/delegation-adapters.ts +457 -0
  76. package/src/slash/delegation-request.ts +103 -0
  77. package/src/slash/prompt-template-bridge.ts +167 -344
  78. package/src/slash/slash-commands.ts +239 -6
  79. package/src/slash/subagents-admin.ts +428 -0
  80. package/src/slash/subagents-editor.ts +86 -0
  81. package/src/tui/fleet.ts +405 -0
  82. package/src/tui/render.ts +90 -16
  83. package/src/watchdog/change-signature.ts +127 -0
  84. package/src/watchdog/child-status.ts +205 -0
  85. package/src/watchdog/emission-guard.ts +123 -0
  86. package/src/watchdog/lsp-diagnostics.ts +532 -0
  87. package/src/watchdog/model-selection.ts +167 -0
  88. package/src/watchdog/register-child.ts +117 -0
  89. package/src/watchdog/register-main.ts +433 -0
  90. package/src/watchdog/render.ts +54 -0
  91. package/src/watchdog/review.ts +293 -0
  92. package/src/watchdog/runtime.ts +712 -0
  93. package/src/watchdog/settings.ts +528 -0
  94. package/src/watchdog/tool-actions.ts +155 -0
  95. package/src/watchdog/turn-delta.ts +161 -0
  96. package/src/watchdog/types.ts +188 -0
  97. package/src/watchdog/warning-format.ts +73 -0
  98. package/src/runs/background/wait.ts +0 -394
@@ -0,0 +1,121 @@
1
+ import { Buffer } from "node:buffer";
2
+ import type { ProtocolOutputLimit } from "../../shared/types.ts";
3
+
4
+ export type { ProtocolOutputLimit } from "../../shared/types.ts";
5
+
6
+ export const MAX_CHILD_PENDING_LINE_BYTES = 4 * 1024 * 1024;
7
+ export const MAX_CHILD_STDERR_BYTES = 128 * 1024;
8
+ const MAX_PROTOCOL_DIAGNOSTIC_BYTES = 4096;
9
+
10
+ export function formatProtocolOutputLimit(limit: ProtocolOutputLimit): string {
11
+ return `${limit.code}: child ${limit.stream} line exceeded ${limit.limitBytes} bytes (observed at least ${limit.observedBytes} bytes without a newline).`;
12
+ }
13
+
14
+ export function createBoundedLineReader(options: {
15
+ stream?: "stdout" | "stderr";
16
+ maxPendingLineBytes?: number;
17
+ onLine: (line: string) => void;
18
+ onLimit: (limit: ProtocolOutputLimit) => void;
19
+ }): {
20
+ push(chunk: Buffer | string): void;
21
+ end(): void;
22
+ exceeded(): boolean;
23
+ } {
24
+ const maxPendingLineBytes = options.maxPendingLineBytes ?? MAX_CHILD_PENDING_LINE_BYTES;
25
+ if (!Number.isInteger(maxPendingLineBytes) || maxPendingLineBytes < 1) {
26
+ throw new Error("maxPendingLineBytes must be a positive integer.");
27
+ }
28
+ let pending: Buffer[] = [];
29
+ let pendingBytes = 0;
30
+ let limitExceeded = false;
31
+
32
+ const emitPending = (): void => {
33
+ if (pendingBytes === 0) return;
34
+ options.onLine(Buffer.concat(pending, pendingBytes).toString("utf8"));
35
+ pending = [];
36
+ pendingBytes = 0;
37
+ };
38
+
39
+ const append = (segment: Buffer): boolean => {
40
+ if (segment.length === 0) return true;
41
+ const observedBytes = pendingBytes + segment.length;
42
+ if (observedBytes > maxPendingLineBytes) {
43
+ const prior = pendingBytes > 0 ? Buffer.concat(pending, pendingBytes) : Buffer.alloc(0);
44
+ const prefixFromPrior = prior.subarray(0, MAX_PROTOCOL_DIAGNOSTIC_BYTES);
45
+ const prefix = prefixFromPrior.length === MAX_PROTOCOL_DIAGNOSTIC_BYTES
46
+ ? prefixFromPrior
47
+ : Buffer.concat([prefixFromPrior, segment.subarray(0, MAX_PROTOCOL_DIAGNOSTIC_BYTES - prefixFromPrior.length)]);
48
+ const tailFromSegment = segment.subarray(Math.max(0, segment.length - MAX_PROTOCOL_DIAGNOSTIC_BYTES));
49
+ const tail = tailFromSegment.length === MAX_PROTOCOL_DIAGNOSTIC_BYTES
50
+ ? tailFromSegment
51
+ : Buffer.concat([prior.subarray(Math.max(0, prior.length - (MAX_PROTOCOL_DIAGNOSTIC_BYTES - tailFromSegment.length))), tailFromSegment]);
52
+ limitExceeded = true;
53
+ pending = [];
54
+ pendingBytes = 0;
55
+ options.onLimit({
56
+ code: "protocol_output_limit",
57
+ stream: options.stream ?? "stdout",
58
+ limitBytes: maxPendingLineBytes,
59
+ observedBytes,
60
+ diagnosticPrefix: prefix.toString("utf8"),
61
+ diagnosticTail: tail.toString("utf8"),
62
+ });
63
+ return false;
64
+ }
65
+ pending.push(segment);
66
+ pendingBytes = observedBytes;
67
+ return true;
68
+ };
69
+
70
+ return {
71
+ push(chunk) {
72
+ if (limitExceeded) return;
73
+ const bytes = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
74
+ let start = 0;
75
+ for (let index = 0; index < bytes.length; index++) {
76
+ if (bytes[index] !== 0x0a) continue;
77
+ if (!append(bytes.subarray(start, index))) return;
78
+ emitPending();
79
+ start = index + 1;
80
+ }
81
+ append(bytes.subarray(start));
82
+ },
83
+ end() {
84
+ if (!limitExceeded) emitPending();
85
+ },
86
+ exceeded: () => limitExceeded,
87
+ };
88
+ }
89
+
90
+ function trimToUtf8Boundary(buffer: Buffer, maxBytes: number): Buffer {
91
+ if (buffer.length <= maxBytes) return buffer;
92
+ let start = buffer.length - maxBytes;
93
+ while (start < buffer.length && (buffer[start]! & 0xc0) === 0x80) start++;
94
+ return buffer.subarray(start);
95
+ }
96
+
97
+ export function createBoundedByteTail(maxBytes = MAX_CHILD_STDERR_BYTES): {
98
+ push(chunk: Buffer | string): void;
99
+ text(): string;
100
+ byteLength(): number;
101
+ } {
102
+ if (!Number.isInteger(maxBytes) || maxBytes < 1) throw new Error("maxBytes must be a positive integer.");
103
+ let tail = Buffer.alloc(0);
104
+ return {
105
+ push(chunk) {
106
+ const bytes = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
107
+ tail = trimToUtf8Boundary(Buffer.concat([tail, bytes]), maxBytes);
108
+ },
109
+ text: () => tail.toString("utf8"),
110
+ byteLength: () => tail.length,
111
+ };
112
+ }
113
+
114
+ export type ChildLifecycleAction = "start-drain" | "cancel-drain" | "none";
115
+
116
+ export function projectChildLifecycle(event: { type?: string; willRetry?: unknown }, terminalAssistantStop = false): ChildLifecycleAction {
117
+ if (event.type === "agent_end" && event.willRetry === true) return "cancel-drain";
118
+ if (event.type === "agent_settled") return "start-drain";
119
+ if (terminalAssistantStop) return "start-drain";
120
+ return "none";
121
+ }
@@ -1,76 +1,8 @@
1
1
  import type { Message } from "@earendil-works/pi-ai";
2
2
  import { isMutatingBashCommand } from "./long-running-guard.ts";
3
+ import { expectsImplementationMutation } from "./task-intent.ts";
3
4
 
4
- const REVIEW_ONLY_PATTERNS = [
5
- /\breview only\b/i,
6
- /\bsuggest fixes only\b/i,
7
- /\bonly return findings\b/i,
8
- /\breturn findings only\b/i,
9
- ];
10
-
11
- const REVIEWER_REQUIRED_EDIT_PATTERNS = [
12
- /\bmust\s+(?:edit|modify|change|fix|patch|apply)\b/i,
13
- /\brequired\s+to\s+(?:edit|modify|change|fix|patch|apply)\b/i,
14
- /\bregardless\s+of\s+findings\b/i,
15
- /\balways\s+(?:edit|modify|change|fix|patch|apply)\b/i,
16
- /\bapply\s+(?:the\s+)?fix(?:es)?\s+directly\b/i,
17
- /\bmake\s+(?:the\s+)?code\s+changes\b/i,
18
- ];
19
-
20
- const EXPLICIT_NO_EDIT_PATTERNS = [
21
- /\bdo not edit\b/i,
22
- /\bdon't edit\b/i,
23
- /\bdo not modify\b/i,
24
- /\bdo not change files\b/i,
25
- ];
26
-
27
- const SCOPED_NO_EDIT_CONSTRAINT_PATTERNS = [
28
- /\bdo not edit files?\s+outside\b/i,
29
- /\bdo not edit\s+outside\b/i,
30
- /\bdo not edit\s+unrelated files?\b/i,
31
- /\bdo not change\s+unrelated files?\b/i,
32
- /\bdo not modify\s+unrelated files?\b/i,
33
- ];
34
-
35
- const NO_TOOL_INTENT_PATTERNS = [
36
- /\bno tools? needed\b/i,
37
- /\bno tools? required\b/i,
38
- /\bwithout using tools\b/i,
39
- /\bdo not use tools\b/i,
40
- /\bdon't use tools\b/i,
41
- ];
42
-
43
- const READ_ONLY_DELIVERABLE_PATTERNS = [
44
- /\b(?:draft|write|compose|prepare|produce)\s+(?:(?:a|an|the)\s+)?(?:github\s+)?(?:issue|bug report|issue draft|issue body|proposal|plan|report|summary|findings?|analysis|recommendations?)\b/i,
45
- /\b(?:issue|bug report)\s+(?:draft|body|template)\b/i,
46
- /\b(?:return|provide|produce)\s+(?:text|markdown|answer|findings?|recommendations?)\s+only\b/i,
47
- ];
48
-
49
- const RESEARCH_AGENT_PATTERNS = [
50
- /\binvestigate\b/i,
51
- /\bscout\b/i,
52
- /\bresearch(?:er)?\b/i,
53
- ];
54
-
55
- const FIX_OR_PATCH_IMPLEMENTATION_PATTERN = /\b(?:fix|patch)\s+(?:(?:it|this|that|them|each|any|all|these|those)\b|(?:(?:a|an|the|any|all)\s+)?(?:(?:failing|failed|broken|flaky|red|cold|start|current|existing|reported|approved|known|regression|unit|integration|e2e|source|typescript|type-?script|ts|type-?check|compiler)\s+)*(?:bug|defect|issues?|problems?|failures?|regressions?|tests?|errors?|items?|typos?|code|source|implementation|component|function|module|class|method|logic|file|files|readme|docs?|changelog|package\.json|config|manifest|extension|prompt|command|lint(?:ing)?|build|ci|type-?check|type\s+checking)\b)/i;
56
-
57
- const WORKER_IMPLEMENTATION_PATTERNS = [
58
- /\b(?:implement|edit|modify|refactor|delete)\b/i,
59
- FIX_OR_PATCH_IMPLEMENTATION_PATTERN,
60
- /\b(?:update|add|remove|replace|create)\b(?!\s+(?:(?:a|an|the)\s+)?(?:report|summary|findings?)(?:\b|$))/i,
61
- /\bapply\s+(?:the\s+)?(?:(?:suggested|proposed|recommended)\s+)?(?:changes?|fix(?:es)?|patch)\b/i,
62
- /\bmake\s+(?:the\s+)?changes\b/i,
63
- /\bdo those fixes\b/i,
64
- ];
65
-
66
- const GENERAL_IMPLEMENTATION_PATTERNS = [
67
- /\b(?:implement|edit|modify|refactor)\b/i,
68
- FIX_OR_PATCH_IMPLEMENTATION_PATTERN,
69
- /\bapply\s+(?:the\s+)?(?:(?:suggested|proposed|recommended)\s+)?(?:changes?|fix(?:es)?|patch)\b/i,
70
- /\bmake\s+(?:the\s+)?changes\b/i,
71
- /\bdo those fixes\b/i,
72
- /\b(?:update|add|remove|replace|delete|create)\s+(?:the\s+)?(?:file|files|code|source|implementation|test|tests|component|function|module|class|method|logic|import|imports|readme|docs?|changelog|package\.json|config|manifest|extension|prompt|command)\b/i,
73
- ];
5
+ export { expectsImplementationMutation };
74
6
 
75
7
  const READ_ONLY_BUILTIN_TOOLS = new Set([
76
8
  "read",
@@ -98,60 +30,9 @@ interface CompletionMutationGuardResult {
98
30
  triggered: boolean;
99
31
  }
100
32
 
101
- type TaskMutationIntent = { kind: "implementation" } | { kind: "read-only" } | { kind: "unknown" };
102
-
103
- type ToolMutationCapability = { kind: "mutation-capable" } | { kind: "read-only" };
104
-
105
- function stripFrameworkInstructions(task: string): string {
106
- return task
107
- .split("\n")
108
- .filter((line) => !/^\s*\[(?:Write to|Read from):/i.test(line))
109
- .filter((line) => !/^\s*(?:Create and maintain progress at:|Update progress at:|\*\*Output:\*\*|Write your findings to(?: exactly this path)?:|This path is authoritative for this run\.|Ignore any other output filename or output path mentioned elsewhere)/i.test(line))
110
- .join("\n");
111
- }
112
-
113
- function stripScopedNoEditConstraints(task: string): string {
114
- let stripped = task;
115
- for (const pattern of SCOPED_NO_EDIT_CONSTRAINT_PATTERNS) {
116
- stripped = stripped.replace(pattern, " ");
117
- }
118
- return stripped;
119
- }
120
-
121
- function taskHasExplicitReadOnlyIntent(taskText: string): boolean {
122
- return REVIEW_ONLY_PATTERNS.some((pattern) => pattern.test(taskText))
123
- || EXPLICIT_NO_EDIT_PATTERNS.some((pattern) => pattern.test(taskText))
124
- || NO_TOOL_INTENT_PATTERNS.some((pattern) => pattern.test(taskText));
125
- }
126
-
127
- function taskHasReadOnlyDeliverable(taskText: string): boolean {
128
- return READ_ONLY_DELIVERABLE_PATTERNS.some((pattern) => pattern.test(taskText));
129
- }
130
-
131
- function toolMutationCapability(tools: string[] | undefined, mcpDirectTools: string[] | undefined): ToolMutationCapability {
132
- if (tools === undefined || tools.length === 0 || (mcpDirectTools?.length ?? 0) > 0) return { kind: "mutation-capable" };
133
- return tools.every((tool) => READ_ONLY_BUILTIN_TOOLS.has(tool)) ? { kind: "read-only" } : { kind: "mutation-capable" };
134
- }
135
-
136
- function classifyTaskMutationIntent(agent: string, task: string): TaskMutationIntent {
137
- const taskText = stripFrameworkInstructions(task);
138
- const taskTextWithoutScopedConstraints = stripScopedNoEditConstraints(taskText);
139
- if (taskHasExplicitReadOnlyIntent(taskTextWithoutScopedConstraints)) return { kind: "read-only" };
140
-
141
- if (RESEARCH_AGENT_PATTERNS.some((pattern) => pattern.test(agent))) return { kind: "read-only" };
142
- if (/\breviewer\b/i.test(agent)) {
143
- return REVIEWER_REQUIRED_EDIT_PATTERNS.some((pattern) => pattern.test(taskText)) ? { kind: "implementation" } : { kind: "read-only" };
144
- }
145
-
146
- const workerIntent = agent === "worker" && WORKER_IMPLEMENTATION_PATTERNS.some((pattern) => pattern.test(taskText));
147
- if (workerIntent) return { kind: "implementation" };
148
-
149
- if (GENERAL_IMPLEMENTATION_PATTERNS.some((pattern) => pattern.test(taskText))) return { kind: "implementation" };
150
- return taskHasReadOnlyDeliverable(taskTextWithoutScopedConstraints) ? { kind: "read-only" } : { kind: "unknown" };
151
- }
152
-
153
- export function expectsImplementationMutation(agent: string, task: string): boolean {
154
- return classifyTaskMutationIntent(agent, task).kind === "implementation";
33
+ export function hasMutationToolCapability(tools: string[] | undefined, mcpDirectTools: string[] | undefined): boolean {
34
+ if (tools === undefined || tools.length === 0 || (mcpDirectTools?.length ?? 0) > 0) return true;
35
+ return !tools.every((tool) => READ_ONLY_BUILTIN_TOOLS.has(tool));
155
36
  }
156
37
 
157
38
  export function hasMutationToolCall(messages: Message[]): boolean {
@@ -171,9 +52,9 @@ export function hasMutationToolCall(messages: Message[]): boolean {
171
52
  }
172
53
 
173
54
  export function evaluateCompletionMutationGuard(input: CompletionMutationGuardInput): CompletionMutationGuardResult {
174
- const expectedMutation = toolMutationCapability(input.tools, input.mcpDirectTools).kind === "read-only"
175
- ? false
176
- : expectsImplementationMutation(input.agent, input.task);
55
+ const expectedMutation = hasMutationToolCapability(input.tools, input.mcpDirectTools)
56
+ ? expectsImplementationMutation(input.agent, input.task)
57
+ : false;
177
58
  const attemptedMutation = hasMutationToolCall(input.messages);
178
59
  return {
179
60
  expectedMutation,
@@ -27,6 +27,7 @@ export interface DynamicCollectedResult {
27
27
  structured?: unknown;
28
28
  error?: string;
29
29
  timedOut?: boolean;
30
+ stopped?: boolean;
30
31
  outputPath?: string;
31
32
  artifactPaths?: ArtifactPaths;
32
33
  }
@@ -42,15 +43,15 @@ const ITEM_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
42
43
  const ITEM_REF_PATTERN = /\{([A-Za-z_][A-Za-z0-9_]*)(?:\.([^{}]+))?\}/g;
43
44
  const RESERVED_TEMPLATE_NAMES = new Set(["task", "previous", "chain_dir", "outputs"]);
44
45
  const DYNAMIC_STEP_KEYS = new Set(["expand", "parallel", "collect", "concurrency", "failFast", "phase", "label", "acceptance"]);
45
- const RUNNER_DYNAMIC_STEP_KEYS = new Set([...DYNAMIC_STEP_KEYS, "effectiveAcceptance", "sessionFiles", "thinkingOverrides"]);
46
+ const RUNNER_DYNAMIC_STEP_KEYS = new Set([...DYNAMIC_STEP_KEYS, "effectiveAcceptance", "acceptanceInput", "acceptanceRole", "sessionFiles", "thinkingOverrides"]);
46
47
  const DYNAMIC_EXPAND_KEYS = new Set(["from", "item", "key", "maxItems", "onEmpty"]);
47
48
  const DYNAMIC_EXPAND_FROM_KEYS = new Set(["output", "path"]);
48
49
  const DYNAMIC_PARALLEL_KEYS = new Set(["agent", "task", "phase", "label", "outputSchema", "cwd", "output", "outputMode", "reads", "progress", "skill", "model", "toolBudget", "acceptance"]);
49
50
  const RUNNER_DYNAMIC_PARALLEL_KEYS = new Set([
50
51
  ...DYNAMIC_PARALLEL_KEYS,
51
- "outputName", "structured", "inheritProjectContext", "inheritSkills", "skills", "outputPath", "maxSubagentDepth",
52
+ "outputName", "structured", "inheritProjectContext", "inheritSkills", "skills", "outputPath", "namespaceOutputPath", "maxSubagentDepth", "waitToolEnabled",
52
53
  "structuredOutput", "structuredOutputSchema", "tools", "extensions", "subagentOnlyExtensions", "mcpDirectTools", "completionGuard", "systemPrompt",
53
- "systemPromptMode", "thinking", "modelCandidates", "sessionFile", "effectiveAcceptance", "parentSessionId",
54
+ "systemPromptMode", "thinking", "modelCandidates", "sessionFile", "effectiveAcceptance", "acceptanceInput", "acceptanceRole", "parentSessionId",
54
55
  ]);
55
56
  const DYNAMIC_COLLECT_KEYS = new Set(["as", "outputSchema"]);
56
57
 
@@ -263,7 +264,7 @@ export function materializeDynamicParallelStep(step: DynamicParallelStep, output
263
264
  export function collectDynamicResults(
264
265
  step: DynamicParallelStep,
265
266
  items: DynamicMaterializedItem[],
266
- results: Array<Pick<SingleResult, "agent" | "exitCode" | "error" | "timedOut" | "structuredOutput" | "artifactPaths" | "savedOutputPath"> & { output?: string; finalOutput?: string }>,
267
+ results: Array<Pick<SingleResult, "agent" | "exitCode" | "error" | "timedOut" | "stopped" | "structuredOutput" | "artifactPaths" | "savedOutputPath"> & { output?: string; finalOutput?: string }>,
267
268
  ): DynamicCollectedResult[] {
268
269
  return items.map((entry, index) => {
269
270
  const result = results[index];
@@ -280,6 +281,7 @@ export function collectDynamicResults(
280
281
  ...(result?.structuredOutput !== undefined ? { structured: result.structuredOutput } : {}),
281
282
  ...(result?.error ? { error: result.error } : {}),
282
283
  ...(result?.timedOut ? { timedOut: true } : {}),
284
+ ...(result?.stopped ? { stopped: true } : {}),
283
285
  ...(result?.savedOutputPath ? { outputPath: result.savedOutputPath } : {}),
284
286
  ...(result?.artifactPaths ? { artifactPaths: result.artifactPaths } : {}),
285
287
  };
@@ -211,6 +211,31 @@ export function resolveSubagentModelOverride(
211
211
  return resolved;
212
212
  }
213
213
 
214
+ export function resolveEffectiveSubagentModel(
215
+ explicitModel: string | boolean | undefined,
216
+ agentModel: string | boolean | undefined,
217
+ parentModel: ParentModel | undefined,
218
+ availableModels: AvailableModelInfo[] | undefined,
219
+ preferredProvider?: string,
220
+ options?: Omit<ResolveSubagentModelOverrideOptions, "source">,
221
+ ): string | undefined {
222
+ const resolved = resolveSubagentModelOverride(
223
+ explicitModel ?? agentModel,
224
+ parentModel,
225
+ availableModels,
226
+ preferredProvider,
227
+ { ...options, source: explicitModel !== undefined ? "explicit" : "inherited" },
228
+ );
229
+ if (resolved || explicitModel === undefined) return resolved;
230
+ return resolveSubagentModelOverride(
231
+ agentModel,
232
+ parentModel,
233
+ availableModels,
234
+ preferredProvider,
235
+ { ...options, source: "inherited" },
236
+ );
237
+ }
238
+
214
239
  export interface BuildModelCandidatesOptions {
215
240
  /** Fallback models are inherited agent config and warn, rather than error, when out of scope. */
216
241
  scope?: ModelScopeConfig;
@@ -267,6 +292,7 @@ const RETRYABLE_MODEL_FAILURE_PATTERNS = [
267
292
  /fetch failed/i,
268
293
  /network error/i,
269
294
  /socket hang up/i,
295
+ /stream ended without finish_reason/i,
270
296
  /upstream/i,
271
297
  /timed? out/i,
272
298
  /timeout/i,
@@ -279,8 +305,18 @@ const RETRYABLE_MODEL_FAILURE_PATTERNS = [
279
305
  /model.*(?:load|fail|error)/i,
280
306
  ];
281
307
 
308
+ /**
309
+ * Failures reported as `<tool> failed (exit N): ...` or `<tool> failed with
310
+ * exit code N` come from a tool call inside the child's task, not from the
311
+ * provider/model, however network-flavored their details read. Retrying a
312
+ * different model cannot fix them and would rerun the whole task. Tool names
313
+ * include namespaced forms like `mcp.server/write`.
314
+ */
315
+ const TOOL_FAILURE_PREFIX = /^[\w.:@/-]+ failed (?:(?:\(exit \d+\):)|(?:with exit code \d+))(?:\s|$)/i;
316
+
282
317
  export function isRetryableModelFailure(error: string | undefined): boolean {
283
318
  if (!error) return false;
319
+ if (TOOL_FAILURE_PREFIX.test(error.trim())) return false;
284
320
  return RETRYABLE_MODEL_FAILURE_PATTERNS.some((pattern) => pattern.test(error));
285
321
  }
286
322
 
@@ -202,7 +202,7 @@ function sanitizeTurnBudget(value: unknown): TurnBudgetState | undefined {
202
202
  const maxTurns = clampNumber(raw.maxTurns);
203
203
  const graceTurns = clampNumber(raw.graceTurns);
204
204
  const turnCount = clampNumber(raw.turnCount);
205
- const outcome = raw.outcome === "within-budget" || raw.outcome === "wrap-up-requested" || raw.outcome === "exceeded" ? raw.outcome : undefined;
205
+ const outcome = raw.outcome === "within-budget" || raw.outcome === "wrap-up-requested" || raw.outcome === "termination-deferred" || raw.outcome === "exceeded" ? raw.outcome : undefined;
206
206
  if (maxTurns === undefined || graceTurns === undefined || turnCount === undefined || !outcome) return undefined;
207
207
  return {
208
208
  maxTurns,
@@ -210,12 +210,13 @@ function sanitizeTurnBudget(value: unknown): TurnBudgetState | undefined {
210
210
  turnCount,
211
211
  outcome,
212
212
  ...(clampNumber(raw.wrapUpRequestedAtTurn) !== undefined ? { wrapUpRequestedAtTurn: clampNumber(raw.wrapUpRequestedAtTurn) } : {}),
213
+ ...(clampNumber(raw.terminationDeferredAtTurn) !== undefined ? { terminationDeferredAtTurn: clampNumber(raw.terminationDeferredAtTurn) } : {}),
213
214
  ...(clampNumber(raw.exceededAtTurn) !== undefined ? { exceededAtTurn: clampNumber(raw.exceededAtTurn) } : {}),
214
215
  };
215
216
  }
216
217
 
217
218
  function sanitizeState(value: unknown, fallback: NestedRunState): NestedRunState {
218
- return value === "queued" || value === "running" || value === "complete" || value === "failed" || value === "paused"
219
+ return value === "queued" || value === "running" || value === "complete" || value === "failed" || value === "paused" || value === "stopped"
219
220
  ? value
220
221
  : fallback;
221
222
  }
@@ -225,7 +226,7 @@ function sanitizeStep(input: unknown, depth: number): NestedStepSummary | undefi
225
226
  const raw = input as Record<string, unknown>;
226
227
  const agent = stringValue(raw.agent, 128);
227
228
  if (!agent) return undefined;
228
- const status = raw.status === "pending" || raw.status === "running" || raw.status === "complete" || raw.status === "completed" || raw.status === "failed" || raw.status === "paused"
229
+ const status = raw.status === "pending" || raw.status === "running" || raw.status === "complete" || raw.status === "completed" || raw.status === "failed" || raw.status === "paused" || raw.status === "stopped"
229
230
  ? raw.status
230
231
  : "pending";
231
232
  return {
@@ -243,6 +244,7 @@ function sanitizeStep(input: unknown, depth: number): NestedStepSummary | undefi
243
244
  ...(clampNumber(raw.endedAt) !== undefined ? { endedAt: clampNumber(raw.endedAt) } : {}),
244
245
  ...(stringValue(raw.error, 1024) ? { error: stringValue(raw.error, 1024) } : {}),
245
246
  ...(raw.timedOut === true ? { timedOut: true } : {}),
247
+ ...(raw.stopped === true ? { stopped: true } : {}),
246
248
  ...(sanitizeTurnBudget(raw.turnBudget) ? { turnBudget: sanitizeTurnBudget(raw.turnBudget) } : {}),
247
249
  ...(raw.turnBudgetExceeded === true ? { turnBudgetExceeded: true } : {}),
248
250
  ...(raw.wrapUpRequested === true ? { wrapUpRequested: true } : {}),
@@ -298,6 +300,7 @@ export function sanitizeSummary(input: unknown, depth = 0): NestedRunSummary | u
298
300
  ...(clampNumber(raw.timeoutMs) !== undefined ? { timeoutMs: clampNumber(raw.timeoutMs) } : {}),
299
301
  ...(clampNumber(raw.deadlineAt) !== undefined ? { deadlineAt: clampNumber(raw.deadlineAt) } : {}),
300
302
  ...(raw.timedOut === true ? { timedOut: true } : {}),
303
+ ...(raw.stopped === true ? { stopped: true } : {}),
301
304
  ...(sanitizeTurnBudget(raw.turnBudget) ? { turnBudget: sanitizeTurnBudget(raw.turnBudget) } : {}),
302
305
  ...(raw.turnBudgetExceeded === true ? { turnBudgetExceeded: true } : {}),
303
306
  ...(raw.wrapUpRequested === true ? { wrapUpRequested: true } : {}),
@@ -353,7 +356,7 @@ export function parseNestedEventRecords(content: string, route: NestedRoute): Ne
353
356
  }
354
357
 
355
358
  function terminal(state: NestedRunState): boolean {
356
- return state === "complete" || state === "failed" || state === "paused";
359
+ return state === "complete" || state === "failed" || state === "paused" || state === "stopped";
357
360
  }
358
361
 
359
362
  function mergeSummary(existing: NestedRunSummary | undefined, event: NestedEventRecord): NestedRunSummary {
@@ -859,6 +862,7 @@ export function nestedSummaryFromAsyncStatus(status: AsyncStatus, asyncDir: stri
859
862
  ...(status.timeoutMs !== undefined ? { timeoutMs: status.timeoutMs } : {}),
860
863
  ...(status.deadlineAt !== undefined ? { deadlineAt: status.deadlineAt } : {}),
861
864
  ...(status.timedOut !== undefined ? { timedOut: status.timedOut } : {}),
865
+ ...(status.stopped !== undefined ? { stopped: status.stopped } : {}),
862
866
  ...(status.turnBudget ? { turnBudget: status.turnBudget } : {}),
863
867
  ...(status.turnBudgetExceeded !== undefined ? { turnBudgetExceeded: status.turnBudgetExceeded } : {}),
864
868
  ...(status.wrapUpRequested !== undefined ? { wrapUpRequested: status.wrapUpRequested } : {}),
@@ -882,6 +886,7 @@ export function nestedSummaryFromAsyncStatus(status: AsyncStatus, asyncDir: stri
882
886
  ...(step.endedAt !== undefined ? { endedAt: step.endedAt } : {}),
883
887
  ...(step.error ? { error: step.error } : {}),
884
888
  ...(step.timedOut !== undefined ? { timedOut: step.timedOut } : {}),
889
+ ...(step.stopped !== undefined ? { stopped: step.stopped } : {}),
885
890
  ...(step.turnBudget ? { turnBudget: step.turnBudget } : {}),
886
891
  ...(step.turnBudgetExceeded !== undefined ? { turnBudgetExceeded: step.turnBudgetExceeded } : {}),
887
892
  ...(step.wrapUpRequested !== undefined ? { wrapUpRequested: step.wrapUpRequested } : {}),
@@ -8,11 +8,12 @@ export interface NestedRunCounts {
8
8
  paused: number;
9
9
  complete: number;
10
10
  failed: number;
11
+ stopped: number;
11
12
  queued: number;
12
13
  }
13
14
 
14
15
  export function countNestedRuns(children: NestedRunSummary[] | undefined): NestedRunCounts {
15
- const counts: NestedRunCounts = { total: 0, running: 0, paused: 0, complete: 0, failed: 0, queued: 0 };
16
+ const counts: NestedRunCounts = { total: 0, running: 0, paused: 0, complete: 0, failed: 0, stopped: 0, queued: 0 };
16
17
  for (const child of children ?? []) {
17
18
  counts.total++;
18
19
  counts[child.state]++;
@@ -22,6 +23,7 @@ export function countNestedRuns(children: NestedRunSummary[] | undefined): Neste
22
23
  counts.paused += nested.paused;
23
24
  counts.complete += nested.complete;
24
25
  counts.failed += nested.failed;
26
+ counts.stopped += nested.stopped;
25
27
  counts.queued += nested.queued;
26
28
  }
27
29
  return counts;
@@ -34,6 +36,7 @@ export function formatNestedAggregate(children: NestedRunSummary[] | undefined):
34
36
  counts.running > 0 ? `${counts.running} running` : "",
35
37
  counts.paused > 0 ? `${counts.paused} paused` : "",
36
38
  counts.failed > 0 ? `${counts.failed} failed` : "",
39
+ counts.stopped > 0 ? `${counts.stopped} stopped` : "",
37
40
  counts.complete > 0 ? `${counts.complete} complete` : "",
38
41
  counts.queued > 0 ? `${counts.queued} queued` : "",
39
42
  ].filter(Boolean);
@@ -28,9 +28,12 @@ export interface RunnerSubagentStep {
28
28
  inheritSkills: boolean;
29
29
  skills?: string[];
30
30
  outputPath?: string;
31
+ /** Defer the authoritative output instruction until a dynamic fanout item is materialized. */
32
+ namespaceOutputPath?: boolean;
31
33
  outputMode?: "inline" | "file-only";
32
34
  sessionFile?: string;
33
35
  maxSubagentDepth?: number;
36
+ waitToolEnabled?: boolean;
34
37
  structuredOutput?: {
35
38
  schema: import("../../shared/types.ts").JsonSchemaObject;
36
39
  schemaPath: string;
@@ -38,6 +41,8 @@ export interface RunnerSubagentStep {
38
41
  };
39
42
  structuredOutputSchema?: import("../../shared/types.ts").JsonSchemaObject;
40
43
  effectiveAcceptance?: import("../../shared/types.ts").ResolvedAcceptanceConfig;
44
+ acceptanceInput?: import("../../shared/types.ts").AcceptanceInput;
45
+ acceptanceRole?: import("../../shared/types.ts").AcceptanceRole;
41
46
  toolBudget?: import("../../shared/types.ts").ResolvedToolBudget;
42
47
  }
43
48
 
@@ -59,6 +64,8 @@ export interface DynamicRunnerGroup {
59
64
  sessionFiles?: (string | undefined)[];
60
65
  thinkingOverrides?: (string | undefined)[];
61
66
  effectiveAcceptance?: import("../../shared/types.ts").ResolvedAcceptanceConfig;
67
+ acceptanceInput?: import("../../shared/types.ts").AcceptanceInput;
68
+ acceptanceRole?: import("../../shared/types.ts").AcceptanceRole;
62
69
  }
63
70
 
64
71
  export type RunnerStep = RunnerSubagentStep | ParallelStepGroup | DynamicRunnerGroup;
@@ -6,9 +6,12 @@ import { encodeNestedPathEnv, parseNestedPathEnv, type NestedPathEntry } from ".
6
6
  import { resolveMcpDirectToolNames } from "./mcp-direct-tool-allowlist.ts";
7
7
  import { STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV } from "./structured-output.ts";
8
8
  import { TEMP_ROOT_DIR, type JsonSchemaObject, type ResolvedToolBudget } from "../../shared/types.ts";
9
+ import { THINKING_LEVELS } from "../../shared/model-info.ts";
9
10
  import { TOOL_BUDGET_ENV, encodeToolBudgetEnv } from "./tool-budget.ts";
11
+ import { CHILD_TOOL_DIAGNOSTIC_PATH_ENV, REQUIRED_CHILD_TOOLS_ENV } from "./tool-availability.ts";
12
+ import { CHILD_WATCHDOG_CONFIG_ENV, encodeChildWatchdogConfig, type ChildWatchdogConfig } from "../../watchdog/child-status.ts";
13
+ import { WAIT_TOOL_ENABLED_ENV } from "../background/wait-config.ts";
10
14
 
11
- const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
12
15
  const TASK_ARG_LIMIT = 8000;
13
16
  const PROMPT_RUNTIME_EXTENSION_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "subagent-prompt-runtime.ts");
14
17
  const FANOUT_CHILD_EXTENSION_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "extension", "fanout-child.ts");
@@ -30,6 +33,8 @@ export const SUBAGENT_PARENT_PATH_ENV = "PI_SUBAGENT_PARENT_PATH";
30
33
  export const SUBAGENT_PARENT_CAPABILITY_TOKEN_ENV = "PI_SUBAGENT_PARENT_CAPABILITY_TOKEN";
31
34
  export const SUBAGENT_PARENT_SESSION_ENV = "PI_SUBAGENT_PARENT_SESSION";
32
35
  export const SUBAGENT_STEER_INBOX_ENV = "PI_SUBAGENT_STEER_INBOX";
36
+ export const SUBAGENT_STEER_CAPABILITY_ENV = "PI_SUBAGENT_STEER_CAPABILITY";
37
+ export const SUBAGENT_STEER_ACK_DIR_ENV = "PI_SUBAGENT_STEER_ACK_DIR";
33
38
 
34
39
  interface BuildPiArgsInput {
35
40
  parentSessionId?: string;
@@ -65,18 +70,23 @@ interface BuildPiArgsInput {
65
70
  parentPath?: NestedPathEntry[];
66
71
  parentCapabilityToken?: string;
67
72
  steerInboxDir?: string;
73
+ steerCapabilityPath?: string;
74
+ steerAckDir?: string;
68
75
  structuredOutput?: {
69
76
  schema: JsonSchemaObject;
70
77
  schemaPath: string;
71
78
  outputPath: string;
72
79
  };
73
80
  toolBudget?: ResolvedToolBudget;
81
+ childWatchdog?: ChildWatchdogConfig;
82
+ waitToolEnabled?: boolean;
74
83
  }
75
84
 
76
85
  interface BuildPiArgsResult {
77
86
  args: string[];
78
87
  env: Record<string, string | undefined>;
79
88
  tempDir?: string;
89
+ toolDiagnosticPath?: string;
80
90
  }
81
91
 
82
92
  function sanitizeSupervisorChannelSegment(value: string): string {
@@ -90,7 +100,7 @@ function supervisorChannelDir(runId: string, agent: string, childIndex: number):
90
100
  export function applyThinkingSuffix(model: string | undefined, thinking: string | false | undefined, replaceExisting = false): string | undefined {
91
101
  if (!model || !thinking) return model;
92
102
  const colonIdx = model.lastIndexOf(":");
93
- if (colonIdx !== -1 && THINKING_LEVELS.includes(model.substring(colonIdx + 1))) {
103
+ if (colonIdx !== -1 && THINKING_LEVELS.some((level) => level === model.substring(colonIdx + 1))) {
94
104
  return replaceExisting ? `${model.slice(0, colonIdx)}:${thinking}` : model;
95
105
  }
96
106
  return `${model}:${thinking}`;
@@ -123,18 +133,21 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
123
133
  : declaredBuiltinToolsBase;
124
134
  const fanoutAuthorized = declaredBuiltinTools.includes("subagent");
125
135
  const toolExtensionPaths: string[] = [];
136
+ let requiredChildTools: string[] = [];
126
137
  if (input.tools?.length) {
127
- const builtinTools = [...declaredBuiltinTools];
138
+ const allowedTools = [...declaredBuiltinTools];
128
139
  for (const tool of input.tools) {
129
140
  if (!declaredBuiltinTools.includes(tool) && (tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js"))) {
130
141
  toolExtensionPaths.push(tool);
131
142
  }
132
143
  }
133
- if (builtinTools.length > 0) {
144
+ if (allowedTools.length > 0) {
134
145
  if (input.mcpDirectTools?.length) {
135
- builtinTools.push(...resolveMcpDirectToolNames(input.mcpDirectTools, input.cwd));
146
+ allowedTools.push(...resolveMcpDirectToolNames(input.mcpDirectTools, input.cwd));
136
147
  }
137
- args.push("--tools", builtinTools.join(","));
148
+ if (input.structuredOutput) allowedTools.push("structured_output");
149
+ requiredChildTools = [...new Set(allowedTools)];
150
+ args.push("--tools", requiredChildTools.join(","));
138
151
  }
139
152
  }
140
153
 
@@ -177,8 +190,18 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
177
190
  }
178
191
 
179
192
  const env: Record<string, string | undefined> = {};
193
+ let toolDiagnosticPath: string | undefined;
194
+ if (requiredChildTools.length > 0) {
195
+ if (!tempDir) tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-"));
196
+ toolDiagnosticPath = path.join(tempDir, "tool-diagnostic.json");
197
+ env[REQUIRED_CHILD_TOOLS_ENV] = JSON.stringify(requiredChildTools);
198
+ env[CHILD_TOOL_DIAGNOSTIC_PATH_ENV] = toolDiagnosticPath;
199
+ }
180
200
  env[SUBAGENT_CHILD_ENV] = "1";
181
201
  env[SUBAGENT_FANOUT_CHILD_ENV] = fanoutAuthorized ? "1" : "0";
202
+ if (input.waitToolEnabled !== undefined) {
203
+ env[WAIT_TOOL_ENABLED_ENV] = input.waitToolEnabled ? "true" : "false";
204
+ }
182
205
  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]);
183
206
  const parentRunId = input.parentRunId ?? input.runId ?? (inheritedNestedRoute ? process.env[SUBAGENT_RUN_ID_ENV] : undefined) ?? process.env[SUBAGENT_PARENT_RUN_ID_ENV] ?? "";
184
207
  const parentChildIndex = input.parentChildIndex !== undefined
@@ -251,12 +274,16 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
251
274
  if (input.steerInboxDir) {
252
275
  env[SUBAGENT_STEER_INBOX_ENV] = input.steerInboxDir;
253
276
  }
277
+ if (input.steerCapabilityPath) env[SUBAGENT_STEER_CAPABILITY_ENV] = input.steerCapabilityPath;
278
+ if (input.steerAckDir) env[SUBAGENT_STEER_ACK_DIR_ENV] = input.steerAckDir;
254
279
  const encodedToolBudget = encodeToolBudgetEnv(input.toolBudget);
255
280
  if (encodedToolBudget) env[TOOL_BUDGET_ENV] = encodedToolBudget;
281
+ const encodedChildWatchdog = encodeChildWatchdogConfig(input.childWatchdog);
282
+ if (encodedChildWatchdog) env[CHILD_WATCHDOG_CONFIG_ENV] = encodedChildWatchdog;
256
283
 
257
284
  env[SUBAGENT_PARENT_SESSION_ENV] = input.parentSessionId ?? process.env[SUBAGENT_PARENT_SESSION_ENV] ?? "";
258
285
 
259
- return { args, env, tempDir };
286
+ return { args, env, tempDir, toolDiagnosticPath };
260
287
  }
261
288
 
262
289
  export const parseParentPathEnv = parseNestedPathEnv;