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.
- package/CHANGELOG.md +71 -0
- package/README.md +224 -31
- package/agents/advisor.md +73 -0
- package/agents/delegate.md +2 -0
- package/agents/worker.md +2 -0
- package/package.json +11 -13
- package/skills/pi-subagents/SKILL.md +42 -13
- package/src/agents/agent-management.ts +7 -1
- package/src/agents/agents.ts +121 -17
- package/src/api/capability-ceiling.ts +17 -0
- package/src/api/delegation.ts +127 -0
- package/src/api/preflight.ts +399 -0
- package/src/extension/config.ts +7 -1
- package/src/extension/index.ts +52 -38
- package/src/extension/rpc.ts +31 -2
- package/src/extension/schemas.ts +29 -3
- package/src/extension/tool-description.ts +4 -2
- package/src/intercom/intercom-bridge.ts +1 -1
- package/src/intercom/native-supervisor-channel.ts +45 -6
- package/src/intercom/result-intercom.ts +7 -0
- package/src/runs/background/async-execution.ts +158 -17
- package/src/runs/background/async-job-tracker.ts +4 -0
- package/src/runs/background/async-resume.ts +47 -8
- package/src/runs/background/async-status.ts +94 -3
- package/src/runs/background/chain-append.ts +2 -0
- package/src/runs/background/completion-batcher.ts +6 -4
- package/src/runs/background/completion-dedupe.ts +2 -11
- package/src/runs/background/fleet-view.ts +9 -4
- package/src/runs/background/notify.ts +132 -120
- package/src/runs/background/process-terminal.ts +280 -0
- package/src/runs/background/result-watcher.ts +138 -78
- package/src/runs/background/run-status.ts +10 -2
- package/src/runs/background/scheduled-runs.ts +6 -1
- package/src/runs/background/stale-run-reconciler.ts +6 -0
- package/src/runs/background/subagent-runner.ts +406 -54
- package/src/runs/background/subagent-wait.ts +130 -4
- package/src/runs/background/wait-tool.ts +2 -2
- package/src/runs/foreground/chain-execution.ts +181 -111
- package/src/runs/foreground/execution.ts +147 -47
- package/src/runs/foreground/foreground-control.ts +90 -0
- package/src/runs/foreground/subagent-executor.ts +426 -165
- package/src/runs/shared/acceptance.ts +94 -41
- package/src/runs/shared/agent-contract.ts +38 -0
- package/src/runs/shared/capability-ceiling.ts +177 -0
- package/src/runs/shared/child-protocol.ts +1 -1
- package/src/runs/shared/completion-guard.ts +36 -5
- package/src/runs/shared/context-mode.ts +44 -0
- package/src/runs/shared/dynamic-fanout.ts +5 -5
- package/src/runs/shared/long-running-guard.ts +4 -0
- package/src/runs/shared/mcp-direct-tool-allowlist.ts +12 -6
- package/src/runs/shared/nested-events.ts +35 -3
- package/src/runs/shared/parallel-handoff.ts +154 -0
- package/src/runs/shared/parallel-utils.ts +11 -0
- package/src/runs/shared/pi-args.ts +148 -56
- package/src/runs/shared/run-history.ts +90 -5
- package/src/runs/shared/session-lease.ts +25 -5
- package/src/runs/shared/structured-output.ts +112 -7
- package/src/runs/shared/subagent-control.ts +4 -0
- package/src/runs/shared/subagent-prompt-runtime.ts +31 -19
- package/src/runs/shared/task-intent.ts +10 -5
- package/src/runs/shared/tool-availability.ts +21 -3
- package/src/runs/shared/tool-budget.ts +11 -5
- package/src/runs/shared/turn-budget.ts +2 -1
- package/src/runs/shared/worktree.ts +63 -14
- package/src/shared/accessible-dir.ts +25 -0
- package/src/shared/artifacts.ts +37 -7
- package/src/shared/atomic-json.ts +14 -42
- package/src/shared/child-transcript.ts +52 -0
- package/src/shared/file-system-retry.ts +47 -0
- package/src/shared/launch-contract.ts +123 -0
- package/src/shared/settings.ts +9 -1
- package/src/shared/types.ts +314 -28
- package/src/shared/utils.ts +17 -42
- package/src/slash/delegation-adapters.ts +153 -6
- package/src/slash/delegation-json.ts +108 -0
- package/src/slash/delegation-request.ts +182 -36
- package/src/slash/prompt-template-bridge.ts +222 -37
- package/src/slash/selector.ts +147 -0
- package/src/slash/slash-commands.ts +15 -6
- package/src/slash/slash-live-state.ts +2 -2
- package/src/slash/subagents-admin.ts +42 -42
- package/src/tui/fleet-status.ts +362 -0
- package/src/tui/fleet-transcript.ts +472 -0
- package/src/tui/fleet.ts +318 -59
- package/src/tui/render.ts +17 -15
- package/src/watchdog/change-signature.ts +105 -12
- package/src/watchdog/review.ts +7 -2
- package/src/watchdog/runtime.ts +5 -3
- 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
|
-
|
|
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
|
|
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 (!
|
|
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 (
|
|
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 (
|
|
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
|
|
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(
|
|
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
|
-
|
|
17
|
-
|
|
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):
|
|
501
|
-
|
|
502
|
-
|
|
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 {
|
|
505
|
-
|
|
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
|
|
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):
|
|
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
|
-
|
|
578
|
-
|
|
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
|
+
}
|
package/src/shared/artifacts.ts
CHANGED
|
@@ -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(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
72
|
-
const
|
|
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
|
-
|
|
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,
|
|
53
|
+
renameWithRetry(fsImpl, tempPath, filePath, renameRetryDelaysMs, wait);
|
|
82
54
|
} finally {
|
|
83
55
|
fsImpl.rmSync(tempPath, { force: true });
|
|
84
56
|
}
|