pi-subagents 0.47.0 → 0.48.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 (57) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +2 -0
  3. package/agents/reviewer.md +3 -4
  4. package/docs/configuration.md +46 -2
  5. package/docs/observability.md +1 -1
  6. package/docs/tool-reference.md +1 -1
  7. package/package.json +1 -1
  8. package/src/agents/agents.ts +9 -3
  9. package/src/extension/config.ts +16 -0
  10. package/src/extension/doctor.ts +40 -0
  11. package/src/extension/index.ts +30 -11
  12. package/src/extension/public-execution.ts +5 -0
  13. package/src/extension/rpc.ts +7 -9
  14. package/src/extension/schemas.ts +4 -4
  15. package/src/intercom/intercom-bridge.ts +4 -1
  16. package/src/intercom/native-supervisor-channel.ts +102 -4
  17. package/src/missions/lifecycle.ts +4 -7
  18. package/src/missions/store.ts +4 -4
  19. package/src/missions/workflow-state.ts +6 -2
  20. package/src/runs/background/active-async-capacity.ts +374 -0
  21. package/src/runs/background/active-run-index.ts +46 -0
  22. package/src/runs/background/async-execution.ts +117 -29
  23. package/src/runs/background/async-job-tracker.ts +279 -134
  24. package/src/runs/background/async-resume.ts +7 -1
  25. package/src/runs/background/async-status.ts +33 -4
  26. package/src/runs/background/chain-append.ts +33 -15
  27. package/src/runs/background/control-channel.ts +55 -17
  28. package/src/runs/background/owned-process-tree.ts +104 -0
  29. package/src/runs/background/process-terminal.ts +17 -3
  30. package/src/runs/background/result-watcher.ts +87 -6
  31. package/src/runs/background/run-status.ts +23 -1
  32. package/src/runs/background/scheduled-runs.ts +23 -0
  33. package/src/runs/background/stale-run-reconciler.ts +10 -3
  34. package/src/runs/background/subagent-runner.ts +75 -33
  35. package/src/runs/foreground/async-dismiss-action.ts +85 -0
  36. package/src/runs/foreground/async-steering-action.ts +6 -7
  37. package/src/runs/foreground/chain-execution.ts +37 -2
  38. package/src/runs/foreground/execution.ts +90 -19
  39. package/src/runs/foreground/foreground-control.ts +12 -0
  40. package/src/runs/foreground/prompt-audit.ts +171 -0
  41. package/src/runs/foreground/subagent-executor.ts +648 -195
  42. package/src/runs/shared/acceptance.ts +13 -4
  43. package/src/runs/shared/llm-intent-arbiter.ts +286 -0
  44. package/src/runs/shared/parallel-utils.ts +2 -0
  45. package/src/runs/shared/pi-args.ts +44 -1
  46. package/src/runs/shared/run-fanout-budget.ts +280 -0
  47. package/src/runs/shared/single-output.ts +4 -2
  48. package/src/runs/shared/subagent-prompt-runtime.ts +24 -5
  49. package/src/runs/shared/task-intent.ts +19 -3
  50. package/src/runs/shared/worktree.ts +17 -5
  51. package/src/shared/artifacts.ts +1 -1
  52. package/src/shared/file-coalescer.ts +9 -0
  53. package/src/shared/types.ts +97 -1
  54. package/src/shared/utils.ts +3 -1
  55. package/src/tui/fleet-status.ts +7 -5
  56. package/src/tui/fleet.ts +225 -12
  57. package/src/workflows/scripted-workflow.ts +26 -6
@@ -23,7 +23,7 @@ import type {
23
23
  SubagentRunMode,
24
24
  } from "../../shared/types.ts";
25
25
  import { isAgentContractV1 } from "./agent-contract.ts";
26
- import { classifyTaskMutationIntent, taskMayMutate } from "./task-intent.ts";
26
+ import { classifyTaskMutationIntent, stripSeverityCompounds, taskMayMutate } from "./task-intent.ts";
27
27
 
