pi-subagents 0.49.0 → 0.51.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 (117) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/agents/gpt-pro.md +17 -0
  3. package/agents/oracle.md +7 -5
  4. package/agents/researcher.md +1 -1
  5. package/agents/reviewer.md +2 -2
  6. package/agents/scout.md +1 -1
  7. package/agents/worker.md +1 -1
  8. package/async-retention-discovery-worker.mjs +180 -0
  9. package/docs/agents.md +37 -2
  10. package/docs/configuration.md +76 -14
  11. package/docs/extension-api.md +78 -1
  12. package/docs/missions.md +1 -1
  13. package/docs/observability.md +20 -4
  14. package/docs/tool-reference.md +55 -39
  15. package/docs/workflows.md +171 -5
  16. package/package.json +4 -2
  17. package/skills/pi-subagents/SKILL.md +5 -4
  18. package/skills/pi-subagents/references/constraints-and-recipes.md +9 -6
  19. package/skills/pi-subagents/references/execution-controls.md +22 -18
  20. package/skills/pi-subagents/references/management-authoring-rpc.md +5 -5
  21. package/skills/pi-subagents/references/prompting-and-roles.md +33 -17
  22. package/src/agents/agent-management.ts +100 -345
  23. package/src/agents/agent-serializer.ts +2 -0
  24. package/src/agents/agents.ts +135 -25
  25. package/src/api/external-job-provider.ts +185 -0
  26. package/src/api/external-runs.ts +174 -84
  27. package/src/api/preflight.ts +42 -11
  28. package/src/api/shared-types.ts +2 -0
  29. package/src/extension/config.ts +36 -3
  30. package/src/extension/doctor.ts +3 -6
  31. package/src/extension/fanout-child.ts +2 -2
  32. package/src/extension/index.ts +210 -90
  33. package/src/extension/public-execution.ts +31 -2
  34. package/src/extension/rpc.ts +5 -1
  35. package/src/extension/schemas.ts +14 -36
  36. package/src/extension/tool-description.ts +37 -24
  37. package/src/inspectors/herdr/actions.ts +5 -9
  38. package/src/inspectors/herdr/inspector-runner.ts +2 -1
  39. package/src/inspectors/herdr/project-panes.ts +4 -8
  40. package/src/inspectors/herdr/shell-command.ts +16 -0
  41. package/src/intercom/intercom-bridge.ts +2 -3
  42. package/src/intercom/native-supervisor-channel.ts +49 -51
  43. package/src/missions/goal-driver.ts +3 -1
  44. package/src/missions/lifecycle.ts +6 -1
  45. package/src/missions/store.ts +4 -9
  46. package/src/profiles/profiles.ts +3 -1
  47. package/src/runs/background/active-run-index.ts +94 -1
  48. package/src/runs/background/async-execution.ts +98 -38
  49. package/src/runs/background/async-job-tracker.ts +21 -4
  50. package/src/runs/background/async-resume.ts +30 -17
  51. package/src/runs/background/async-retention.ts +888 -0
  52. package/src/runs/background/async-status-snapshot.ts +277 -0
  53. package/src/runs/background/async-status.ts +47 -56
  54. package/src/runs/background/chain-append.ts +3 -33
  55. package/src/runs/background/chain-root-attachment.ts +2 -2
  56. package/src/runs/background/completion-replay.ts +11 -1
  57. package/src/runs/background/control-channel.ts +14 -68
  58. package/src/runs/background/fleet-view.ts +3 -1
  59. package/src/runs/background/index-segment.ts +59 -0
  60. package/src/runs/background/notify.ts +3 -1
  61. package/src/runs/background/result-files.ts +505 -0
  62. package/src/runs/background/result-watcher.ts +250 -51
  63. package/src/runs/background/retained-children.ts +79 -20
  64. package/src/runs/background/run-id-query.ts +7 -0
  65. package/src/runs/background/run-id-resolver.ts +37 -29
  66. package/src/runs/background/run-status.ts +34 -20
  67. package/src/runs/background/scheduled-runs.ts +71 -27
  68. package/src/runs/background/stale-run-reconciler.ts +33 -16
  69. package/src/runs/background/steering.ts +11 -1
  70. package/src/runs/background/subagent-runner.ts +535 -161
  71. package/src/runs/background/subagent-wait.ts +9 -7
  72. package/src/runs/background/terminal-run-index.ts +129 -0
  73. package/src/runs/background/wait-completions.ts +22 -2
  74. package/src/runs/background/wait-subscriptions.ts +80 -1
  75. package/src/runs/foreground/async-dismiss-action.ts +2 -1
  76. package/src/runs/foreground/async-steering-action.ts +21 -14
  77. package/src/runs/foreground/execution.ts +221 -15
  78. package/src/runs/foreground/subagent-executor.ts +529 -1519
  79. package/src/runs/foreground/workflow-foreground-steering.ts +6 -5
  80. package/src/runs/shared/chain-outputs.ts +1 -3
  81. package/src/runs/shared/completion-guard.ts +96 -6
  82. package/src/runs/shared/external-cli-runner.ts +4 -0
  83. package/src/runs/shared/external-job-bridge.ts +450 -0
  84. package/src/runs/shared/external-job-runner.ts +286 -0
  85. package/src/runs/shared/mcp-direct-tool-allowlist.ts +14 -0
  86. package/src/runs/shared/model-fallback.ts +34 -3
  87. package/src/runs/shared/nested-events.ts +66 -62
  88. package/src/runs/shared/orca-progress-tabs.ts +437 -0
  89. package/src/runs/shared/parallel-handoff.ts +46 -4
  90. package/src/runs/shared/parallel-utils.ts +6 -15
  91. package/src/runs/shared/permissions.ts +5 -1
  92. package/src/runs/shared/pi-args.ts +8 -1
  93. package/src/runs/shared/subagent-control.ts +41 -4
  94. package/src/runs/shared/subagent-prompt-runtime.ts +13 -15
  95. package/src/runs/shared/subagent-startup-retry.ts +12 -0
  96. package/src/runs/shared/tool-timeout.ts +93 -0
  97. package/src/runs/shared/workflow-graph.ts +1 -23
  98. package/src/runs/shared/worktree.ts +12 -1
  99. package/src/shared/atomic-json.ts +22 -2
  100. package/src/shared/capacity-resilient-json.ts +102 -0
  101. package/src/shared/completion-owner.ts +14 -0
  102. package/src/shared/file-system-retry.ts +49 -1
  103. package/src/shared/fork-context.ts +42 -0
  104. package/src/shared/prompt-resources.ts +0 -40
  105. package/src/shared/settings.ts +3 -27
  106. package/src/shared/types.ts +88 -27
  107. package/src/shared/utils.ts +8 -0
  108. package/src/shared/watch-strategy.ts +10 -0
  109. package/src/slash/slash-commands.ts +45 -28
  110. package/src/slash/slash-live-state.ts +3 -0
  111. package/src/tui/fleet-status.ts +160 -45
  112. package/src/tui/fleet.ts +186 -28
  113. package/src/tui/render.ts +41 -10
  114. package/src/workflows/chat-progress.ts +7 -5
  115. package/src/workflows/scripted-workflow.ts +424 -125
  116. package/src/runs/foreground/chain-clarify.ts +0 -1354
  117. package/src/runs/foreground/chain-execution.ts +0 -1565
