pi-subagents 0.35.1 → 0.37.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 (89) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +224 -31
  3. package/agents/advisor.md +73 -0
  4. package/agents/delegate.md +2 -0
  5. package/agents/worker.md +2 -0
  6. package/package.json +11 -13
  7. package/skills/pi-subagents/SKILL.md +42 -13
  8. package/src/agents/agent-management.ts +7 -1
  9. package/src/agents/agents.ts +121 -17
  10. package/src/api/capability-ceiling.ts +17 -0
  11. package/src/api/delegation.ts +127 -0
  12. package/src/api/preflight.ts +399 -0
  13. package/src/extension/config.ts +7 -1
  14. package/src/extension/index.ts +52 -38
  15. package/src/extension/rpc.ts +31 -2
  16. package/src/extension/schemas.ts +29 -3
  17. package/src/extension/tool-description.ts +4 -2
  18. package/src/intercom/intercom-bridge.ts +1 -1
  19. package/src/intercom/native-supervisor-channel.ts +45 -6
  20. package/src/intercom/result-intercom.ts +7 -0
  21. package/src/runs/background/async-execution.ts +158 -17
  22. package/src/runs/background/async-job-tracker.ts +4 -0
  23. package/src/runs/background/async-resume.ts +47 -8
  24. package/src/runs/background/async-status.ts +94 -3
  25. package/src/runs/background/chain-append.ts +2 -0
  26. package/src/runs/background/completion-batcher.ts +6 -4
  27. package/src/runs/background/completion-dedupe.ts +2 -11
  28. package/src/runs/background/fleet-view.ts +9 -4
  29. package/src/runs/background/notify.ts +132 -120
  30. package/src/runs/background/process-terminal.ts +280 -0
  31. package/src/runs/background/result-watcher.ts +138 -78
  32. package/src/runs/background/run-status.ts +10 -2
  33. package/src/runs/background/scheduled-runs.ts +6 -1
  34. package/src/runs/background/stale-run-reconciler.ts +6 -0
  35. package/src/runs/background/subagent-runner.ts +406 -54
  36. package/src/runs/background/subagent-wait.ts +130 -4
  37. package/src/runs/background/wait-tool.ts +2 -2
  38. package/src/runs/foreground/chain-execution.ts +181 -111
  39. package/src/runs/foreground/execution.ts +147 -47
  40. package/src/runs/foreground/foreground-control.ts +90 -0
  41. package/src/runs/foreground/subagent-executor.ts +426 -165
  42. package/src/runs/shared/acceptance.ts +94 -41
  43. package/src/runs/shared/agent-contract.ts +38 -0
  44. package/src/runs/shared/capability-ceiling.ts +177 -0
  45. package/src/runs/shared/child-protocol.ts +1 -1
  46. package/src/runs/shared/completion-guard.ts +36 -5
  47. package/src/runs/shared/context-mode.ts +44 -0
  48. package/src/runs/shared/dynamic-fanout.ts +5 -5
  49. package/src/runs/shared/long-running-guard.ts +4 -0
  50. package/src/runs/shared/mcp-direct-tool-allowlist.ts +12 -6
  51. package/src/runs/shared/nested-events.ts +35 -3
  52. package/src/runs/shared/parallel-handoff.ts +154 -0
  53. package/src/runs/shared/parallel-utils.ts +11 -0
  54. package/src/runs/shared/pi-args.ts +148 -56
  55. package/src/runs/shared/run-history.ts +90 -5
  56. package/src/runs/shared/session-lease.ts +25 -5
  57. package/src/runs/shared/structured-output.ts +112 -7
  58. package/src/runs/shared/subagent-control.ts +4 -0
  59. package/src/runs/shared/subagent-prompt-runtime.ts +31 -19
  60. package/src/runs/shared/task-intent.ts +10 -5
  61. package/src/runs/shared/tool-availability.ts +21 -3
  62. package/src/runs/shared/tool-budget.ts +11 -5
  63. package/src/runs/shared/turn-budget.ts +2 -1
  64. package/src/runs/shared/worktree.ts +63 -14
  65. package/src/shared/accessible-dir.ts +25 -0
  66. package/src/shared/artifacts.ts +37 -7
  67. package/src/shared/atomic-json.ts +14 -42
  68. package/src/shared/child-transcript.ts +52 -0
  69. package/src/shared/file-system-retry.ts +47 -0
  70. package/src/shared/launch-contract.ts +123 -0
  71. package/src/shared/settings.ts +9 -1
  72. package/src/shared/types.ts +314 -28
  73. package/src/shared/utils.ts +17 -42
  74. package/src/slash/delegation-adapters.ts +153 -6
  75. package/src/slash/delegation-json.ts +108 -0
  76. package/src/slash/delegation-request.ts +182 -36
  77. package/src/slash/prompt-template-bridge.ts +222 -37
  78. package/src/slash/selector.ts +147 -0
  79. package/src/slash/slash-commands.ts +15 -6
  80. package/src/slash/slash-live-state.ts +2 -2
  81. package/src/slash/subagents-admin.ts +42 -42
  82. package/src/tui/fleet-status.ts +362 -0
  83. package/src/tui/fleet-transcript.ts +472 -0
  84. package/src/tui/fleet.ts +318 -59
  85. package/src/tui/render.ts +17 -15
  86. package/src/watchdog/change-signature.ts +105 -12
  87. package/src/watchdog/review.ts +7 -2
  88. package/src/watchdog/runtime.ts +5 -3
  89. package/src/slash/subagents-editor.ts +0 -86