28
28
  const LEVEL_RANK: Record<Exclude<AcceptanceLevel, "auto">, number> = {
29
29
  none: 0,
@@ -93,7 +93,7 @@ function inferLevel(input: {
93
93
  const rolePatchTask = input.acceptanceRole !== undefined
94
94
  && intent.kind !== "read-only"
95
95
  && !/\b(?:do not|don't|must not)\s+patch\b/.test(task)
96
- && /\bpatch\s+(?:(?:\.{0,2}[\\/])?(?:[\w.-]+[\\/])+[\w.-]+|[\w.-]+\.[a-z0-9]+\b|(?:the\s+)?parser\b)/.test(task);
96
+ && /\bpatch\s+(?:(?:\.{0,2}[\\/])?(?:[\w.-]+[\\/])+[\w.-]+|[\w.-]+\.[a-z0-9]+\b|(?:the\s+)?parser\b)/.test(stripSeverityCompounds(task));
97
97
  const taskMayWrite = readOnlyTask ? false : taskMayMutate(input.task ?? "") || intent.kind === "implementation" || rolePatchTask;
98
98
  const readOnlyAgent = input.acceptanceRole === "read-only"
99
99
  || (input.acceptanceRole === undefined && /\b(?:reviewer|oracle|scout|researcher|analyst)\b/.test(agent));
@@ -433,6 +433,7 @@ export function formatAcceptancePrompt(acceptance: ResolvedAcceptanceConfig, opt
433
433
  "",
434
434
  "Finish with a fenced JSON block tagged `acceptance-report` in this shape:",
435
435
  "Use empty arrays when no items apply; array fields contain strings unless object entries are shown.",
436
+ "Empty-string entries (`[\"\"]`) are ignored; use `[]` when nothing applies.",
436
437
  "`criteriaSatisfied[].status` must be exactly one of: satisfied, not-satisfied, not-applicable.",
437
438
  "`commandsRun[].result` must be exactly one of: passed, failed, not-run.",
438
439
  "`manualNotes` and `notes` are optional strings; an empty string means no note and does not satisfy `manual-notes` evidence.",
@@ -612,9 +613,17 @@ function normalizeAcceptanceReportValue(value: unknown, pathLabel = ""): { value
612
613
  case "testsAddedOrUpdated":
613
614
  case "validationOutput":
614
615
  case "residualRisks":
615
- case "reviewFindings":
616
- normalized[canonical] = typeof fieldValue === "string" ? [fieldValue] : fieldValue;
616
+ case "reviewFindings": {
617
+ // Tolerate empty-string entries ("[\"\"]"): models write them for "no items"
618
+ // and a single empty entry must not reject the whole report. Drop them at
619
+ // parse time; non-string entries are kept so validateStringArrayField still
620
+ // flags structural garbage.
621
+ const items = typeof fieldValue === "string" ? [fieldValue] : fieldValue;
622
+ normalized[canonical] = Array.isArray(items)
623
+ ? items.filter((item) => typeof item !== "string" || item.trim().length > 0)
624
+ : items;
617
625
  break;
626
+ }
618
627
  case "noStagedFiles": {
619
628
  const token = typeof fieldValue === "string" ? fieldValue.trim().toLowerCase() : undefined;
620
629
  normalized[canonical] = token === "true" ? true : token === "false" ? false : fieldValue;
@@ -0,0 +1,286 @@
1
+ import { createHash } from "node:crypto";
2
+ import { Agent, type AgentTool, type StreamFn } from "@earendil-works/pi-agent-core";
3
+ import { convertToLlm, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { streamSimple } from "@earendil-works/pi-ai/compat";
5
+ import { Type, type Static } from "typebox";
6
+
7
+ /**
8
+ * LLM intent arbiter for the completion mutation guard.
9
+ *
10
+ * The regex classifier (task-intent.ts) is deliberately narrow, so exotic
11
+ * review wording can still look like an implementation task ("to fix this,
12
+ * compare the outputs", verbs inside URLs or quoted text). When the guard is
13
+ * about to hard-fail a run that made no edits, this arbiter asks a model
14
+ * whether the task actually instructed file changes. It can only downgrade a
15
+ * failure to a pass; every error, timeout, or non-read-only verdict keeps the
16
+ * guard's original behavior.
17
+ *
18
+ * Enabled by default; set PI_SUBAGENTS_LLM_INTENT_ARBITER=0 to disable.
19
+ */
20
+
21
+ const COMPLETION_GUARD_ERROR_PREFIX =
22
+ "Subagent completed without making edits for an implementation task.";
23
+
24
+ export type TaskMutationVerdict = "read-only" | "implementation" | "unavailable";
25
+
26
+ export type TaskMutationArbiter = (task: string) => Promise<TaskMutationVerdict>;
27
+
28
+ const DecisionParams = Type.Object(
29
+ {
30
+ classification: Type.String({ enum: ["read_only", "implementation"] }),
31
+ reason: Type.String({ description: "One concise reason for this classification." }),
32
+ },
33
+ { additionalProperties: false },
34
+ );
35
+
36
+ type DecisionParams = Static<typeof DecisionParams>;
37
+
38
+ interface ArbiterRuntime {
39
+ model: NonNullable<RegistryModel>;
40
+ baseStreamFn: StreamFn;
41
+ timeoutMs: number;
42
+ }
43
+
44
+ interface ArbiterAuth {
45
+ apiKey?: string;
46
+ headers?: Record<string, string>;
47
+ env?: Record<string, string>;
48
+ }
49
+
50
+ export interface TaskMutationArbiterOptions {
51
+ /** Explicit "provider/id" model override (tests, config). */
52
+ model?: string;
53
+ /** Injectable stream function (tests). */
54
+ streamFn?: StreamFn;
55
+ timeoutMs?: number;
56
+ }
57
+
58
+ const DEFAULT_ARBITER_TIMEOUT_MS = 10_000;
59
+
60
+ type RegistryModel = ReturnType<NonNullable<ExtensionContext["modelRegistry"]["find"]>>;
61
+
62
+ function resolveArbiterModel(
63
+ ctx: ExtensionContext,
64
+ options?: TaskMutationArbiterOptions,
65
+ ): NonNullable<RegistryModel> | null {
66
+ const registry = ctx.modelRegistry as {
67
+ find?: (provider: string, modelId: string) => RegistryModel | undefined;
68
+ getAvailable?: () => Array<{ provider?: string; id?: string }>;
69
+ };
70
+ const explicit = options?.model?.trim();
71
+ if (explicit) {
72
+ const [provider, id] = explicit.split("/");
73
+ if (provider && id) return registry.find?.(provider, id) ?? null;
74
+ return null;
75
+ }
76
+ if (ctx.model) return ctx.model as NonNullable<RegistryModel>;
77
+ const first = registry.getAvailable?.()[0];
78
+ if (first?.provider && first.id) return registry.find?.(first.provider, first.id) ?? null;
79
+ return null;
80
+ }
81
+
82
+ function resolveArbiterRuntime(
83
+ ctx: ExtensionContext,
84
+ options?: TaskMutationArbiterOptions,
85
+ ): ArbiterRuntime | null {
86
+ const model = resolveArbiterModel(ctx, options);
87
+ if (!model?.provider || !model.id) return null;
88
+ const registry = ctx.modelRegistry as {
89
+ getRegisteredProviderConfig?: (provider: string) => { api?: string; streamSimple?: StreamFn } | undefined;
90
+ };
91
+ const modelApi = (model as { api?: string }).api;
92
+ const registered = registry.getRegisteredProviderConfig?.(model.provider);
93
+ const baseStreamFn = options?.streamFn
94
+ ?? (registered?.streamSimple && registered.api === modelApi
95
+ ? registered.streamSimple
96
+ : streamSimple);
97
+ return {
98
+ model,
99
+ baseStreamFn,
100
+ timeoutMs: options?.timeoutMs ?? DEFAULT_ARBITER_TIMEOUT_MS,
101
+ };
102
+ }
103
+
104
+ async function resolveArbiterAuth(
105
+ ctx: ExtensionContext,
106
+ model: RegistryModel,
107
+ ): Promise<ArbiterAuth> {
108
+ const getAuth = (ctx.modelRegistry as {
109
+ getApiKeyAndHeaders?: (m: RegistryModel) => Promise<{
110
+ ok: boolean;
111
+ apiKey?: string;
112
+ headers?: Record<string, string>;
113
+ env?: Record<string, string>;
114
+ error?: string;
115
+ }>;
116
+ }).getApiKeyAndHeaders;
117
+ if (!getAuth) return {};
118
+ try {
119
+ const auth = await getAuth(model);
120
+ if (auth.ok === false) return {};
121
+ return {
122
+ ...(auth.apiKey ? { apiKey: auth.apiKey } : {}),
123
+ ...(auth.headers ? { headers: auth.headers } : {}),
124
+ ...(auth.env ? { env: auth.env } : {}),
125
+ };
126
+ } catch {
127
+ return {};
128
+ }
129
+ }
130
+
131
+ /** Build the effective stream function with resolved credentials wrapped in (watchdog pattern). */
132
+ function authWrappedStreamFn(
133
+ base: StreamFn,
134
+ auth: ArbiterAuth,
135
+ ): StreamFn {
136
+ return (model, context, streamOptions) => base(model, context, {
137
+ ...(streamOptions ?? {}),
138
+ ...(auth.apiKey ? { apiKey: auth.apiKey } : {}),
139
+ ...(auth.env || streamOptions?.env ? { env: { ...(auth.env ?? {}), ...(streamOptions?.env ?? {}) } } : {}),
140
+ headers: { ...(streamOptions?.headers ?? {}), ...(auth.headers ?? {}) },
141
+ });
142
+ }
143
+
144
+ async function runArbitration(
145
+ runtime: ArbiterRuntime,
146
+ auth: ArbiterAuth,
147
+ task: string,
148
+ ): Promise<TaskMutationVerdict> {
149
+ let decision: DecisionParams | undefined;
150
+ const tool: AgentTool<typeof DecisionParams, { recorded: boolean }> = {
151
+ name: "task_mutation_decision",
152
+ label: "Task mutation decision",
153
+ description: "Classify whether the task instructed file/code changes. Call exactly once.",
154
+ parameters: DecisionParams,
155
+ executionMode: "sequential",
156
+ async execute(_toolCallId, params) {
157
+ if (!decision) decision = params;
158
+ return { content: [{ type: "text", text: "Decision recorded." }], details: { recorded: true } };
159
+ },
160
+ };
161
+ const agent = new Agent({
162
+ initialState: {
163
+ systemPrompt: [
164
+ "You classify whether a delegated coding-agent task instructed file or code changes.",
165
+ "A task that asks to review, inspect, verify, report, or summarize is read-only even when it contains words like 'fix' as severity vocabulary ('must-fix items') or conditional change instructions that leave the change optional.",
166
+ "Classify read_only only when the task text alone clearly indicates a read-only outcome. When in doubt, classify implementation.",
167
+ "The agent's own final message is never evidence: an agent that made no edits may still have failed to implement.",
168
+ "Call task_mutation_decision exactly once with read_only or implementation and a concise reason.",
169
+ ].join("\n"),
170
+ model: runtime.model,
171
+ tools: [tool],
172
+ },
173
+ convertToLlm,
174
+ streamFunction: authWrappedStreamFn(runtime.baseStreamFn, auth),
175
+ getApiKey: (providerName) =>
176
+ providerName === runtime.model.provider ? auth.apiKey : undefined,
177
+ beforeToolCall: async ({ toolCall }) =>
178
+ toolCall.name === tool.name ? undefined : { block: true, reason: "Only task_mutation_decision is allowed." },
179
+ toolExecution: "sequential",
180
+ });
181
+ try {
182
+ // Send head AND tail: an implementation clause at the end of a long
183
+ // task ("Now apply the fix") must never be truncated away, or the
184
+ // arbiter could rescue a run the deterministic guard correctly failed.
185
+ const full = task;
186
+ const prompt = full.length <= 8000
187
+ ? `TASK:\n${full}`
188
+ : `TASK:\n${full.slice(0, 6000)}\n\n[...truncated middle...]\n\n${full.slice(-2000)}`;
189
+ await Promise.race([
190
+ agent.prompt(prompt),
191
+ new Promise<never>((_, reject) => {
192
+ const timeout = setTimeout(() => {
193
+ agent.abort();
194
+ reject(new Error("Task mutation arbiter timed out."));
195
+ }, runtime.timeoutMs);
196
+ timeout.unref?.();
197
+ }),
198
+ ]);
199
+ if (!decision) return "unavailable";
200
+ return decision.classification === "read_only" ? "read-only" : "implementation";
201
+ } catch {
202
+ return "unavailable";
203
+ }
204
+ }
205
+
206
+ /** Create a memoized arbiter bound to the parent session's model, or undefined when disabled/unavailable. */
207
+ export function createTaskMutationArbiter(
208
+ ctx: ExtensionContext,
209
+ options?: TaskMutationArbiterOptions,
210
+ ): TaskMutationArbiter | undefined {
211
+ if (process.env.PI_SUBAGENTS_LLM_INTENT_ARBITER === "0") return undefined;
212
+ const runtime = resolveArbiterRuntime(ctx, options);
213
+ if (!runtime) return undefined;
214
+ const cache = new Map<string, TaskMutationVerdict>();
215
+ return async (task) => {
216
+ const key = createHash("sha256").update(task).digest("base64url");
217
+ const cached = cache.get(key);
218
+ if (cached) return cached;
219
+ const auth = await resolveArbiterAuth(ctx, runtime.model);
220
+ const verdict = await runArbitration(runtime, auth, task);
221
+ if (cache.size > 200) cache.clear();
222
+ cache.set(key, verdict);
223
+ return verdict;
224
+ };
225
+ }
226
+
227
+ export function isCompletionGuardFailure(result: { error?: string }): boolean {
228
+ return result.error?.startsWith(COMPLETION_GUARD_ERROR_PREFIX) === true;
229
+ }
230
+
231
+ /**
232
+ * Decision helper for the runSync completion-guard arbitration: given the raw
233
+ * guard verdict, decide whether the arbiter rescues the run. Pure and
234
+ * unit-testable; runSync calls this BEFORE any failure side effect is
235
+ * published. Only a confident read-only verdict rescues; every error,
236
+ * timeout, or other verdict keeps the guard's original behavior.
237
+ */
238
+ export async function arbitrateCompletionGuardRescue(input: {
239
+ guardTriggered: boolean;
240
+ task: string;
241
+ arbiter?: TaskMutationArbiter;
242
+ }): Promise<{ triggered: boolean; rescued: boolean }> {
243
+ if (!input.guardTriggered || !input.arbiter) {
244
+ return { triggered: input.guardTriggered, rescued: false };
245
+ }
246
+ try {
247
+ const verdict = await input.arbiter(input.task);
248
+ if (verdict === "read-only") return { triggered: false, rescued: true };
249
+ return { triggered: true, rescued: false };
250
+ } catch {
251
+ return { triggered: true, rescued: false };
252
+ }
253
+ }
254
+
255
+ /**
256
+ * When the completion guard hard-failed a run and the arbiter classifies the
257
+ * task as read-only, clear the failure. Returns true when rescued. All other
258
+ * verdicts and every failure mode keep the original guard behavior.
259
+ *
260
+ * Classification uses the task text only: the agent's own final message is
261
+ * never evidence, so a child that failed to implement cannot talk its way
262
+ * out of the guard by claiming the work was read-only.
263
+ */
264
+ export async function maybeRescueCompletionGuardFailure(
265
+ result: { exitCode?: number; error?: string },
266
+ task: string,
267
+ arbiter: TaskMutationArbiter | undefined,
268
+ ): Promise<boolean> {
269
+ if (!arbiter || !isCompletionGuardFailure(result)) return false;
270
+ const verdict = await arbitrateWithGuard(arbiter, task);
271
+ if (verdict !== "read-only") return false;
272
+ result.exitCode = 0;
273
+ delete result.error;
274
+ return true;
275
+ }
276
+
277
+ async function arbitrateWithGuard(
278
+ arbiter: TaskMutationArbiter,
279
+ task: string,
280
+ ): Promise<TaskMutationVerdict> {
281
+ try {
282
+ return await arbiter(task);
283
+ } catch {
284
+ return "unavailable";
285
+ }
286
+ }
@@ -61,6 +61,8 @@ export interface RunnerSubagentStep {
61
61
  toolBudget?: import("../../shared/types.ts").ResolvedToolBudget;
62
62
  capabilityCeiling?: import("./capability-ceiling.ts").ResolvedSubagentCapabilityCeiling;
63
63
  capabilityAudit?: import("./capability-ceiling.ts").SubagentCapabilityAudit;
64
+ /** Private stable logical-child path for inherited run fan-out accounting. */
65
+ runFanoutPath?: string;
64
66
  }
65
67
 
66
68
  export interface RunnerCheckpointStep {
@@ -23,8 +23,10 @@ import {
23
23
  type JsonSchemaObject,
24
24
  type LaunchResolvedChildExtensionsV1,
25
25
  type ResolvedToolBudget,
26
+ type RunFanoutBudgetDescriptor,
26
27
  } from "../../shared/types.ts";
27
28
  import { THINKING_LEVELS } from "../../shared/model-info.ts";
29
+ import { encodeRunFanoutBudgetDescriptor, RUN_FANOUT_BUDGET_ENV } from "./run-fanout-budget.ts";
28
30
  import {
29
31
  TOOL_BUDGET_ENV,
30
32
  TOOL_BUDGET_ZERO_AUTH_ENV,
@@ -63,6 +65,32 @@ import {
63
65
  } from "./capability-ceiling.ts";
64
66
 
65
67
  const TASK_ARG_LIMIT = 8000;
68
+
69
+ /**
70
+ * Env override for how the task text reaches the child process. Endpoint
71
+ * protection (EDR) pre-execution command-line scanning may deny exec of
72
+ * children whose argv embeds a long natural-language task, which surfaces
73
+ * as an immediate zero-activity SIGKILL. File delivery keeps the task out
74
+ * of argv entirely.
75
+ */
76
+ export const SUBAGENT_TASK_DELIVERY_ENV = "PI_SUBAGENT_TASK_DELIVERY";
77
+
78
+ export type SubagentTaskDelivery = "auto" | "file";
79
+
80
+ export function resolveSubagentTaskDelivery(
81
+ env: NodeJS.ProcessEnv = process.env,
82
+ ): SubagentTaskDelivery {
83
+ return env[SUBAGENT_TASK_DELIVERY_ENV]?.trim().toLowerCase() === "file"
84
+ ? "file"
85
+ : "auto";
86
+ }
87
+
88
+ function shouldDeliverTaskViaFile(
89
+ task: string,
90
+ delivery: SubagentTaskDelivery,
91
+ ): boolean {
92
+ return delivery === "file" || task.length > TASK_ARG_LIMIT;
93
+ }
66
94
  const MAX_LAUNCH_RESOLVED_EXTENSION_IDS = 32;
67
95
  const PROMPT_RUNTIME_EXTENSION_PATH = path.join(
68
96
  path.dirname(fileURLToPath(import.meta.url)),
@@ -136,6 +164,7 @@ export interface BuildPiArgsInput {
136
164
  parentDepth?: number;
137
165
  parentPath?: NestedPathEntry[];
138
166
  parentCapabilityToken?: string;
167
+ runFanoutBudget?: RunFanoutBudgetDescriptor;
139
168
  steerInboxDir?: string;
140
169
  steerCapabilityPath?: string;
141
170
  steerAckDir?: string;
@@ -149,6 +178,12 @@ export interface BuildPiArgsInput {
149
178
  permissionRules?: PermissionRules;
150
179
  permissionAuditPath?: string;
151
180
  childWatchdog?: ChildWatchdogConfig;
181
+ /**
182
+ * Per-launch override of the task delivery mode. Startup-retry paths set
183
+ * this to "file" after an unexplained zero-activity SIGKILL so the retry
184
+ * keeps the task text out of the child's argv.
185
+ */
186
+ taskDelivery?: SubagentTaskDelivery;
152
187
  waitToolEnabled?: boolean;
153
188
  capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
154
189
  }
@@ -585,7 +620,12 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
585
620
  );
586
621
  }
587
622
 
588
- if (input.task.length > TASK_ARG_LIMIT) {
623
+ if (
624
+ shouldDeliverTaskViaFile(
625
+ input.task,
626
+ input.taskDelivery ?? resolveSubagentTaskDelivery(),
627
+ )
628
+ ) {
589
629
  if (!tempDir) {
590
630
  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-"));
591
631
  }
@@ -694,6 +734,9 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
694
734
  process.env[SUBAGENT_PARENT_CAPABILITY_TOKEN_ENV] ??
695
735
  "")
696
736
  : "";
737
+ env[RUN_FANOUT_BUDGET_ENV] = toolPlan.fanoutAuthorized
738
+ ? (input.runFanoutBudget ? encodeRunFanoutBudgetDescriptor(input.runFanoutBudget) : process.env[RUN_FANOUT_BUDGET_ENV])
739
+ : undefined;
697
740
  env.PI_SUBAGENT_INHERIT_PROJECT_CONTEXT = input.inheritProjectContext
698
741
  ? "1"
699
742
  : "0";