@@ -6,6 +6,9 @@ import {
6
6
  type ControlNotificationChannel,
7
7
  type ResolvedControlConfig,
8
8
  } from "../../shared/types.ts";
9
+ import { isToolTimeoutExempt } from "./tool-timeout.ts";
10
+ import { createHash } from "node:crypto";
11
+ import { previewDisplayText } from "../../shared/display-text.ts";
9
12
 
10
13
  const CONTROL_EVENT_TYPES: ControlEventType[] = ["active_long_running", "needs_attention"];
11
14
  const CONTROL_NOTIFICATION_CHANNELS: ControlNotificationChannel[] = ["event", "async", "intercom"];
@@ -84,6 +87,18 @@ export function deriveActivityState(input: {
84
87
  return ageMs > input.config.needsAttentionAfterMs ? "needs_attention" : undefined;
85
88
  }
86
89
 
90
+ export function shouldEmitOpenToolAttention(input: {
91
+ config: ResolvedControlConfig;
92
+ currentTool?: string;
93
+ currentToolStartedAt?: number;
94
+ now?: number;
95
+ }): boolean {
96
+ if (!input.config.enabled || !input.currentTool || input.currentToolStartedAt === undefined) return false;
97
+ if (isToolTimeoutExempt(input.currentTool)) return false;
98
+ const now = input.now ?? Date.now();
99
+ return Math.max(0, now - input.currentToolStartedAt) >= input.config.activeNoticeAfterMs;
100
+ }
101
+
87
102
  export function buildControlEvent(input: {
88
103
  type?: ControlEventType;
89
104
  from?: ActivityState;
@@ -103,6 +118,10 @@ export function buildControlEvent(input: {
103
118
  currentPath?: string;
104
119
  elapsedMs?: number;
105
120
  recentFailureSummary?: string;
121
+ workflowKey?: string;
122
+ phase?: string;
123
+ label?: string;
124
+ taskPreview?: string;
106
125
  }): ControlEvent {
107
126
  const ts = input.ts ?? Date.now();
108
127
  const type = input.type ?? (input.to === "active_long_running" ? "active_long_running" : "needs_attention");
@@ -131,6 +150,10 @@ export function buildControlEvent(input: {
131
150
  ...(input.currentPath ? { currentPath: input.currentPath } : {}),
132
151
  ...(elapsedMs !== undefined ? { elapsedMs } : {}),
133
152
  ...(input.recentFailureSummary ? { recentFailureSummary: input.recentFailureSummary } : {}),
153
+ ...(input.workflowKey ? { workflowKey: input.workflowKey } : {}),
154
+ ...(input.phase ? { phase: input.phase } : {}),
155
+ ...(input.label ? { label: input.label } : {}),
156
+ ...(input.taskPreview ? { taskPreview: previewDisplayText(input.taskPreview, 160) } : {}),
134
157
  };
135
158
  }
136
159
 
@@ -140,7 +163,8 @@ export function shouldNotifyControlEvent(config: ResolvedControlConfig, event: C
140
163
 
141
164
  export function controlNotificationKey(event: ControlEvent, childIntercomTarget?: string): string {
142
165
  const childKey = childIntercomTarget ?? (event.index !== undefined ? `${event.runId}:${event.index}` : event.runId);
143
- return `${childKey}:${event.type}:${event.reason ?? "idle"}`;
166
+ const contextHash = createHash("sha256").update(formatControlNudge(event)).digest("hex").slice(0, 8);
167
+ return `${childKey}:${event.type}:${event.reason ?? "idle"}:${contextHash}`;
144
168
  }
145
169
 
146
170
  export function claimControlNotification(config: ResolvedControlConfig, event: ControlEvent, seenKeys: Set<string>, childIntercomTarget?: string): boolean {
@@ -162,6 +186,17 @@ function formatLongRunningFacts(event: ControlEvent): string | undefined {
162
186
  return facts.length > 0 ? facts.join(" | ") : undefined;
163
187
  }
164
188
 
189
+ export function formatControlNudge(event: ControlEvent): string {
190
+ const scope = event.label ?? event.phase ?? event.workflowKey ?? event.taskPreview;
191
+ if (event.recentFailureSummary) return previewDisplayText(`Resolve the recent failure${scope ? ` for ${scope}` : ""}: ${event.recentFailureSummary}. Report the smallest next step or ask for a decision.`, 160);
192
+ if (event.currentTool || event.currentPath) {
193
+ const current = [event.currentTool ? `tool ${event.currentTool}` : undefined, event.currentPath ? `path ${event.currentPath}` : undefined].filter(Boolean).join(" at ");
194
+ return previewDisplayText(`Check ${current}${scope ? ` for ${scope}` : ""}. Report the smallest next step or ask for a decision.`, 160);
195
+ }
196
+ if (scope) return previewDisplayText(`Continue ${scope}. Report the smallest next step or ask for a decision.`, 160);
197
+ return "What are you blocked on? Reply with the smallest next step or ask for a decision.";
198
+ }
199
+
165
200
  export function formatControlNoticeMessage(event: ControlEvent, childIntercomTarget?: string): string {
166
201
  const runTarget = event.runId;
167
202
  if (event.reason === "completion_guard") {
@@ -174,9 +209,9 @@ export function formatControlNoticeMessage(event: ControlEvent, childIntercomTar
174
209
  ].filter((line): line is string => Boolean(line)).join("\n");
175
210
  }
176
211
 
177
- const nudgeMessage = "What are you blocked on? Reply with the smallest next step or ask for a decision.";
178
- const steerCommand = `subagent({ action: "steer", id: "${runTarget}", ${event.index !== undefined ? `index: ${event.index}, ` : ""}message: "${nudgeMessage}" })`;
179
- const nestedResumeCommand = `subagent({ action: "resume", id: "${runTarget}", message: "${nudgeMessage}" })`;
212
+ const nudgeMessage = formatControlNudge(event);
213
+ const steerCommand = `subagent({ action: "steer", id: "${runTarget}", ${event.index !== undefined ? `index: ${event.index}, ` : ""}message: ${JSON.stringify(nudgeMessage)} })`;
214
+ const nestedResumeCommand = `subagent({ action: "resume", id: "${runTarget}", message: ${JSON.stringify(nudgeMessage)} })`;
180
215
  if (event.type === "active_long_running") {
181
216
  const facts = formatLongRunningFacts(event);
182
217
  return [
@@ -196,10 +231,12 @@ export function formatControlNoticeMessage(event: ControlEvent, childIntercomTar
196
231
  const supervisorHint = event.reason === "supervisor_request"
197
232
  ? "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
233
  : undefined;
234
+ const facts = formatLongRunningFacts(event);
199
235
  return [
200
236
  `Subagent needs attention: ${event.agent}`,
201
237
  `Run: ${runTarget}${event.index !== undefined ? ` step ${event.index + 1}` : ""}`,
202
238
  `Signal: ${event.message}`,
239
+ facts ? `Facts: ${facts}` : undefined,
203
240
  event.recentFailureSummary ? `Recent failures: ${event.recentFailureSummary}` : undefined,
204
241
  supervisorHint,
205
242
  "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.",
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import { registerNativeSupervisorClient } from "../../intercom/native-supervisor-channel.ts";
6
+ import { shouldUseNativeFsWatch } from "../../shared/watch-strategy.ts";
6
7
  import { decodePermissionRules, permissionDecision, PERMISSION_AUDIT_PATH_ENV, PERMISSION_POLICY_ENV } from "./permissions.ts";
7
8
  import { consumeSteerRequestsFromDir, MAX_STEER_QUEUE_SIZE, steerAckPathFromDir, writeSteerAckAt, writeSteerCapabilityAt, writeSteerRequestToDir, type SteerDeliveryStatus, type SteerRequest } from "../background/control-channel.ts";
8
9
  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";
@@ -337,6 +338,7 @@ export function registerSteeringInbox(
337
338
  nativeRealpath?: (filePath: string) => string;
338
339
  legacySettleFallbackMs?: number;
339
340
  safetyPollIntervalMs?: number;
341
+ platform?: NodeJS.Platform;
340
342
  timers?: Pick<typeof globalThis, "setInterval" | "clearInterval">;
341
343
  } = {},
342
344
  ): void {
@@ -449,13 +451,17 @@ export function registerSteeringInbox(
449
451
  safetyInterval = (deps.timers?.setInterval ?? setInterval)(flush, deps.safetyPollIntervalMs ?? STEERING_SAFETY_POLL_INTERVAL_MS) as NodeJS.Timeout;
450
452
  safetyInterval.unref?.();
451
453
  };
452
- try {
453
- watcher = (deps.watch ?? fs.watch)(resolveWatchPath(steerInbox, deps.nativeRealpath), () => flush());
454
- watcher.on("error", startPolling);
455
- startSafetyPolling();
456
- } catch {
457
- watcher = undefined;
454
+ if (!shouldUseNativeFsWatch("child-steering-inbox", deps.platform)) {
458
455
  startPolling();
456
+ } else {
457
+ try {
458
+ watcher = (deps.watch ?? fs.watch)(resolveWatchPath(steerInbox, deps.nativeRealpath), () => flush());
459
+ watcher.on("error", startPolling);
460
+ startSafetyPolling();
461
+ } catch {
462
+ watcher = undefined;
463
+ startPolling();
464
+ }
459
465
  }
460
466
  };
461
467
  const activate = (): undefined => {
@@ -580,16 +586,9 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
580
586
  } as unknown as SubagentState;
581
587
  if (typeof pi.registerTool === "function") registerWaitTool(pi, waitState, waitToolEnabled);
582
588
  let nativeSupervisorClientRegistered = false;
583
- let nativeSupervisorFallbackRegistered = false;
584
589
  const registerNativeSupervisorClientOnce = (): void => {
585
590
  if (nativeSupervisorClientRegistered) return;
586
591
  nativeSupervisorClientRegistered = true;
587
- registerNativeSupervisorClient(pi, { includeIntercomFallback: false });
588
- };
589
- const registerNativeSupervisorFallbackOnce = (): void => {
590
- registerNativeSupervisorClientOnce();
591
- if (nativeSupervisorFallbackRegistered) return;
592
- nativeSupervisorFallbackRegistered = true;
593
592
  registerNativeSupervisorClient(pi);
594
593
  };
595
594
  const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown, ctx?: ExtensionContext) => unknown) => void;
@@ -597,7 +596,6 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
597
596
  const sessionManager = (ctx as { sessionManager?: Parameters<typeof resolveCurrentSessionId>[0] } | undefined)?.sessionManager;
598
597
  waitState.currentSessionId = sessionManager ? resolveCurrentSessionId(sessionManager) : null;
599
598
  registerNativeSupervisorClientOnce();
600
- if (readRequiredChildTools()?.includes("intercom")) registerNativeSupervisorFallbackOnce();
601
599
  });
602
600
  onRuntimeEvent("agent_start", () => {
603
601
  refreshChildToolDiagnostic(pi);
@@ -650,7 +648,7 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
650
648
 
651
649
  onRuntimeEvent("before_agent_start", async (event: unknown) => {
652
650
  if (!event || typeof event !== "object" || !("systemPrompt" in event) || typeof event.systemPrompt !== "string") return undefined;
653
- registerNativeSupervisorFallbackOnce();
651
+ registerNativeSupervisorClientOnce();
654
652
  const intercomSessionName = process.env[SUBAGENT_INTERCOM_SESSION_NAME_ENV]?.trim();
655
653
  if (intercomSessionName && typeof pi.setSessionName === "function") {
656
654
  pi.setSessionName(intercomSessionName);
@@ -12,6 +12,7 @@ export const SUBAGENT_STARTUP_RETRY_DELAYS_MS = [250, 750, 1500] as const;
12
12
  export const MAX_SUBAGENT_STARTUP_FAILURE_DURATION_MS = 2000;
13
13
 
14
14
  const SIGKILL_PROCESS_SIGNAL_ERROR = formatProcessSignalError("SIGKILL");
15
+ const EXTENSION_REGISTRATION_CONFLICT_PATTERN = /Failed to load extension\b[^\n]*:\s*(?:Tool|Flag)\s+"[^"]+"\s+conflicts with\b/i;
15
16
 
16
17
  export interface SubagentStartupFailureEvidence {
17
18
  exitCode: number | null | undefined;
@@ -31,6 +32,17 @@ export interface SubagentStartupFailureEvidence {
31
32
  turnBudgetExceeded?: boolean;
32
33
  }
33
34
 
35
+ export function formatSubagentExtensionConflictError(
36
+ error: string | undefined,
37
+ input: { agent: string; ambientExtensionsEnabled: boolean },
38
+ ): string | undefined {
39
+ if (!error || !input.ambientExtensionsEnabled || !EXTENSION_REGISTRATION_CONFLICT_PATTERN.test(error)) return error;
40
+ const settings = JSON.stringify({
41
+ subagents: { agentOverrides: { [input.agent]: { extensions: [] } } },
42
+ });
43
+ return `${error}\n\nSubagent startup loaded conflicting ambient Pi extensions. Retry this run after disabling ambient extension discovery for agent ${JSON.stringify(input.agent)} in project .pi/settings.json: ${settings}. Replace the empty array with only the required extension entry paths when this agent needs extension tools.`;
44
+ }
45
+
34
46
  function hasUsage(usage: Usage): boolean {
35
47
  return usage.input !== 0
36
48
  || usage.output !== 0
@@ -0,0 +1,93 @@
1
+ export const TOOL_TIMEOUT_ENV = "PI_SUBAGENT_TOOL_TIMEOUT_MS";
2
+
3
+ /** Maximum delay a Node.js timer accepts without overflow. */
4
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
5
+
6
+ export const DEFAULT_FAST_TOOL_TIMEOUT_MS = 300_000;
7
+
8
+ export const DEFAULT_FAST_TOOL_TIMEOUT_TOOLS = new Set([
9
+ "read",
10
+ "grep",
11
+ "find",
12
+ "ls",
13
+ "edit",
14
+ "write",
15
+ "structured_output",
16
+ ]);
17
+
18
+ /** Tools whose normal job can be to wait for a person or another run. */
19
+ export const TOOL_TIMEOUT_EXEMPT_TOOLS = new Set(["contact_supervisor", "intercom", "subagent_wait"]);
20
+
21
+ // Backward-compatible export name for existing callers/tests.
22
+ export const TOOL_TIMEOUT_ALLOWLIST = TOOL_TIMEOUT_EXEMPT_TOOLS;
23
+
24
+ export function isToolTimeoutExempt(toolName: string | undefined): boolean {
25
+ return toolName !== undefined && TOOL_TIMEOUT_EXEMPT_TOOLS.has(toolName);
26
+ }
27
+
28
+ export function defaultToolTimeoutMs(toolName: string | undefined): number | undefined {
29
+ return toolName !== undefined && DEFAULT_FAST_TOOL_TIMEOUT_TOOLS.has(toolName)
30
+ ? DEFAULT_FAST_TOOL_TIMEOUT_MS
31
+ : undefined;
32
+ }
33
+
34
+ export function effectiveToolTimeoutMs(toolName: string | undefined, configuredToolTimeoutMs: number | undefined): number | undefined {
35
+ if (isToolTimeoutExempt(toolName)) return undefined;
36
+ return configuredToolTimeoutMs ?? defaultToolTimeoutMs(toolName);
37
+ }
38
+
39
+ export function formatToolTimeoutMessage(toolName: string, timeoutMs: number): string {
40
+ return `Tool '${toolName}' exceeded its timeout of ${timeoutMs}ms.`;
41
+ }
42
+
43
+ export function toolTimeoutCallKey(event: { toolCallId?: unknown; toolName?: unknown }, fallbackId: number): string {
44
+ return typeof event.toolCallId === "string" && event.toolCallId.length > 0
45
+ ? `id:${event.toolCallId}`
46
+ : `anon:${String(event.toolName ?? "tool")}:${fallbackId}`;
47
+ }
48
+
49
+ export interface ToolTimeoutResolutionInput {
50
+ /** Per-call value from the subagent tool params (highest precedence). */
51
+ callValue?: unknown;
52
+ /** Agent frontmatter default (second precedence). */
53
+ agentValue?: number;
54
+ /** Global extension config.toolTimeoutMs (third precedence). */
55
+ configValue?: unknown;
56
+ /** PI_SUBAGENT_TOOL_TIMEOUT_MS environment override (lowest precedence). */
57
+ envValue?: string | undefined;
58
+ }
59
+
60
+ /** Resolve the configured hard timeout. Default fast-tool timeouts apply later per tool name. */
61
+ export function resolveToolTimeoutMs(input: ToolTimeoutResolutionInput): { toolTimeoutMs?: number; error?: string } {
62
+ const candidates: Array<{ label: string; value: unknown }> = [
63
+ { label: "toolTimeoutMs", value: input.callValue },
64
+ { label: "agent.toolTimeoutMs", value: input.agentValue },
65
+ { label: "config.toolTimeoutMs", value: input.configValue },
66
+ ];
67
+ let winner: { label: string; value: unknown } | undefined;
68
+ for (const candidate of candidates) {
69
+ if (candidate.value === undefined) continue;
70
+ winner = candidate;
71
+ break;
72
+ }
73
+ if (winner === undefined && input.envValue !== undefined && input.envValue.trim() !== "") {
74
+ winner = { label: TOOL_TIMEOUT_ENV, value: input.envValue };
75
+ }
76
+ if (winner === undefined) return {};
77
+
78
+ let raw: unknown = winner.value;
79
+ let parsed: number;
80
+ if (winner.label === TOOL_TIMEOUT_ENV && typeof raw === "string") {
81
+ parsed = Number(raw);
82
+ if (raw.trim() !== "" && !Number.isNaN(parsed)) raw = parsed;
83
+ }
84
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw <= 0 || raw > MAX_TIMER_DELAY_MS) {
85
+ return { error: `${winner.label} must be a positive integer no larger than ${MAX_TIMER_DELAY_MS}.` };
86
+ }
87
+ return { toolTimeoutMs: raw };
88
+ }
89
+
90
+ /** Read the environment override without requiring callers to know the name. */
91
+ export function toolTimeoutFromEnv(env: NodeJS.ProcessEnv = process.env): string | undefined {
92
+ return env[TOOL_TIMEOUT_ENV];
93
+ }
@@ -1,4 +1,4 @@
1
- import { isCheckpointStep, isDynamicParallelStep, isParallelStep, type ChainStep, type SequentialStep } from "../../shared/settings.ts";
1
+ import { isDynamicParallelStep, isParallelStep, type ChainStep, type SequentialStep } from "../../shared/settings.ts";
2
2
  import type { SingleResult, SubagentRunMode, WorkflowGraphNode, WorkflowGraphSnapshot, WorkflowNodeStatus } from "../../shared/types.ts";
3
3
 
4
4
  export interface WorkflowGraphBuildInput {
@@ -81,28 +81,6 @@ export function buildWorkflowGraphSnapshot(input: WorkflowGraphBuildInput): Work
81
81
 
82
82
  for (let stepIndex = 0; stepIndex < input.steps.length; stepIndex++) {
83
83
  const step = input.steps[stepIndex]!;
84
- if (isCheckpointStep(step)) {
85
- const status = normalizeStatus(input.stepStatuses?.[flatIndex]?.status)
86
- ?? (input.currentStepIndex === stepIndex ? "paused" : "pending");
87
- const checkpoint = { name: step.checkpoint, ...(step.message ? { message: step.message } : {}), status: status === "rejected" ? "rejected" as const : status === "completed" ? "approved" as const : "pending" as const, stepIndex };
88
- const id = `step-${stepIndex}`;
89
- nodes.push({
90
- id,
91
- kind: "checkpoint",
92
- phase: step.phase,
93
- label: step.label?.trim() || step.checkpoint,
94
- status,
95
- flatIndex,
96
- stepIndex,
97
- checkpoint,
98
- error: input.stepStatuses?.[flatIndex]?.error,
99
- });
100
- pushPhase(phases, step.phase, id);
101
- if (status === "paused" || input.currentStepIndex === stepIndex) currentNodeId = id;
102
- flatIndex++;
103
- continue;
104
- }
105
-
106
84
  if (isParallelStep(step)) {
107
85
  const groupId = `step-${stepIndex}`;
108
86
  const children: WorkflowGraphNode[] = [];
@@ -46,7 +46,7 @@ export interface WorktreeCleanupTask {
46
46
  }
47
47
 
48
48
  export type WorktreeCleanupIntent =
49
- | { kind: "preserve"; capturedDiffs?: WorktreeDiff[]; handoffManifestPath?: string }
49
+ | { kind: "preserve"; capturedDiffs?: WorktreeDiff[]; handoffManifestPath?: string; cleanupBlocker?: string }
50
50
  | {
51
51
  kind: "discard";
52
52
  authorization:
@@ -571,6 +571,17 @@ function cleanupSingleWorktree(
571
571
  const errors: string[] = [];
572
572
  let worktreeRemoved = false;
573
573
  let branchRemoved = false;
574
+ if (intent.kind === "preserve" && intent.cleanupBlocker) {
575
+ return {
576
+ index: worktree.index,
577
+ path: worktree.path,
578
+ branch: worktree.branch,
579
+ worktreeRemoved: false,
580
+ branchRemoved: false,
581
+ preserved: true,
582
+ reason: intent.cleanupBlocker,
583
+ };
584
+ }
574
585
  if (intent.kind !== "setup-rollback") {
575
586
  try {
576
587
  removeSyntheticPathsBeforeDiff(worktree);
@@ -1,9 +1,12 @@
1
+ import { createHash } from "node:crypto";
1
2
  import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
4
  import { DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS, runFileSystemOperationWithRetry, waitForFileSystemRetry } from "./file-system-retry.ts";
4
5
 
5
6
  type AtomicJsonFs = Pick<typeof fs, "mkdirSync" | "writeFileSync" | "renameSync" | "rmSync">;
6
7
 
8
+ const MAX_PATH_COMPONENT_BYTES = 255;
9
+
7
10
  type AtomicJsonWriterOptions = {
8
11
  fs?: AtomicJsonFs;
9
12
  now?: () => number;
@@ -28,6 +31,13 @@ function renameWithRetry(
28
31
  }, { retryDelaysMs, wait });
29
32
  }
30
33
 
34
+ function tempBaseName(filePath: string, pid: number, nowMs: number, randomId: string): string {
35
+ const suffix = `.${pid}.${nowMs}.${randomId}.tmp`;
36
+ const preferred = `.${path.basename(filePath)}${suffix}`;
37
+ if (Buffer.byteLength(preferred, "utf-8") <= MAX_PATH_COMPONENT_BYTES) return preferred;
38
+ return `.${createHash("sha256").update(path.basename(filePath)).digest("hex")}${suffix}`;
39
+ }
40
+
31
41
  export function createAtomicJsonWriter(options: AtomicJsonWriterOptions = {}): (filePath: string, payload: object) => void {
32
42
  const fsImpl = options.fs ?? fs;
33
43
  const now = options.now ?? Date.now;
@@ -46,13 +56,23 @@ export function createAtomicJsonWriter(options: AtomicJsonWriterOptions = {}): (
46
56
  }, { retryDelaysMs: directoryRetryDelaysMs, wait });
47
57
  const tempPath = path.join(
48
58
  path.dirname(filePath),
49
- `.${path.basename(filePath)}.${pid}.${now()}.${random().toString(36).slice(2)}.tmp`,
59
+ tempBaseName(filePath, pid, now(), random().toString(36).slice(2)),
50
60
  );
61
+ let writeError: unknown;
51
62
  try {
52
63
  fsImpl.writeFileSync(tempPath, JSON.stringify(payload, null, 2), mode === undefined ? "utf-8" : { encoding: "utf-8", mode });
53
64
  renameWithRetry(fsImpl, tempPath, filePath, renameRetryDelaysMs, wait);
65
+ } catch (error) {
66
+ writeError = error;
67
+ throw error;
54
68
  } finally {
55
- fsImpl.rmSync(tempPath, { force: true });
69
+ try {
70
+ fsImpl.rmSync(tempPath, { force: true });
71
+ } catch (cleanupError) {
72
+ // Preserve the write/rename failure: cleanup is best effort and must
73
+ // not hide the error callers need to classify or report.
74
+ if (writeError === undefined) throw cleanupError;
75
+ }
56
76
  }
57
77
  };
58
78
  }
@@ -0,0 +1,102 @@
1
+ import { isStorageCapacityError } from "./file-system-retry.ts";
2
+ import { writeAtomicJson } from "./atomic-json.ts";
3
+
4
+ type TimerApi = {
5
+ setTimeout(handler: () => void, delayMs: number): unknown;
6
+ clearTimeout(handle: unknown): void;
7
+ };
8
+
9
+ const defaultTimerApi: TimerApi = {
10
+ setTimeout: (handler, delayMs) => setTimeout(handler, delayMs),
11
+ clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
12
+ };
13
+
14
+ export type CapacityResilientJsonWriterOptions = {
15
+ /** The underlying write operation. It must be atomic for each target path. */
16
+ write?: (filePath: string, payload: object) => void;
17
+ /** Keep retry timers referenced by the event loop (needed by child runners). */
18
+ keepAlive?: boolean;
19
+ retryDelayMs?: number;
20
+ timerApi?: TimerApi;
21
+ onError?: (error: unknown, filePath: string) => void;
22
+ onSuccess?: (filePath: string, payload: object) => void;
23
+ };
24
+
25
+ export type CapacityResilientJsonWriter = {
26
+ write(filePath: string, payload: object, writeOperation?: (filePath: string, payload: object) => void): void;
27
+ pendingCount(): number;
28
+ dispose(): void;
29
+ };
30
+
31
+ /**
32
+ * Defers only storage-capacity failures, retaining the newest payload per path.
33
+ * Initial/ordinary writes remain synchronous and preserve their throwing contract;
34
+ * only ENOSPC-like failures enter the guarded retry loop.
35
+ */
36
+ export function createCapacityResilientJsonWriter(options: CapacityResilientJsonWriterOptions = {}): CapacityResilientJsonWriter {
37
+ const defaultWrite = options.write ?? writeAtomicJson;
38
+ const keepAlive = options.keepAlive ?? false;
39
+ const retryDelayMs = options.retryDelayMs ?? 1_000;
40
+ const timerApi = options.timerApi ?? defaultTimerApi;
41
+ const onError = options.onError ?? ((error, filePath) => console.error(`Failed to persist JSON '${filePath}':`, error));
42
+ const onSuccess = options.onSuccess;
43
+ const reportError = (error: unknown, filePath: string): void => {
44
+ try {
45
+ onError(error, filePath);
46
+ } catch (callbackError) {
47
+ console.error(`Failed to report JSON persistence error for '${filePath}':`, callbackError);
48
+ }
49
+ };
50
+ const notifySuccess = (filePath: string, payload: object): void => {
51
+ if (!onSuccess) return;
52
+ try {
53
+ onSuccess(filePath, payload);
54
+ } catch (error) {
55
+ reportError(error, filePath);
56
+ }
57
+ };
58
+ const pending = new Map<string, { payload: object; write: (filePath: string, payload: object) => void }>();
59
+ let retryTimer: unknown;
60
+
61
+ const scheduleRetry = (): void => {
62
+ if (retryTimer !== undefined) return;
63
+ retryTimer = timerApi.setTimeout(() => {
64
+ retryTimer = undefined;
65
+ for (const [filePath, entry] of pending) {
66
+ try {
67
+ entry.write(filePath, entry.payload);
68
+ pending.delete(filePath);
69
+ notifySuccess(filePath, entry.payload);
70
+ } catch (error) {
71
+ if (isStorageCapacityError(error)) continue;
72
+ pending.delete(filePath);
73
+ reportError(error, filePath);
74
+ }
75
+ }
76
+ if (pending.size > 0) scheduleRetry();
77
+ }, retryDelayMs);
78
+ if (!keepAlive && typeof retryTimer === "object" && retryTimer !== null && "unref" in retryTimer && typeof retryTimer.unref === "function") {
79
+ retryTimer.unref();
80
+ }
81
+ };
82
+
83
+ return {
84
+ write(filePath, payload, writeOperation = defaultWrite): void {
85
+ try {
86
+ writeOperation(filePath, payload);
87
+ pending.delete(filePath);
88
+ notifySuccess(filePath, payload);
89
+ } catch (error) {
90
+ if (!isStorageCapacityError(error)) throw error;
91
+ pending.set(filePath, { payload, write: writeOperation });
92
+ scheduleRetry();
93
+ }
94
+ },
95
+ pendingCount: () => pending.size,
96
+ dispose: (): void => {
97
+ if (retryTimer !== undefined) timerApi.clearTimeout(retryTimer);
98
+ retryTimer = undefined;
99
+ pending.clear();
100
+ },
101
+ };
102
+ }
@@ -0,0 +1,14 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ const COMPLETION_OWNER_KEY = Symbol.for("pi-subagents.completion-owner-id");
4
+
5
+ type CompletionOwnerGlobal = typeof globalThis & {
6
+ [COMPLETION_OWNER_KEY]?: string;
7
+ };
8
+
9
+ /** Stable for one parent Pi process across extension reloads. */
10
+ export function currentCompletionOwnerId(): string {
11
+ const runtime = globalThis as CompletionOwnerGlobal;
12
+ runtime[COMPLETION_OWNER_KEY] ??= randomUUID();
13
+ return runtime[COMPLETION_OWNER_KEY];
14
+ }
@@ -1,8 +1,51 @@
1
1
  const WAIT_BUFFER = typeof SharedArrayBuffer !== "undefined" ? new SharedArrayBuffer(4) : undefined;
2
2
  const WAIT_VIEW = WAIT_BUFFER ? new Int32Array(WAIT_BUFFER) : undefined;
3
3
  const RETRYABLE_FILE_SYSTEM_ERROR_CODES = new Set(["EACCES", "EBUSY", "EPERM"]);
4
+ const STORAGE_CAPACITY_ERROR_CODES = new Set(["EDQUOT", "EMFILE", "ENFILE", "ENOSPC"]);
4
5
 
5
- export const DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS = [10, 25, 50, 100, 200, 500, 1000, 2000, 4000] as const;
6
+ export const FS_RETRY_MAX_TOTAL_MS_ENV = "PI_SUBAGENT_FS_RETRY_MAX_TOTAL_MS";
7
+
8
+ const BASE_FILE_SYSTEM_RETRY_DELAYS_MS = [10, 25, 50, 100, 200, 500, 1000, 2000, 4000] as const;
9
+
10
+ /**
11
+ * Clamp the retry ladder to a total sleep budget, preserving its length.
12
+ *
13
+ * waitForFileSystemRetry blocks the calling thread, so the ladder above is also
14
+ * a ceiling on how long a single contended rename can stall that thread: about
15
+ * 7.9s. That is fine for a short-lived CLI. A long-lived host that loads this
16
+ * extension in-process runs the same writers on its event loop, where an 8s
17
+ * stall stops it serving anything at all -- and because Atomics.wait parks
18
+ * rather than spins, it presents as an unresponsive process at 0% CPU.
19
+ *
20
+ * The length is preserved deliberately: run-fanout-budget.ts and
21
+ * workflow-state.ts index this array by attempt number and treat running off
22
+ * the end as "timed out acquiring lock". Shortening it would quietly shrink
23
+ * those attempt budgets too, so only the sleeps shrink here.
24
+ *
25
+ * Unset by default, so behaviour is unchanged unless a host opts in. Opting in
26
+ * trades lock-wait tolerance for responsiveness: entries clamped to 0 return
27
+ * immediately, so contention that would previously have been waited out is
28
+ * surfaced as an error sooner.
29
+ */
30
+ export function resolveFileSystemRetryDelays(
31
+ env: NodeJS.ProcessEnv = process.env,
32
+ base: readonly number[] = BASE_FILE_SYSTEM_RETRY_DELAYS_MS,
33
+ ): readonly number[] {
34
+ const raw = env[FS_RETRY_MAX_TOTAL_MS_ENV];
35
+ if (raw === undefined || raw.trim() === "") return base;
36
+ const budget = Number(raw);
37
+ if (!Number.isInteger(budget) || budget < 0) {
38
+ throw new Error(`${FS_RETRY_MAX_TOTAL_MS_ENV} must be a non-negative integer number of milliseconds.`);
39
+ }
40
+ let spent = 0;
41
+ return base.map((delayMs) => {
42
+ const allowed = Math.max(0, Math.min(delayMs, budget - spent));
43
+ spent += allowed;
44
+ return allowed;
45
+ });
46
+ }
47
+
48
+ export const DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS = resolveFileSystemRetryDelays();
6
49
 
7
50
  export type FileSystemRetryOptions = {
8
51
  retryDelaysMs?: readonly number[];
@@ -32,6 +75,11 @@ export function isRetryableFileSystemError(error: unknown): boolean {
32
75
  return typeof code === "string" && RETRYABLE_FILE_SYSTEM_ERROR_CODES.has(code);
33
76
  }
34
77
 
78
+ export function isStorageCapacityError(error: unknown): boolean {
79
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
80
+ return typeof code === "string" && STORAGE_CAPACITY_ERROR_CODES.has(code);
81
+ }
82
+
35
83
  export function runFileSystemOperationWithRetry<T>(operation: () => T, options: FileSystemRetryOptions = {}): T {
36
84
  const retryDelaysMs = options.retryDelaysMs ?? DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS;
37
85
  const wait = options.wait ?? waitForFileSystemRetry;
@@ -56,6 +56,48 @@ export function resolveSubagentContext(value: unknown): SubagentExecutionContext
56
56
  return value === "fork" ? "fork" : "fresh";
57
57
  }
58
58
 
59
+ export interface PreferredForkAvailability {
60
+ getSessionFile(): string | undefined;
61
+ getLeafId?: () => string | null;
62
+ }
63
+
64
+ export interface PreferredForkSnapshot {
65
+ parentSessionFile?: string | null;
66
+ leafId?: string | null;
67
+ }
68
+
69
+ export interface SubagentLaunchContextInput {
70
+ explicitContext?: SubagentExecutionContext;
71
+ agentDefaultContext?: SubagentExecutionContext;
72
+ defaultSubagentContext?: SubagentExecutionContext;
73
+ canUseImplicitFork: boolean;
74
+ }
75
+
76
+ /** Resolve the actual launch context from explicit, global, and agent preferences. */
77
+ export function resolveSubagentLaunchContext(input: SubagentLaunchContextInput): SubagentExecutionContext {
78
+ if (input.explicitContext !== undefined) return input.explicitContext;
79
+ const preferredContext = input.defaultSubagentContext ?? input.agentDefaultContext ?? "fresh";
80
+ return preferredContext === "fork" && input.canUseImplicitFork ? "fork" : "fresh";
81
+ }
82
+
83
+ /** True when an implicit `defaultContext: fork` can create a real branch now.
84
+ * Explicit `context: "fork"` stays strict and does not use this preference. */
85
+ export function canPreferFork(sessionManager: PreferredForkAvailability): boolean {
86
+ return canPreferForkFromSnapshot({
87
+ parentSessionFile: sessionManager.getSessionFile(),
88
+ leafId: sessionManager.getLeafId?.() ?? null,
89
+ });
90
+ }
91
+
92
+ export function canPreferForkFromSnapshot(input: PreferredForkSnapshot): boolean {
93
+ if (!input.parentSessionFile || !input.leafId) return false;
94
+ try {
95
+ return fs.existsSync(input.parentSessionFile);
96
+ } catch {
97
+ return false;
98
+ }
99
+ }
100
+
59
101
  /** Decide whether a resolved child model uses Anthropic's provider or message API, which
60
102
  * requires the sanitized fork to disable thinking. Unknown models stay conservative. */
61
103
  export function forkedChildRequiresThinkingOff(