@@ -193,11 +193,15 @@ export function formatControlNoticeMessage(event: ControlEvent, childIntercomTar
193
193
  ].filter((line): line is string => Boolean(line)).join("\n");
194
194
  }
195
195
 
196
+ const supervisorHint = event.reason === "supervisor_request"
197
+ ? "Supervisor request: reply to the pending request. If subagent_supervisor pending is empty, check intercom pending because an external intercom tool may own the request."
198
+ : undefined;
196
199
  return [
197
200
  `Subagent needs attention: ${event.agent}`,
198
201
  `Run: ${runTarget}${event.index !== undefined ? ` step ${event.index + 1}` : ""}`,
199
202
  `Signal: ${event.message}`,
200
203
  event.recentFailureSummary ? `Recent failures: ${event.recentFailureSummary}` : undefined,
204
+ supervisorHint,
201
205
  "Hint: Inspect status first unless the run is clearly blocked. Use steer for a top-level live async child, routed resume for a live nested child, or resume to revive a paused/completed/failed child.",
202
206
  `Top-level live async nudge: ${steerCommand}`,
203
207
  `Routed live nested nudge: ${nestedResumeCommand}`,
@@ -4,15 +4,15 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import { registerNativeSupervisorClient } from "../../intercom/native-supervisor-channel.ts";
5
5
  import { consumeSteerRequestsFromDir, steerAckPathFromDir, writeSteerAckAt, writeSteerCapabilityAt, writeSteerRequestToDir, type SteerRequest } from "../background/control-channel.ts";
6
6
  import { SUBAGENT_CHILD_AGENT_ENV, SUBAGENT_CHILD_INDEX_ENV, SUBAGENT_FANOUT_CHILD_ENV, SUBAGENT_STEER_ACK_DIR_ENV, SUBAGENT_STEER_CAPABILITY_ENV, SUBAGENT_STEER_INBOX_ENV } from "./pi-args.ts";
7
- import { STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV, validateStructuredOutputValue } from "./structured-output.ts";
7
+ import { createStructuredOutputToolParameters, STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV, validateStructuredOutputValue } from "./structured-output.ts";
8
8
  import {
9
9
  CHILD_TOOL_DIAGNOSTIC_PATH_ENV,
10
- formatChildToolDiagnostic,
10
+ MCP_DIRECT_CHILD_TOOLS_ENV,
11
11
  REQUIRED_CHILD_TOOLS_ENV,
12
12
  writeChildToolDiagnostic,
13
13
  type ChildToolDiagnostic,
14
14
  } from "./tool-availability.ts";
15
- import { TOOL_BUDGET_ENV, decodeToolBudgetEnv, shouldBlockToolForBudget, toolBudgetBlockedMessage, toolBudgetSoftNudge } from "./tool-budget.ts";
15
+ import { TOOL_BUDGET_ENV, TOOL_BUDGET_ZERO_AUTH_ENV, decodeToolBudgetEnv, shouldBlockToolForBudget, toolBudgetBlockedMessage, toolBudgetSoftNudge } from "./tool-budget.ts";
16
16
  import type { JsonSchemaObject, ResolvedToolBudget, SubagentState } from "../../shared/types.ts";
17
17
  import { resolveCurrentSessionId } from "../../shared/session-identity.ts";
18
18
  import { resolveWatchPath } from "../../shared/utils.ts";
@@ -69,16 +69,34 @@ function readBooleanEnv(name: string): boolean | undefined {
69
69
  return value !== "0";
70
70
  }
71
71
 
72
- function refreshChildToolDiagnostic(pi: ExtensionAPI): ChildToolDiagnostic | undefined {
73
- const filePath = process.env[CHILD_TOOL_DIAGNOSTIC_PATH_ENV]?.trim();
72
+ function readRequiredChildTools(): string[] | undefined {
74
73
  const encoded = process.env[REQUIRED_CHILD_TOOLS_ENV]?.trim();
75
- if (!filePath || !encoded) return undefined;
74
+ if (!encoded) return undefined;
76
75
  const required = JSON.parse(encoded) as unknown;
77
76
  if (!Array.isArray(required) || required.some((name) => typeof name !== "string" || !name)) {
78
77
  throw new Error(`Invalid ${REQUIRED_CHILD_TOOLS_ENV} payload.`);
79
78
  }
79
+ return required;
80
+ }
81
+
82
+ function readMcpDirectChildTools(): string[] | undefined {
83
+ const encoded = process.env[MCP_DIRECT_CHILD_TOOLS_ENV]?.trim();
84
+ if (!encoded) return undefined;
85
+ try {
86
+ const tools = JSON.parse(encoded) as unknown;
87
+ if (!Array.isArray(tools) || tools.some((name) => typeof name !== "string" || !name)) return undefined;
88
+ return tools;
89
+ } catch {
90
+ return undefined;
91
+ }
92
+ }
93
+
94
+ function refreshChildToolDiagnostic(pi: ExtensionAPI): ChildToolDiagnostic | undefined {
95
+ const filePath = process.env[CHILD_TOOL_DIAGNOSTIC_PATH_ENV]?.trim();
96
+ const required = readRequiredChildTools();
97
+ if (!filePath || !required) return undefined;
80
98
  const available = pi.getAllTools().map((tool) => tool.name);
81
- return writeChildToolDiagnostic(filePath, required, available, process.env[SUBAGENT_CHILD_AGENT_ENV]?.trim());
99
+ return writeChildToolDiagnostic(filePath, required, available, process.env[SUBAGENT_CHILD_AGENT_ENV]?.trim(), readMcpDirectChildTools());
82
100
  }
83
101
 
84
102
  function findSectionEnd(prompt: string, startIndex: number, nextHeaders: string[]): number {
@@ -329,7 +347,7 @@ export function registerSteeringInbox(
329
347
 
330
348
  export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
331
349
  registerSteeringInbox(pi);
332
- registerToolBudget(pi, decodeToolBudgetEnv(process.env[TOOL_BUDGET_ENV]));
350
+ registerToolBudget(pi, decodeToolBudgetEnv(process.env[TOOL_BUDGET_ENV], { allowZero: process.env[TOOL_BUDGET_ZERO_AUTH_ENV] === "1" }));
333
351
  registerChildWatchdog(pi);
334
352
  const waitToolEnabled = resolveWaitToolConfig().enabled;
335
353
  const waitState = {
@@ -365,6 +383,9 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
365
383
  const sessionManager = (ctx as { sessionManager?: Parameters<typeof resolveCurrentSessionId>[0] } | undefined)?.sessionManager;
366
384
  waitState.currentSessionId = sessionManager ? resolveCurrentSessionId(sessionManager) : null;
367
385
  registerNativeSupervisorClientOnce();
386
+ if (readRequiredChildTools()?.includes("intercom")) registerNativeSupervisorFallbackOnce();
387
+ });
388
+ onRuntimeEvent("agent_start", () => {
368
389
  refreshChildToolDiagnostic(pi);
369
390
  });
370
391
  onRuntimeEvent("agent_end", async (_event: unknown, ctx: unknown) => {
@@ -375,12 +396,7 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
375
396
  const structuredSchemaPath = process.env[STRUCTURED_OUTPUT_SCHEMA_ENV];
376
397
  if (structuredOutputPath && structuredSchemaPath) {
377
398
  const schema = JSON.parse(fs.readFileSync(structuredSchemaPath, "utf-8")) as JsonSchemaObject;
378
- const parameters = {
379
- type: "object",
380
- properties: { value: schema },
381
- required: ["value"],
382
- additionalProperties: false,
383
- };
399
+ const parameters = createStructuredOutputToolParameters(schema);
384
400
  const registerTool = pi.registerTool as unknown as (tool: {
385
401
  name: string;
386
402
  label: string;
@@ -394,7 +410,7 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
394
410
  description: "Submit the required final structured output for this subagent step. This terminates the step.",
395
411
  parameters: parameters as never,
396
412
  async execute(_id: string, params: { value: unknown }) {
397
- const validation = validateStructuredOutputValue(schema, params.value);
413
+ const validation = await validateStructuredOutputValue(schema, params.value);
398
414
  if (validation.status === "invalid") {
399
415
  throw new Error(`Structured output validation failed: ${validation.message}`);
400
416
  }
@@ -417,7 +433,6 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
417
433
 
418
434
  onRuntimeEvent("before_agent_start", async (event: { systemPrompt: string }) => {
419
435
  registerNativeSupervisorFallbackOnce();
420
- const toolDiagnostic = refreshChildToolDiagnostic(pi);
421
436
  const intercomSessionName = process.env[SUBAGENT_INTERCOM_SESSION_NAME_ENV]?.trim();
422
437
  if (intercomSessionName && typeof pi.setSessionName === "function") {
423
438
  pi.setSessionName(intercomSessionName);
@@ -434,9 +449,6 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
434
449
  fanoutChild: fanoutChild === true,
435
450
  });
436
451
  }
437
- if (toolDiagnostic) {
438
- rewritten = `${formatChildToolDiagnostic(toolDiagnostic)}\nDo not claim tool-dependent work succeeded; report this configuration error to the parent.\n\n${rewritten}`;
439
- }
440
452
  if (rewritten === event.systemPrompt) return;
441
453
  return { systemPrompt: rewritten };
442
454
  });
@@ -25,10 +25,11 @@ const REVIEW_ONLY_PATTERNS = [
25
25
  ];
26
26
 
27
27
  const REVIEWER_REQUIRED_EDIT_PATTERNS = [
28
- /\bmust\s+(?:edit|modify|change|fix|patch|apply)\b/i,
29
- /\brequired\s+to\s+(?:edit|modify|change|fix|patch|apply)\b/i,
28
+ /\bmust\s+(?:edit|modify|change|fix|patch|apply|implement)\b/i,
29
+ /\brequired\s+to\s+(?:edit|modify|change|fix|patch|apply|implement)\b/i,
30
+ /(?:^|[.!?\n]\s*)implement\s+(?:the\s+)?(?:approved|requested|specified|file|code|source|fix(?:es)?|changes?)\b/i,
30
31
  /\bregardless\s+of\s+findings\b/i,
31
- /\balways\s+(?:edit|modify|change|fix|patch|apply)\b/i,
32
+ /\balways\s+(?:edit|modify|change|fix|patch|apply|implement)\b/i,
32
33
  /\bapply\s+(?:the\s+)?fix(?:es)?\s+directly\b/i,
33
34
  /\bmake\s+(?:the\s+)?code\s+changes\b/i,
34
35
  ];
@@ -134,8 +135,12 @@ function taskHasReadOnlyDeliverable(taskText: string): boolean {
134
135
  return READ_ONLY_DELIVERABLE_PATTERNS.some((pattern) => pattern.test(taskText));
135
136
  }
136
137
 
138
+ function isReviewerStyleAgent(agent: string): boolean {
139
+ return /\b(?:advisor|reviewer|oracle)\b/i.test(agent);
140
+ }
141
+
137
142
  function hasImplementationIntent(agent: string, taskText: string): boolean {
138
- if (/\breviewer\b/i.test(agent)) return REVIEWER_REQUIRED_EDIT_PATTERNS.some((pattern) => pattern.test(taskText));
143
+ if (isReviewerStyleAgent(agent)) return REVIEWER_REQUIRED_EDIT_PATTERNS.some((pattern) => pattern.test(taskText));
139
144
  if (agent === "worker") return WORKER_IMPLEMENTATION_PATTERNS.some((pattern) => pattern.test(taskText));
140
145
  return GENERAL_IMPLEMENTATION_PATTERNS.some((pattern) => pattern.test(taskText));
141
146
  }
@@ -151,7 +156,7 @@ export function classifyTaskMutationIntent(agent: string, task: string): TaskMut
151
156
 
152
157
  if (RESEARCH_AGENT_PATTERNS.some((pattern) => pattern.test(agent))) return { kind: "read-only" };
153
158
  if (hasImplementationIntent(agent, taskText)) return { kind: "implementation" };
154
- if (/\breviewer\b/i.test(agent)) return { kind: "read-only" };
159
+ if (isReviewerStyleAgent(agent)) return { kind: "read-only" };
155
160
  return taskHasReadOnlyDeliverable(taskTextWithoutScopedConstraints) ? { kind: "read-only" } : { kind: "unknown" };
156
161
  }
157
162
 
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
 
4
4
  export const REQUIRED_CHILD_TOOLS_ENV = "PI_SUBAGENT_REQUIRED_TOOLS";
5
+ export const MCP_DIRECT_CHILD_TOOLS_ENV = "PI_SUBAGENT_MCP_DIRECT_TOOLS";
5
6
  export const CHILD_TOOL_DIAGNOSTIC_PATH_ENV = "PI_SUBAGENT_TOOL_DIAGNOSTIC_PATH";
6
7
 
7
8
  export interface ChildToolDiagnostic {
@@ -9,22 +10,35 @@ export interface ChildToolDiagnostic {
9
10
  required: string[];
10
11
  available: string[];
11
12
  missing: string[];
13
+ missingMcpDirectTools?: string[];
12
14
  }
13
15
 
16
+ const PI_CORE_CHILD_TOOLS = new Set(["bash", "edit", "find", "grep", "ls", "read", "write"]);
17
+
14
18
  export function writeChildToolDiagnostic(
15
19
  filePath: string,
16
20
  required: string[],
17
21
  available: string[],
18
22
  agent?: string,
23
+ mcpDirectTools?: string[],
19
24
  ): ChildToolDiagnostic | undefined {
20
- const availableNames = new Set(available);
25
+ const availableNames = new Set([...available, ...PI_CORE_CHILD_TOOLS]);
21
26
  const missing = required.filter((name) => !availableNames.has(name));
22
27
  if (missing.length === 0) {
23
28
  fs.rmSync(filePath, { force: true });
24
29
  return undefined;
25
30
  }
26
31
 
27
- const diagnostic: ChildToolDiagnostic = { agent, required, available, missing };
32
+ const missingMcpDirectTools = mcpDirectTools?.length
33
+ ? missing.filter((name) => mcpDirectTools.includes(name))
34
+ : [];
35
+ const diagnostic: ChildToolDiagnostic = {
36
+ agent,
37
+ required,
38
+ available,
39
+ missing,
40
+ ...(missingMcpDirectTools.length > 0 ? { missingMcpDirectTools } : {}),
41
+ };
28
42
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
29
43
  fs.writeFileSync(filePath, JSON.stringify(diagnostic), { mode: 0o600 });
30
44
  return diagnostic;
@@ -34,7 +48,7 @@ export function readChildToolDiagnostic(filePath: string | undefined): ChildTool
34
48
  if (!filePath || !fs.existsSync(filePath)) return undefined;
35
49
  const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as Partial<ChildToolDiagnostic>;
36
50
  const stringArray = (value: unknown): value is string[] => Array.isArray(value) && value.every((entry) => typeof entry === "string" && entry.length > 0);
37
- if (!stringArray(parsed.required) || !stringArray(parsed.available) || !stringArray(parsed.missing) || (parsed.agent !== undefined && typeof parsed.agent !== "string")) {
51
+ if (!stringArray(parsed.required) || !stringArray(parsed.available) || !stringArray(parsed.missing) || (parsed.agent !== undefined && typeof parsed.agent !== "string") || (parsed.missingMcpDirectTools !== undefined && !stringArray(parsed.missingMcpDirectTools))) {
38
52
  throw new Error(`Malformed child tool diagnostic at '${filePath}'.`);
39
53
  }
40
54
  return {
@@ -42,6 +56,7 @@ export function readChildToolDiagnostic(filePath: string | undefined): ChildTool
42
56
  required: parsed.required,
43
57
  available: parsed.available,
44
58
  missing: parsed.missing,
59
+ ...(parsed.missingMcpDirectTools ? { missingMcpDirectTools: parsed.missingMcpDirectTools } : {}),
45
60
  };
46
61
  }
47
62
 
@@ -50,6 +65,9 @@ export function formatChildToolDiagnostic(diagnostic: ChildToolDiagnostic): stri
50
65
  return [
51
66
  `${subject} requested unavailable child tools: ${diagnostic.missing.join(", ")}.`,
52
67
  "The `tools` field is a strict allowlist; it does not load extension code.",
68
+ ...(diagnostic.missingMcpDirectTools?.length
69
+ ? [`Resolved MCP direct tools missing from the child registry: ${diagnostic.missingMcpDirectTools.join(", ")}. This indicates a host/pi-mcp-adapter registration problem, not a tool-call failure.`]
70
+ : []),
53
71
  "For extension tools, add the provider path to `subagentOnlyExtensions` (child-only), `extensions`, or as a path-like entry in `tools`, while keeping each registered tool name in `tools`.",
54
72
  "For MCP tools, verify the MCP adapter configuration and selected tool names. For builtin tools, verify the name against the installed Pi version.",
55
73
  ].join("\n");
@@ -2,6 +2,7 @@ import type { ResolvedToolBudget, ToolBudgetConfig, ToolBudgetState } from "../.
2
2
 
3
3
  export const DEFAULT_TOOL_BUDGET_BLOCK = ["read", "grep", "find", "ls"] as const;
4
4
  export const TOOL_BUDGET_ENV = "PI_SUBAGENT_TOOL_BUDGET";
5
+ export const TOOL_BUDGET_ZERO_AUTH_ENV = "PI_SUBAGENT_TOOL_BUDGET_ZERO_AUTH";
5
6
 
6
7
  export function normalizeToolBudgetBlock(block: ToolBudgetConfig["block"] | undefined): "*" | string[] {
7
8
  if (block === "*") return "*";
@@ -9,12 +10,17 @@ export function normalizeToolBudgetBlock(block: ToolBudgetConfig["block"] | unde
9
10
  return [...new Set(block.map((tool) => tool.trim()).filter(Boolean))];
10
11
  }
11
12
 
12
- export function validateToolBudgetConfig(raw: unknown, label = "toolBudget"): { budget?: ResolvedToolBudget; error?: string } {
13
+ export function validateToolBudgetConfig(
14
+ raw: unknown,
15
+ label = "toolBudget",
16
+ options: { minimumHard?: 0 | 1 } = {},
17
+ ): { budget?: ResolvedToolBudget; error?: string } {
13
18
  if (raw === undefined) return {};
14
19
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { error: `${label} must be an object with hard and optional soft/block.` };
15
20
  const value = raw as ToolBudgetConfig;
16
- if (typeof value.hard !== "number" || !Number.isInteger(value.hard) || value.hard < 1) {
17
- return { error: `${label}.hard must be an integer >= 1.` };
21
+ const minimumHard = options.minimumHard ?? 1;
22
+ if (typeof value.hard !== "number" || !Number.isInteger(value.hard) || value.hard < minimumHard) {
23
+ return { error: `${label}.hard must be an integer >= ${minimumHard}.` };
18
24
  }
19
25
  if (value.soft !== undefined && (typeof value.soft !== "number" || !Number.isInteger(value.soft) || value.soft < 1)) {
20
26
  return { error: `${label}.soft must be an integer >= 1 when provided.` };
@@ -65,10 +71,10 @@ export function encodeToolBudgetEnv(budget: ResolvedToolBudget | undefined): str
65
71
  return budget ? JSON.stringify(budget) : undefined;
66
72
  }
67
73
 
68
- export function decodeToolBudgetEnv(value: string | undefined): ResolvedToolBudget | undefined {
74
+ export function decodeToolBudgetEnv(value: string | undefined, options: { allowZero?: boolean } = {}): ResolvedToolBudget | undefined {
69
75
  if (!value?.trim()) return undefined;
70
76
  const parsed = JSON.parse(value) as unknown;
71
- const normalized = validateToolBudgetConfig(parsed, TOOL_BUDGET_ENV);
77
+ const normalized = validateToolBudgetConfig(parsed, TOOL_BUDGET_ENV, options.allowZero ? { minimumHard: 0 } : undefined);
72
78
  if (normalized.error) throw new Error(normalized.error);
73
79
  return normalized.budget;
74
80
  }
@@ -88,10 +88,11 @@ export function turnBudgetDecision(
88
88
  turnCount: number,
89
89
  terminalAssistantStop: boolean,
90
90
  toolWorkActiveOrStarting: boolean,
91
+ enforceHardLimit = false,
91
92
  ): "continue" | "defer" | "abort" {
92
93
  const hardLimit = budget.maxTurns + budget.graceTurns;
93
94
  if (turnCount < hardLimit) return "continue";
94
- if (toolWorkActiveOrStarting) return "defer";
95
+ if (toolWorkActiveOrStarting && !enforceHardLimit) return "defer";
95
96
  if (turnCount === hardLimit && terminalAssistantStop) return "continue";
96
97
  return "abort";
97
98
  }
@@ -18,7 +18,7 @@ interface WorktreeInfo {
18
18
  syntheticPaths: string[];
19
19
  }
20
20
 
21
- interface WorktreeDiff {
21
+ export interface WorktreeDiff {
22
22
  index: number;
23
23
  agent: string;
24
24
  branch: string;
@@ -27,6 +27,23 @@ interface WorktreeDiff {
27
27
  insertions: number;
28
28
  deletions: number;
29
29
  patchPath: string;
30
+ error?: string;
31
+ }
32
+
33
+ export interface WorktreeCleanupTask {
34
+ index: number;
35
+ path: string;
36
+ branch: string;
37
+ worktreeRemoved: boolean;
38
+ branchRemoved: boolean;
39
+ errors?: string[];
40
+ }
41
+
42
+ export interface WorktreeCleanupReport {
43
+ state: "complete" | "partial";
44
+ tasks: WorktreeCleanupTask[];
45
+ pruned: boolean;
46
+ errors?: string[];
30
47
  }
31
48
 
32
49
  interface WorktreeTaskCwdConflict {
@@ -426,7 +443,7 @@ function removeSyntheticPathsBeforeDiff(worktree: WorktreeInfo): void {
426
443
  }
427
444
  }
428
445
 
429
- function emptyDiff(index: number, agent: string, branch: string, patchPath: string): WorktreeDiff {
446
+ function emptyDiff(index: number, agent: string, branch: string, patchPath: string, error?: string): WorktreeDiff {
430
447
  return {
431
448
  index,
432
449
  agent,
@@ -436,6 +453,7 @@ function emptyDiff(index: number, agent: string, branch: string, patchPath: stri
436
453
  insertions: 0,
437
454
  deletions: 0,
438
455
  patchPath,
456
+ ...(error ? { error } : {}),
439
457
  };
440
458
  }
441
459
 
@@ -497,13 +515,30 @@ function writeEmptyPatch(patchPath: string): void {
497
515
  }
498
516
  }
499
517
 
500
- function cleanupSingleWorktree(repoCwd: string, worktree: WorktreeInfo): void {
501
- try { runGitChecked(repoCwd, ["worktree", "remove", "--force", worktree.path]); } catch {
502
- // Cleanup is best-effort to avoid masking caller errors.
518
+ function cleanupSingleWorktree(repoCwd: string, worktree: WorktreeInfo): WorktreeCleanupTask {
519
+ const errors: string[] = [];
520
+ let worktreeRemoved = false;
521
+ let branchRemoved = false;
522
+ try {
523
+ runGitChecked(repoCwd, ["worktree", "remove", "--force", worktree.path]);
524
+ worktreeRemoved = true;
525
+ } catch (error) {
526
+ errors.push(`worktree removal failed: ${error instanceof Error ? error.message : String(error)}`);
503
527
  }
504
- try { runGitChecked(repoCwd, ["branch", "-D", worktree.branch]); } catch {
505
- // Cleanup is best-effort to avoid masking caller errors.
528
+ try {
529
+ runGitChecked(repoCwd, ["branch", "-D", worktree.branch]);
530
+ branchRemoved = true;
531
+ } catch (error) {
532
+ errors.push(`branch removal failed: ${error instanceof Error ? error.message : String(error)}`);
506
533
  }
534
+ return {
535
+ index: worktree.index,
536
+ path: worktree.path,
537
+ branch: worktree.branch,
538
+ worktreeRemoved,
539
+ branchRemoved,
540
+ ...(errors.length ? { errors } : {}),
541
+ };
507
542
  }
508
543
 
509
544
  function hasWorktreeChanges(diff: WorktreeDiff): boolean {
@@ -560,23 +595,37 @@ export function diffWorktrees(setup: WorktreeSetup, agents: string[], diffsDir:
560
595
  const patchPath = path.join(diffsDir, `task-${index}-${safePatchAgentName(agent)}.patch`);
561
596
  try {
562
597
  diffs.push(captureWorktreeDiff(setup, worktree, agent, patchPath));
563
- } catch {
564
- // Preserve execution flow; failed diff capture maps to an empty per-task patch.
598
+ } catch (error) {
599
+ // Preserve execution flow while retaining the failed capture as handoff evidence.
565
600
  writeEmptyPatch(patchPath);
566
- diffs.push(emptyDiff(index, agent, worktree.branch, patchPath));
601
+ diffs.push(emptyDiff(index, agent, worktree.branch, patchPath, error instanceof Error ? error.message : String(error)));
567
602
  }
568
603
  }
569
604
 
570
605
  return diffs;
571
606
  }
572
607
 
573
- export function cleanupWorktrees(setup: WorktreeSetup): void {
608
+ export function cleanupWorktrees(setup: WorktreeSetup): WorktreeCleanupReport {
609
+ const tasks: WorktreeCleanupTask[] = [];
574
610
  for (let index = setup.worktrees.length - 1; index >= 0; index--) {
575
- cleanupSingleWorktree(setup.cwd, setup.worktrees[index]!);
611
+ tasks.push(cleanupSingleWorktree(setup.cwd, setup.worktrees[index]!));
576
612
  }
577
- try { runGitChecked(setup.cwd, ["worktree", "prune"]); } catch {
578
- // Pruning is best-effort cleanup.
613
+ tasks.sort((left, right) => left.index - right.index);
614
+ const errors: string[] = [];
615
+ let pruned = false;
616
+ try {
617
+ runGitChecked(setup.cwd, ["worktree", "prune"]);
618
+ pruned = true;
619
+ } catch (error) {
620
+ errors.push(`worktree prune failed: ${error instanceof Error ? error.message : String(error)}`);
579
621
  }
622
+ const state = tasks.every((task) => task.worktreeRemoved && task.branchRemoved) && pruned ? "complete" : "partial";
623
+ return {
624
+ state,
625
+ tasks,
626
+ pruned,
627
+ ...(errors.length ? { errors } : {}),
628
+ };
580
629
  }
581
630
 
582
631
  export function formatWorktreeDiffSummary(diffs: WorktreeDiff[]): string {
@@ -0,0 +1,25 @@
1
+ import * as fs from "node:fs";
2
+ import { DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS, runFileSystemOperationWithRetry, waitForFileSystemRetry } from "./file-system-retry.ts";
3
+
4
+ type AccessibleDirFs = Pick<typeof fs, "accessSync" | "mkdirSync">;
5
+
6
+ type AccessibleDirOptions = {
7
+ fs?: AccessibleDirFs;
8
+ retryDirectoryErrors?: boolean;
9
+ retryDelaysMs?: readonly number[];
10
+ wait?: (delayMs: number) => void;
11
+ };
12
+
13
+ export function ensureAccessibleDir(dirPath: string, options: AccessibleDirOptions = {}): void {
14
+ const fsImpl = options.fs ?? fs;
15
+ const retryDirectoryErrors = options.retryDirectoryErrors ?? process.platform === "win32";
16
+ const retryDelaysMs = retryDirectoryErrors ? options.retryDelaysMs ?? DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS : [];
17
+ const wait = options.wait ?? waitForFileSystemRetry;
18
+
19
+ runFileSystemOperationWithRetry(() => {
20
+ fsImpl.mkdirSync(dirPath, { recursive: true });
21
+ }, { retryDelaysMs, wait });
22
+ runFileSystemOperationWithRetry(() => {
23
+ fsImpl.accessSync(dirPath, fs.constants.R_OK | fs.constants.W_OK);
24
+ }, { retryDelaysMs, wait });
25
+ }
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { TEMP_ARTIFACTS_DIR, type ArtifactPaths } from "./types.ts";
3
+ import { TEMP_ARTIFACTS_DIR, type ArtifactPaths, type ArtifactDirPreference } from "./types.ts";
4
4
  import { getAgentDir } from "./utils.ts";
5
5
  const CLEANUP_MARKER_FILE = ".last-cleanup";
6
6
  const PROJECT_ARTIFACT_ROOT = ".pi-subagents";
@@ -17,13 +17,30 @@ export function getProjectChainRunsDir(cwd: string): string {
17
17
  return path.join(getProjectSubagentsDir(cwd), "chain-runs");
18
18
  }
19
19
 
20
- export function getArtifactsDir(sessionFile: string | null, projectCwd?: string): string {
21
- if (projectCwd) return getProjectArtifactsDir(projectCwd);
22
- if (sessionFile) {
23
- const sessionDir = path.dirname(sessionFile);
24
- return path.join(sessionDir, "subagent-artifacts");
20
+ export function getArtifactsDir(
21
+ sessionFile: string | null,
22
+ projectCwd?: string,
23
+ dirPreference: ArtifactDirPreference = "project",
24
+ ): string {
25
+ switch (dirPreference) {
26
+ case "session":
27
+ if (sessionFile) {
28
+ const sessionDir = path.dirname(sessionFile);
29
+ return path.join(sessionDir, "subagent-artifacts");
30
+ }
31
+ return TEMP_ARTIFACTS_DIR;
32
+ case "temp":
33
+ return TEMP_ARTIFACTS_DIR;
34
+ case "project":
35
+ if (projectCwd) return getProjectArtifactsDir(projectCwd);
36
+ if (sessionFile) {
37
+ const sessionDir = path.dirname(sessionFile);
38
+ return path.join(sessionDir, "subagent-artifacts");
39
+ }
40
+ return TEMP_ARTIFACTS_DIR;
41
+ default:
42
+ throw new Error(`Unsupported artifactDir ${JSON.stringify(dirPreference)}; expected "project", "session", or "temp".`);
25
43
  }
26
- return TEMP_ARTIFACTS_DIR;
27
44
  }
28
45
 
29
46
  export function getArtifactPaths(artifactsDir: string, runId: string, agent: string, index?: number): ArtifactPaths {
@@ -47,6 +64,19 @@ export function writeArtifact(filePath: string, content: string): void {
47
64
  fs.writeFileSync(filePath, content, "utf-8");
48
65
  }
49
66
 
67
+ export function formatOutputArtifactContent(input: {
68
+ output: string;
69
+ error?: string;
70
+ transcriptPath?: string;
71
+ metadataPath?: string;
72
+ }): string {
73
+ if (input.output.trim() || !input.error) return input.output;
74
+ const lines = ["Subagent run failed before producing output.", "", "Error:", input.error];
75
+ if (input.transcriptPath) lines.push("", `Transcript: ${input.transcriptPath}`);
76
+ if (input.metadataPath) lines.push(`Metadata: ${input.metadataPath}`);
77
+ return lines.join("\n");
78
+ }
79
+
50
80
  export function writeMetadata(filePath: string, metadata: object): void {
51
81
  fs.writeFileSync(filePath, JSON.stringify(metadata, null, 2), "utf-8");
52
82
  }
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS, runFileSystemOperationWithRetry, waitForFileSystemRetry } from "./file-system-retry.ts";
3
4
 
4
5
  type AtomicJsonFs = Pick<typeof fs, "mkdirSync" | "writeFileSync" | "renameSync" | "rmSync">;
5
6
 
@@ -10,38 +11,11 @@ type AtomicJsonWriterOptions = {
10
11
  random?: () => number;
11
12
  mode?: number;
12
13
  retryRenameErrors?: boolean;
14
+ retryDirectoryErrors?: boolean;
13
15
  retryDelaysMs?: readonly number[];
14
16
  wait?: (delayMs: number) => void;
15
17
  };
16
18
 
17
- const DEFAULT_RENAME_RETRY_DELAYS_MS = [10, 25, 50, 100, 200, 500, 1000, 2000, 4000] as const;
18
- const RETRYABLE_RENAME_ERROR_CODES = new Set(["EACCES", "EBUSY", "EPERM"]);
19
- const WAIT_BUFFER = typeof SharedArrayBuffer !== "undefined" ? new SharedArrayBuffer(4) : undefined;
20
- const WAIT_VIEW = WAIT_BUFFER ? new Int32Array(WAIT_BUFFER) : undefined;
21
-
22
- function waitSync(delayMs: number): void {
23
- if (delayMs <= 0) return;
24
- if (WAIT_VIEW) {
25
- try {
26
- // writeAtomicJson is synchronous because callers often update status from sync callbacks.
27
- // Atomics.wait gives Windows rename locks time to clear without burning CPU.
28
- Atomics.wait(WAIT_VIEW, 0, 0, delayMs);
29
- return;
30
- } catch {
31
- // Fall through to the portable busy wait below.
32
- }
33
- }
34
- const end = Date.now() + delayMs;
35
- while (Date.now() < end) {
36
- // Portable fallback for runtimes where Atomics.wait is unavailable.
37
- }
38
- }
39
-
40
- function isRetryableRenameError(error: unknown): boolean {
41
- const code = (error as NodeJS.ErrnoException | undefined)?.code;
42
- return typeof code === "string" && RETRYABLE_RENAME_ERROR_CODES.has(code);
43
- }
44
-
45
19
  function renameWithRetry(
46
20
  fsImpl: AtomicJsonFs,
47
21
  sourcePath: string,
@@ -49,16 +23,9 @@ function renameWithRetry(
49
23
  retryDelaysMs: readonly number[],
50
24
  wait: (delayMs: number) => void,
51
25
  ): void {
52
- for (let attempt = 0; ; attempt++) {
53
- try {
54
- fsImpl.renameSync(sourcePath, targetPath);
55
- return;
56
- } catch (error) {
57
- const delayMs = retryDelaysMs[attempt];
58
- if (delayMs === undefined || !isRetryableRenameError(error)) throw error;
59
- wait(delayMs);
60
- }
61
- }
26
+ runFileSystemOperationWithRetry(() => {
27
+ fsImpl.renameSync(sourcePath, targetPath);
28
+ }, { retryDelaysMs, wait });
62
29
  }
63
30
 
64
31
  export function createAtomicJsonWriter(options: AtomicJsonWriterOptions = {}): (filePath: string, payload: object) => void {
@@ -68,17 +35,22 @@ export function createAtomicJsonWriter(options: AtomicJsonWriterOptions = {}): (
68
35
  const random = options.random ?? Math.random;
69
36
  const mode = options.mode;
70
37
  const retryRenameErrors = options.retryRenameErrors ?? process.platform === "win32";
71
- const retryDelaysMs = retryRenameErrors ? options.retryDelaysMs ?? DEFAULT_RENAME_RETRY_DELAYS_MS : [];
72
- const wait = options.wait ?? waitSync;
38
+ const retryDirectoryErrors = options.retryDirectoryErrors ?? retryRenameErrors;
39
+ const retryDelaysMs = options.retryDelaysMs ?? DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS;
40
+ const renameRetryDelaysMs = retryRenameErrors ? retryDelaysMs : [];
41
+ const directoryRetryDelaysMs = retryDirectoryErrors ? retryDelaysMs : [];
42
+ const wait = options.wait ?? waitForFileSystemRetry;
73
43
  return (filePath: string, payload: object): void => {
74
- fsImpl.mkdirSync(path.dirname(filePath), { recursive: true });
44
+ runFileSystemOperationWithRetry(() => {
45
+ fsImpl.mkdirSync(path.dirname(filePath), { recursive: true });
46
+ }, { retryDelaysMs: directoryRetryDelaysMs, wait });
75
47
  const tempPath = path.join(
76
48
  path.dirname(filePath),
77
49
  `.${path.basename(filePath)}.${pid}.${now()}.${random().toString(36).slice(2)}.tmp`,
78
50
  );
79
51
  try {
80
52
  fsImpl.writeFileSync(tempPath, JSON.stringify(payload, null, 2), mode === undefined ? "utf-8" : { encoding: "utf-8", mode });
81
- renameWithRetry(fsImpl, tempPath, filePath, retryDelaysMs, wait);
53
+ renameWithRetry(fsImpl, tempPath, filePath, renameRetryDelaysMs, wait);
82
54
  } finally {
83
55
  fsImpl.rmSync(tempPath, { force: true });
84
56
  }