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.
- package/CHANGELOG.md +97 -0
- package/agents/gpt-pro.md +17 -0
- package/agents/oracle.md +7 -5
- package/agents/researcher.md +1 -1
- package/agents/reviewer.md +2 -2
- package/agents/scout.md +1 -1
- package/agents/worker.md +1 -1
- package/async-retention-discovery-worker.mjs +180 -0
- package/docs/agents.md +37 -2
- package/docs/configuration.md +76 -14
- package/docs/extension-api.md +78 -1
- package/docs/missions.md +1 -1
- package/docs/observability.md +20 -4
- package/docs/tool-reference.md +55 -39
- package/docs/workflows.md +171 -5
- package/package.json +4 -2
- package/skills/pi-subagents/SKILL.md +5 -4
- package/skills/pi-subagents/references/constraints-and-recipes.md +9 -6
- package/skills/pi-subagents/references/execution-controls.md +22 -18
- package/skills/pi-subagents/references/management-authoring-rpc.md +5 -5
- package/skills/pi-subagents/references/prompting-and-roles.md +33 -17
- package/src/agents/agent-management.ts +100 -345
- package/src/agents/agent-serializer.ts +2 -0
- package/src/agents/agents.ts +135 -25
- package/src/api/external-job-provider.ts +185 -0
- package/src/api/external-runs.ts +174 -84
- package/src/api/preflight.ts +42 -11
- package/src/api/shared-types.ts +2 -0
- package/src/extension/config.ts +36 -3
- package/src/extension/doctor.ts +3 -6
- package/src/extension/fanout-child.ts +2 -2
- package/src/extension/index.ts +210 -90
- package/src/extension/public-execution.ts +31 -2
- package/src/extension/rpc.ts +5 -1
- package/src/extension/schemas.ts +14 -36
- package/src/extension/tool-description.ts +37 -24
- package/src/inspectors/herdr/actions.ts +5 -9
- package/src/inspectors/herdr/inspector-runner.ts +2 -1
- package/src/inspectors/herdr/project-panes.ts +4 -8
- package/src/inspectors/herdr/shell-command.ts +16 -0
- package/src/intercom/intercom-bridge.ts +2 -3
- package/src/intercom/native-supervisor-channel.ts +49 -51
- package/src/missions/goal-driver.ts +3 -1
- package/src/missions/lifecycle.ts +6 -1
- package/src/missions/store.ts +4 -9
- package/src/profiles/profiles.ts +3 -1
- package/src/runs/background/active-run-index.ts +94 -1
- package/src/runs/background/async-execution.ts +98 -38
- package/src/runs/background/async-job-tracker.ts +21 -4
- package/src/runs/background/async-resume.ts +30 -17
- package/src/runs/background/async-retention.ts +888 -0
- package/src/runs/background/async-status-snapshot.ts +277 -0
- package/src/runs/background/async-status.ts +47 -56
- package/src/runs/background/chain-append.ts +3 -33
- package/src/runs/background/chain-root-attachment.ts +2 -2
- package/src/runs/background/completion-replay.ts +11 -1
- package/src/runs/background/control-channel.ts +14 -68
- package/src/runs/background/fleet-view.ts +3 -1
- package/src/runs/background/index-segment.ts +59 -0
- package/src/runs/background/notify.ts +3 -1
- package/src/runs/background/result-files.ts +505 -0
- package/src/runs/background/result-watcher.ts +250 -51
- package/src/runs/background/retained-children.ts +79 -20
- package/src/runs/background/run-id-query.ts +7 -0
- package/src/runs/background/run-id-resolver.ts +37 -29
- package/src/runs/background/run-status.ts +34 -20
- package/src/runs/background/scheduled-runs.ts +71 -27
- package/src/runs/background/stale-run-reconciler.ts +33 -16
- package/src/runs/background/steering.ts +11 -1
- package/src/runs/background/subagent-runner.ts +535 -161
- package/src/runs/background/subagent-wait.ts +9 -7
- package/src/runs/background/terminal-run-index.ts +129 -0
- package/src/runs/background/wait-completions.ts +22 -2
- package/src/runs/background/wait-subscriptions.ts +80 -1
- package/src/runs/foreground/async-dismiss-action.ts +2 -1
- package/src/runs/foreground/async-steering-action.ts +21 -14
- package/src/runs/foreground/execution.ts +221 -15
- package/src/runs/foreground/subagent-executor.ts +529 -1519
- package/src/runs/foreground/workflow-foreground-steering.ts +6 -5
- package/src/runs/shared/chain-outputs.ts +1 -3
- package/src/runs/shared/completion-guard.ts +96 -6
- package/src/runs/shared/external-cli-runner.ts +4 -0
- package/src/runs/shared/external-job-bridge.ts +450 -0
- package/src/runs/shared/external-job-runner.ts +286 -0
- package/src/runs/shared/mcp-direct-tool-allowlist.ts +14 -0
- package/src/runs/shared/model-fallback.ts +34 -3
- package/src/runs/shared/nested-events.ts +66 -62
- package/src/runs/shared/orca-progress-tabs.ts +437 -0
- package/src/runs/shared/parallel-handoff.ts +46 -4
- package/src/runs/shared/parallel-utils.ts +6 -15
- package/src/runs/shared/permissions.ts +5 -1
- package/src/runs/shared/pi-args.ts +8 -1
- package/src/runs/shared/subagent-control.ts +41 -4
- package/src/runs/shared/subagent-prompt-runtime.ts +13 -15
- package/src/runs/shared/subagent-startup-retry.ts +12 -0
- package/src/runs/shared/tool-timeout.ts +93 -0
- package/src/runs/shared/workflow-graph.ts +1 -23
- package/src/runs/shared/worktree.ts +12 -1
- package/src/shared/atomic-json.ts +22 -2
- package/src/shared/capacity-resilient-json.ts +102 -0
- package/src/shared/completion-owner.ts +14 -0
- package/src/shared/file-system-retry.ts +49 -1
- package/src/shared/fork-context.ts +42 -0
- package/src/shared/prompt-resources.ts +0 -40
- package/src/shared/settings.ts +3 -27
- package/src/shared/types.ts +88 -27
- package/src/shared/utils.ts +8 -0
- package/src/shared/watch-strategy.ts +10 -0
- package/src/slash/slash-commands.ts +45 -28
- package/src/slash/slash-live-state.ts +3 -0
- package/src/tui/fleet-status.ts +160 -45
- package/src/tui/fleet.ts +186 -28
- package/src/tui/render.ts +41 -10
- package/src/workflows/chat-progress.ts +7 -5
- package/src/workflows/scripted-workflow.ts +424 -125
- package/src/runs/foreground/chain-clarify.ts +0 -1354
- 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
|
-
|
|
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 =
|
|
178
|
-
const steerCommand = `subagent({ action: "steer", id: "${runTarget}", ${event.index !== undefined ? `index: ${event.index}, ` : ""}message:
|
|
179
|
-
const nestedResumeCommand = `subagent({ action: "resume", id: "${runTarget}", message:
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|