pi-subagents 0.64.0 → 0.65.1
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 +65 -0
- package/README.md +2 -2
- package/agents/reviewer.md +1 -1
- package/agents/scout.md +1 -1
- package/docs/agents.md +19 -17
- package/docs/configuration.md +23 -15
- package/docs/extension-api.md +18 -6
- package/docs/missions.md +2 -0
- package/docs/observability.md +10 -12
- package/docs/tool-reference.md +15 -8
- package/docs/watchdog.md +1 -1
- package/docs/workflows.md +13 -3
- package/package.json +5 -4
- package/runner-server-preload.mjs +13 -0
- package/skills/pi-subagents/SKILL.md +2 -1
- package/skills/pi-subagents/references/constraints-and-recipes.md +1 -1
- package/skills/pi-subagents/references/execution-controls.md +7 -3
- package/skills/pi-subagents/references/multi-lane-orchestration.md +2 -0
- package/src/agents/agent-management.ts +47 -15
- package/src/api/capability-ceiling.ts +0 -1
- package/src/api/{pi-args.ts → child-tool-plan.ts} +1 -1
- package/src/api/preflight.ts +7 -4
- package/src/extension/config.ts +4 -2
- package/src/extension/doctor.ts +2 -10
- package/src/extension/fanout-child.ts +9 -11
- package/src/extension/index.ts +58 -7
- package/src/extension/public-execution.ts +14 -0
- package/src/extension/rpc.ts +3 -2
- package/src/extension/schemas.ts +3 -2
- package/src/extension/tool-description.ts +13 -8
- package/src/integrations/pi-web-session-liveness.ts +73 -0
- package/src/intercom/native-supervisor-channel.ts +159 -95
- package/src/intercom/supervisor-ui.ts +244 -0
- package/src/missions/workflow-state.ts +37 -16
- package/src/runs/background/async-execution.ts +51 -25
- package/src/runs/background/async-job-tracker.ts +11 -0
- package/src/runs/background/async-resume.ts +14 -3
- package/src/runs/background/async-retention.ts +9 -0
- package/src/runs/background/control-channel.ts +2 -204
- package/src/runs/background/notify.ts +2 -0
- package/src/runs/background/process-terminal.ts +1 -1
- package/src/runs/background/retained-nested-route-tracker.ts +96 -0
- package/src/runs/background/run-child-session.ts +614 -0
- package/src/runs/background/run-status.ts +0 -1
- package/src/runs/background/runner-aliases.ts +152 -0
- package/src/runs/background/runner-child-sessions.ts +31 -0
- package/src/runs/background/scheduled-runs.ts +18 -4
- package/src/runs/background/subagent-runner.ts +203 -901
- package/src/runs/foreground/async-steering-action.ts +1 -17
- package/src/runs/foreground/execution.ts +204 -378
- package/src/runs/foreground/foreground-control.ts +4 -0
- package/src/runs/foreground/foreground-history.ts +3 -1
- package/src/runs/foreground/prompt-audit.ts +9 -5
- package/src/runs/foreground/subagent-executor.ts +161 -91
- package/src/runs/foreground/workflow-foreground-steering.ts +24 -98
- package/src/runs/shared/abort-recovery.ts +3 -3
- package/src/runs/shared/acceptance.ts +14 -1
- package/src/runs/shared/capability-ceiling.ts +1 -2
- package/src/runs/shared/child-hooks.ts +25 -0
- package/src/runs/shared/child-identity.ts +13 -2
- package/src/runs/shared/child-launch.ts +314 -0
- package/src/runs/shared/child-lifecycle.ts +25 -0
- package/src/runs/shared/child-runtime-config.ts +126 -0
- package/src/runs/shared/child-session.ts +337 -0
- package/src/runs/shared/child-tool-plan.ts +530 -0
- package/src/runs/shared/claude-code-adapter.ts +5 -1
- package/src/runs/shared/completion-guard.ts +1 -1
- package/src/runs/shared/external-cli-preflight.ts +16 -0
- package/src/runs/shared/llm-intent-arbiter.ts +20 -11
- package/src/runs/shared/mcp-direct-tool-allowlist.ts +5 -4
- package/src/runs/shared/model-exclusions.ts +84 -15
- package/src/runs/shared/model-fallback.ts +78 -6
- package/src/runs/shared/nested-events.ts +32 -48
- package/src/runs/shared/nested-path.ts +0 -14
- package/src/runs/shared/orca-progress-tabs.ts +12 -7
- package/src/runs/shared/parallel-utils.ts +1 -2
- package/src/runs/shared/permissions.ts +0 -13
- package/src/runs/shared/process-signal.ts +4 -1
- package/src/runs/shared/run-fanout-budget.ts +0 -13
- package/src/runs/shared/runtime-acknowledged-extensions.ts +0 -27
- package/src/runs/shared/structured-output.ts +17 -4
- package/src/runs/shared/subagent-control.ts +2 -0
- package/src/runs/shared/subagent-prompt-runtime.ts +87 -384
- package/src/runs/shared/tool-availability.ts +18 -62
- package/src/runs/shared/tool-budget.ts +0 -14
- package/src/runs/shared/worktree-cleanup-plan.ts +31 -9
- package/src/runs/shared/worktree.ts +192 -40
- package/src/shared/child-session-name.ts +1 -1
- package/src/shared/jsonl-writer.ts +11 -0
- package/src/shared/model-response-aliases.ts +13 -0
- package/src/shared/thinking-ceiling.ts +0 -6
- package/src/shared/types.ts +63 -28
- package/src/shared/utils.ts +6 -4
- package/src/shared/watch-strategy.ts +2 -0
- package/src/slash/slash-commands.ts +0 -6
- package/src/tui/fleet-status.ts +1 -1
- package/src/tui/fleet.ts +0 -1
- package/src/tui/render.ts +254 -41
- package/src/watchdog/child-status.ts +0 -1
- package/src/watchdog/register-child.ts +12 -12
- package/src/workflows/scripted-workflow.ts +80 -7
- package/src/workflows/workflow-checklist.ts +4 -3
- package/src/runs/shared/child-protocol.ts +0 -415
- package/src/runs/shared/pi-args.ts +0 -1059
- package/src/runs/shared/subagent-startup-retry.ts +0 -116
- package/src/shared/post-exit-stdio-guard.ts +0 -85
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
import * as fs from "node:fs";
|
|
2
|
-
import * as path from "node:path";
|
|
3
|
-
|
|
4
|
-
export const REQUIRED_CHILD_TOOLS_ENV = "PI_SUBAGENT_REQUIRED_TOOLS";
|
|
5
|
-
export const MCP_DIRECT_CHILD_TOOLS_ENV = "PI_SUBAGENT_MCP_DIRECT_TOOLS";
|
|
6
|
-
export const CHILD_TOOL_DIAGNOSTIC_PATH_ENV = "PI_SUBAGENT_TOOL_DIAGNOSTIC_PATH";
|
|
7
|
-
|
|
8
1
|
export interface ChildToolDiagnostic {
|
|
9
2
|
agent?: string;
|
|
10
3
|
required: string[];
|
|
@@ -13,53 +6,25 @@ export interface ChildToolDiagnostic {
|
|
|
13
6
|
missingMcpDirectTools?: string[];
|
|
14
7
|
}
|
|
15
8
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const availableNames = new Set(available);
|
|
24
|
-
const missing = required.filter((name) => !availableNames.has(name));
|
|
25
|
-
if (missing.length === 0) {
|
|
26
|
-
fs.rmSync(filePath, { force: true });
|
|
27
|
-
return undefined;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const missingMcpDirectTools = mcpDirectTools?.length
|
|
31
|
-
? missing.filter((name) => mcpDirectTools.includes(name))
|
|
32
|
-
: [];
|
|
33
|
-
const diagnostic: ChildToolDiagnostic = {
|
|
34
|
-
agent,
|
|
35
|
-
required,
|
|
36
|
-
available,
|
|
37
|
-
missing,
|
|
38
|
-
...(missingMcpDirectTools.length > 0 ? { missingMcpDirectTools } : {}),
|
|
39
|
-
};
|
|
40
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
41
|
-
fs.writeFileSync(filePath, JSON.stringify(diagnostic), { mode: 0o600 });
|
|
42
|
-
return diagnostic;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function readChildToolDiagnostic(filePath: string | undefined): ChildToolDiagnostic | undefined {
|
|
46
|
-
if (!filePath || !fs.existsSync(filePath)) return undefined;
|
|
47
|
-
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as Partial<ChildToolDiagnostic>;
|
|
48
|
-
const stringArray = (value: unknown): value is string[] => Array.isArray(value) && value.every((entry) => typeof entry === "string" && entry.length > 0);
|
|
49
|
-
if (!stringArray(parsed.required) || !stringArray(parsed.available) || !stringArray(parsed.missing) || (parsed.agent !== undefined && typeof parsed.agent !== "string") || (parsed.missingMcpDirectTools !== undefined && !stringArray(parsed.missingMcpDirectTools))) {
|
|
50
|
-
throw new Error(`Malformed child tool diagnostic at '${filePath}'.`);
|
|
51
|
-
}
|
|
52
|
-
return {
|
|
53
|
-
...(parsed.agent ? { agent: parsed.agent } : {}),
|
|
54
|
-
required: parsed.required,
|
|
55
|
-
available: parsed.available,
|
|
56
|
-
missing: parsed.missing,
|
|
57
|
-
...(parsed.missingMcpDirectTools ? { missingMcpDirectTools: parsed.missingMcpDirectTools } : {}),
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function formatChildToolDiagnostic(diagnostic: ChildToolDiagnostic): string {
|
|
9
|
+
/**
|
|
10
|
+
* Explain missing child tools. Foreground children run inside the parent
|
|
11
|
+
* process and never load the parent's ambient extensions, so tools an ambient
|
|
12
|
+
* extension registers (MCP tools, provider tools) only exist for background
|
|
13
|
+
* children; the diagnostic says so instead of reporting a generic gap.
|
|
14
|
+
*/
|
|
15
|
+
export function formatChildToolDiagnostic(diagnostic: ChildToolDiagnostic, options: { host?: "parent" | "runner" } = {}): string {
|
|
62
16
|
const subject = diagnostic.agent ? `Agent '${diagnostic.agent}'` : "Subagent";
|
|
17
|
+
if (options.host === "parent") {
|
|
18
|
+
return [
|
|
19
|
+
`${subject} ran as a foreground child, which never loads the parent's ambient extensions, and these child tools were unavailable: ${diagnostic.missing.join(", ")}.`,
|
|
20
|
+
"The `tools` field is a strict allowlist; it does not load extension code.",
|
|
21
|
+
...(diagnostic.missingMcpDirectTools?.length
|
|
22
|
+
? [`MCP direct tools missing from the child registry: ${diagnostic.missingMcpDirectTools.join(", ")}.`]
|
|
23
|
+
: []),
|
|
24
|
+
"Agents that need MCP tools (`mcpDirectTools`, or MCP tools from an ambient adapter such as pi-mcp-adapter) or models from a provider extension must run as background children (`async: true`), which load the ambient extensions.",
|
|
25
|
+
"For extension tools a foreground child can load, 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`.",
|
|
26
|
+
].join("\n");
|
|
27
|
+
}
|
|
63
28
|
return [
|
|
64
29
|
`${subject} requested unavailable child tools: ${diagnostic.missing.join(", ")}.`,
|
|
65
30
|
"The `tools` field is a strict allowlist; it does not load extension code.",
|
|
@@ -70,12 +35,3 @@ export function formatChildToolDiagnostic(diagnostic: ChildToolDiagnostic): stri
|
|
|
70
35
|
"For MCP tools, verify the MCP adapter configuration and selected tool names. For builtin tools, verify the name against the installed Pi version.",
|
|
71
36
|
].join("\n");
|
|
72
37
|
}
|
|
73
|
-
|
|
74
|
-
export function readChildToolDiagnosticError(filePath: string | undefined): string | undefined {
|
|
75
|
-
try {
|
|
76
|
-
const diagnostic = readChildToolDiagnostic(filePath);
|
|
77
|
-
return diagnostic ? formatChildToolDiagnostic(diagnostic) : undefined;
|
|
78
|
-
} catch (error) {
|
|
79
|
-
return `Failed to read child tool availability diagnostic: ${error instanceof Error ? error.message : String(error)}`;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import type { ResolvedToolBudget, ToolBudgetConfig, ToolBudgetState } from "../../shared/types.ts";
|
|
2
2
|
|
|
3
3
|
export const DEFAULT_TOOL_BUDGET_BLOCK = ["read", "grep", "find", "ls"] as const;
|
|
4
|
-
export const TOOL_BUDGET_ENV = "PI_SUBAGENT_TOOL_BUDGET";
|
|
5
|
-
export const TOOL_BUDGET_ZERO_AUTH_ENV = "PI_SUBAGENT_TOOL_BUDGET_ZERO_AUTH";
|
|
6
4
|
|
|
7
5
|
export function normalizeToolBudgetBlock(block: ToolBudgetConfig["block"] | undefined): "*" | string[] {
|
|
8
6
|
if (block === "*") return "*";
|
|
@@ -66,15 +64,3 @@ export function toolBudgetSoftNudge(budget: ResolvedToolBudget, toolCount: numbe
|
|
|
66
64
|
export function toolBudgetBlockedMessage(budget: ResolvedToolBudget, toolName: string, toolCount: number): string {
|
|
67
65
|
return `Tool budget hard limit reached after ${toolCount} tool call${toolCount === 1 ? "" : "s"} (hard ${budget.hard}). The '${toolName}' tool is blocked so you can finalize from the context you already have.`;
|
|
68
66
|
}
|
|
69
|
-
|
|
70
|
-
export function encodeToolBudgetEnv(budget: ResolvedToolBudget | undefined): string | undefined {
|
|
71
|
-
return budget ? JSON.stringify(budget) : undefined;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function decodeToolBudgetEnv(value: string | undefined, options: { allowZero?: boolean } = {}): ResolvedToolBudget | undefined {
|
|
75
|
-
if (!value?.trim()) return undefined;
|
|
76
|
-
const parsed = JSON.parse(value) as unknown;
|
|
77
|
-
const normalized = validateToolBudgetConfig(parsed, TOOL_BUDGET_ENV, options.allowZero ? { minimumHard: 0 } : undefined);
|
|
78
|
-
if (normalized.error) throw new Error(normalized.error);
|
|
79
|
-
return normalized.budget;
|
|
80
|
-
}
|
|
@@ -13,7 +13,9 @@ import type {
|
|
|
13
13
|
ParallelHandoffGroup,
|
|
14
14
|
ParallelHandoffManifest,
|
|
15
15
|
} from "../../shared/types.ts";
|
|
16
|
+
import { getAgentDir } from "../../shared/utils.ts";
|
|
16
17
|
import { isTerminalParallelHandoffChildStatus } from "./parallel-handoff.ts";
|
|
18
|
+
import { MACHINE_DIFF_OPTIONS, validateWorktreePatchRepresentsCurrentWorktree } from "./worktree.ts";
|
|
17
19
|
|
|
18
20
|
export const WORKTREE_CLEANUP_PLAN_VERSION = 1 as const;
|
|
19
21
|
export const WORKTREE_CLEANUP_PLAN_TTL_MS = 30 * 60 * 1000;
|
|
@@ -244,11 +246,24 @@ function resolveRepoRoot(repo: string): string {
|
|
|
244
246
|
}
|
|
245
247
|
|
|
246
248
|
function resolveCleanupBaseDir(repoRoot: string, configuredBaseDir: string | undefined): string {
|
|
247
|
-
const raw = configuredBaseDir ?? process.env.PI_SUBAGENTS_WORKTREE_DIR
|
|
248
|
-
|
|
249
|
-
if (
|
|
250
|
-
|
|
251
|
-
|
|
249
|
+
const raw = configuredBaseDir ?? process.env.PI_SUBAGENTS_WORKTREE_DIR;
|
|
250
|
+
let dedicatedRoot: string;
|
|
251
|
+
if (raw === undefined || (configuredBaseDir === undefined && !raw.trim())) {
|
|
252
|
+
dedicatedRoot = path.join(path.dirname(repoRoot), "worktrees");
|
|
253
|
+
} else {
|
|
254
|
+
const trimmed = raw.trim();
|
|
255
|
+
if (!trimmed) throw new Error("worktree base directory cannot be empty");
|
|
256
|
+
const expanded = trimmed.startsWith("~/") ? path.join(os.homedir(), trimmed.slice(2)) : trimmed;
|
|
257
|
+
dedicatedRoot = path.isAbsolute(expanded) ? expanded : path.resolve(repoRoot, expanded);
|
|
258
|
+
}
|
|
259
|
+
return path.resolve(path.join(dedicatedRoot, path.basename(repoRoot)));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function cleanupContainmentInvalid(repoRoot: string, projectDir: string): boolean {
|
|
263
|
+
const extensionsDir = path.join(getAgentDir(), "extensions");
|
|
264
|
+
return samePath(projectDir, repoRoot)
|
|
265
|
+
|| pathInside(repoRoot, projectDir, true)
|
|
266
|
+
|| pathInside(extensionsDir, projectDir);
|
|
252
267
|
}
|
|
253
268
|
|
|
254
269
|
export function parseGitWorktreeList(raw: string): GitWorktreeRecord[] {
|
|
@@ -558,7 +573,7 @@ function resolveBranchTip(repoRoot: string, branch: string): { value?: string; e
|
|
|
558
573
|
return { value: result.stdout.trim() };
|
|
559
574
|
}
|
|
560
575
|
|
|
561
|
-
function isPatchCaptured(record: ManifestMetadataRecord, worktreePath: string): { path?: string; error?: string } {
|
|
576
|
+
function isPatchCaptured(record: ManifestMetadataRecord, worktreePath: string, baseCommit: string): { path?: string; error?: string } {
|
|
562
577
|
const patchPath = metadataPatchPath(record);
|
|
563
578
|
if (!patchPath || record.child.patch?.error !== undefined || record.child.patch?.changed !== true) return {};
|
|
564
579
|
const inspection = inspectPath(patchPath);
|
|
@@ -573,12 +588,15 @@ function isPatchCaptured(record: ManifestMetadataRecord, worktreePath: string):
|
|
|
573
588
|
if (!patchStat.isFile()) return { error: "captured handoff patch is not a file" };
|
|
574
589
|
if (pathInside(worktreePath, inspection.realpath)) return { error: "durable handoff patch lives inside the worktree" };
|
|
575
590
|
if (patchStat.size <= 0) return { error: "captured handoff patch is empty" };
|
|
591
|
+
const validationError = validateWorktreePatchRepresentsCurrentWorktree(worktreePath, baseCommit, patchPath);
|
|
592
|
+
if (validationError) return { error: `captured handoff patch failed validation: ${validationError}` };
|
|
576
593
|
return { path: patchPath };
|
|
577
594
|
}
|
|
578
595
|
|
|
579
596
|
function buildManagedEntry(input: {
|
|
580
597
|
repoRoot: string;
|
|
581
598
|
baseDir: string;
|
|
599
|
+
containmentInvalid: boolean;
|
|
582
600
|
targetHead: string;
|
|
583
601
|
rootPath: string;
|
|
584
602
|
git: GitWorktreeRecord;
|
|
@@ -605,7 +623,9 @@ function buildManagedEntry(input: {
|
|
|
605
623
|
if (pathInspection.missing) return blockedEntry(entry, "stale", "unknown", "worktree path is missing from disk");
|
|
606
624
|
if (pathInspection.symlink) return blockedEntry(entry, "unknown", "unknown", "worktree path is a symlink; cleanup requires a real directory");
|
|
607
625
|
if (!pathInspection.directory || !pathInspection.realpath) return blockedEntry(entry, "unknown", "unknown", "worktree path is not a directory");
|
|
608
|
-
|
|
626
|
+
const extensionsDir = path.join(getAgentDir(), "extensions");
|
|
627
|
+
if (pathInside(extensionsDir, pathInspection.realpath)) return blockedEntry(entry, "ineligible", "keep", `worktree real path is inside Pi extensions directory '${extensionsDir}'`);
|
|
628
|
+
if (input.containmentInvalid || !pathInside(baseDir, pathInspection.realpath, true)) return blockedEntry(entry, "ineligible", "keep", `worktree real path is outside configured base directory '${baseDir}'`);
|
|
609
629
|
|
|
610
630
|
if (rootPath === comparablePath(pathInspection.realpath)) return blockedEntry(entry, "ineligible", "keep", "worktree is the repository root");
|
|
611
631
|
if (!git.branch) return blockedEntry(entry, "unknown", "unknown", "detached worktrees have no metadata-recorded branch");
|
|
@@ -648,14 +668,14 @@ function buildManagedEntry(input: {
|
|
|
648
668
|
entry.preconditions.statusDigest = status.digest;
|
|
649
669
|
if (status.output && status.output.trim()) return blockedEntry(entry, "dirty", "keep", "worktree has uncommitted or untracked changes");
|
|
650
670
|
|
|
651
|
-
const diff = runGit(worktreePath, ["diff", "--quiet", resolvedBase.value, "--"]);
|
|
671
|
+
const diff = runGit(worktreePath, ["diff", "--quiet", ...MACHINE_DIFF_OPTIONS, resolvedBase.value, "--"]);
|
|
652
672
|
if (diff.status !== 0 && diff.status !== 1) return blockedEntry(entry, "unknown", "unknown", `git diff safety check failed: ${gitFailure(diff, "git diff")}`);
|
|
653
673
|
const ancestor = runGit(repoRoot, ["merge-base", "--is-ancestor", git.head, targetHead]);
|
|
654
674
|
if (ancestor.status !== 0 && ancestor.status !== 1) return blockedEntry(entry, "unknown", "unknown", `local merge safety check failed: ${gitFailure(ancestor, "git merge-base --is-ancestor")}`);
|
|
655
675
|
const branchTipIsAncestor = ancestor.status === 0;
|
|
656
676
|
let divergenceSafe = diff.status === 0;
|
|
657
677
|
if (!divergenceSafe) {
|
|
658
|
-
const captured = isPatchCaptured(record, worktreePath);
|
|
678
|
+
const captured = isPatchCaptured(record, worktreePath, resolvedBase.value);
|
|
659
679
|
if (captured.error) return blockedEntry(entry, "ineligible", "keep", captured.error);
|
|
660
680
|
if (captured.path) {
|
|
661
681
|
entry.patchPath = captured.path;
|
|
@@ -725,6 +745,7 @@ export function buildWorktreeCleanupPlan(input: BuildWorktreeCleanupPlanInput):
|
|
|
725
745
|
const now = input.now ?? Date.now();
|
|
726
746
|
if (!Number.isFinite(now)) throw new Error("worktree cleanup plan timestamp must be finite");
|
|
727
747
|
const baseDir = resolveCleanupBaseDir(repoRoot, input.worktreeBaseDir);
|
|
748
|
+
const containmentInvalid = cleanupContainmentInvalid(repoRoot, baseDir);
|
|
728
749
|
const gitRecords = listGitWorktrees(repoRoot);
|
|
729
750
|
const targetHead = runGitChecked(repoRoot, ["rev-parse", "HEAD"]);
|
|
730
751
|
const rootPath = comparablePath(repoRoot);
|
|
@@ -749,6 +770,7 @@ export function buildWorktreeCleanupPlan(input: BuildWorktreeCleanupPlanInput):
|
|
|
749
770
|
else entries.push(buildManagedEntry({
|
|
750
771
|
repoRoot,
|
|
751
772
|
baseDir,
|
|
773
|
+
containmentInvalid,
|
|
752
774
|
targetHead,
|
|
753
775
|
rootPath,
|
|
754
776
|
git,
|
|
@@ -9,6 +9,7 @@ import { getAgentDir } from "../../shared/utils.ts";
|
|
|
9
9
|
import type { ManagedWorktreeProvider, WorktreeNaming, WorktreeProvider } from "../../shared/types.ts";
|
|
10
10
|
|
|
11
11
|
export const DEFAULT_WORKTREE_PROVIDER: WorktreeProvider = "auto";
|
|
12
|
+
export const DEFAULT_WORKTREE_BASE_REF = "HEAD";
|
|
12
13
|
export const DEFAULT_WORKTREE_BRANCH_PREFIX = "pi-subagents/";
|
|
13
14
|
/** Internal marker used to defer Worktrunk-dependent instruction paths to launch time. */
|
|
14
15
|
export const WORKTREE_AGENT_CWD_PLACEHOLDER = path.join(path.parse(process.cwd()).root, "__pi_subagents_worktree_cwd__");
|
|
@@ -16,6 +17,9 @@ const WORKTREE_NAMING_COMPONENT_MAX_BYTES = 96;
|
|
|
16
17
|
const WORKTREE_NAMING_LABEL_MAX_BYTES = 256;
|
|
17
18
|
const WORKTREE_NAMING_BRANCH_MAX_BYTES = 256;
|
|
18
19
|
const WORKTREE_COMMAND_OUTPUT_MAX_BYTES = 128 * 1024;
|
|
20
|
+
export const MACHINE_DIFF_OPTIONS = ["--no-color", "--no-ext-diff", "--no-textconv", "--default-prefix", "--line-prefix=", "--no-relative"] as const;
|
|
21
|
+
const MACHINE_PATCH_OPTIONS = [...MACHINE_DIFF_OPTIONS, "--binary"] as const;
|
|
22
|
+
const PATCH_VALIDATION_OPTIONS = ["apply", "--check", "--cached", "--reverse", "--binary", "--whitespace=nowarn"] as const;
|
|
19
23
|
|
|
20
24
|
export interface WorktreeNamingInput {
|
|
21
25
|
runId: string;
|
|
@@ -121,6 +125,8 @@ export interface CreateWorktreesOptions {
|
|
|
121
125
|
tasks?: Array<string | undefined>;
|
|
122
126
|
/** Worktree allocator selection; auto prefers Worktrunk when available. */
|
|
123
127
|
provider?: WorktreeProvider;
|
|
128
|
+
/** Git ref used as the worktree base; defaults to `HEAD`. */
|
|
129
|
+
baseRef?: string;
|
|
124
130
|
/** Branch namespace; defaults to `pi-subagents/`. */
|
|
125
131
|
branchPrefix?: string;
|
|
126
132
|
setupHook?: WorktreeSetupHookConfig;
|
|
@@ -159,13 +165,14 @@ interface GitResult {
|
|
|
159
165
|
interface RepoState {
|
|
160
166
|
toplevel: string;
|
|
161
167
|
cwdRelative: string;
|
|
168
|
+
sourceCheckout: SourceCheckoutSnapshot;
|
|
162
169
|
baseCommit: string;
|
|
163
170
|
}
|
|
164
171
|
|
|
165
172
|
const DEFAULT_WORKTREE_SETUP_HOOK_TIMEOUT_MS = 30000;
|
|
166
173
|
|
|
167
|
-
function runGit(cwd: string, args: string[]): GitResult {
|
|
168
|
-
const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8", windowsHide: true, shell: false });
|
|
174
|
+
function runGit(cwd: string, args: string[], env?: NodeJS.ProcessEnv): GitResult {
|
|
175
|
+
const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8", windowsHide: true, shell: false, ...(env ? { env: { ...process.env, ...env } } : {}) });
|
|
169
176
|
return {
|
|
170
177
|
stdout: result.stdout ?? "",
|
|
171
178
|
stderr: result.stderr ?? "",
|
|
@@ -183,6 +190,53 @@ function runGitChecked(cwd: string, args: string[]): string {
|
|
|
183
190
|
return result.stdout;
|
|
184
191
|
}
|
|
185
192
|
|
|
193
|
+
/** Validate a captured patch against the worktree index without changing either. */
|
|
194
|
+
export function validateWorktreePatch(worktreePath: string, patchPath: string): string | undefined {
|
|
195
|
+
const result = runGit(worktreePath, [...PATCH_VALIDATION_OPTIONS, patchPath]);
|
|
196
|
+
if (result.status === 0) return undefined;
|
|
197
|
+
return result.stderr.trim() || result.stdout.trim() || `git -C ${worktreePath} apply --check failed`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function currentWorktreePatch(worktreePath: string, baseCommit: string): { patch: string } | { error: string } {
|
|
201
|
+
let tempDir: string;
|
|
202
|
+
try {
|
|
203
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-worktree-index-"));
|
|
204
|
+
} catch (error) {
|
|
205
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
206
|
+
}
|
|
207
|
+
const env = { GIT_INDEX_FILE: path.join(tempDir, "index") };
|
|
208
|
+
try {
|
|
209
|
+
const readTree = runGit(worktreePath, ["read-tree", "HEAD"], env);
|
|
210
|
+
if (readTree.status !== 0) return { error: readTree.stderr.trim() || readTree.stdout.trim() || "git read-tree failed" };
|
|
211
|
+
const add = runGit(worktreePath, ["add", "-A"], env);
|
|
212
|
+
if (add.status !== 0) return { error: add.stderr.trim() || add.stdout.trim() || "git add failed" };
|
|
213
|
+
const diff = runGit(worktreePath, ["diff", "--cached", ...MACHINE_PATCH_OPTIONS, baseCommit], env);
|
|
214
|
+
if (diff.status !== 0) return { error: diff.stderr.trim() || diff.stdout.trim() || "git diff failed" };
|
|
215
|
+
return { patch: diff.stdout };
|
|
216
|
+
} finally {
|
|
217
|
+
try {
|
|
218
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
219
|
+
} catch {
|
|
220
|
+
// Cleanup safety depends on preserving the worktree, not on best-effort temp-index deletion.
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function validateWorktreePatchRepresentsCurrentWorktree(worktreePath: string, baseCommit: string, patchPath: string): string | undefined {
|
|
226
|
+
const validationError = validateWorktreePatch(worktreePath, patchPath);
|
|
227
|
+
if (validationError) return validationError;
|
|
228
|
+
let capturedPatch: string;
|
|
229
|
+
try {
|
|
230
|
+
capturedPatch = fs.readFileSync(patchPath, "utf-8");
|
|
231
|
+
} catch (error) {
|
|
232
|
+
return error instanceof Error ? error.message : String(error);
|
|
233
|
+
}
|
|
234
|
+
const current = currentWorktreePatch(worktreePath, baseCommit);
|
|
235
|
+
if ("error" in current) return current.error || "failed to capture current worktree patch";
|
|
236
|
+
if (capturedPatch !== current.patch) return "captured handoff patch does not match current worktree changes";
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
186
240
|
function findGitWorktreePath(cwd: string, branch: string): string | undefined {
|
|
187
241
|
const targetBranch = `branch refs/heads/${branch}`;
|
|
188
242
|
let currentPath: string | undefined;
|
|
@@ -193,7 +247,7 @@ function findGitWorktreePath(cwd: string, branch: string): string | undefined {
|
|
|
193
247
|
return undefined;
|
|
194
248
|
}
|
|
195
249
|
|
|
196
|
-
function resolveRepoState(cwd: string): RepoState {
|
|
250
|
+
function resolveRepoState(cwd: string, requestedBaseRef: string | undefined): RepoState {
|
|
197
251
|
const cwdRelative = resolveRepoCwdRelative(cwd);
|
|
198
252
|
const toplevel = runGitChecked(cwd, ["rev-parse", "--show-toplevel"]).trim();
|
|
199
253
|
|
|
@@ -204,8 +258,17 @@ function resolveRepoState(cwd: string): RepoState {
|
|
|
204
258
|
throw new Error("worktree isolation requires a clean git working tree. Commit or stash changes first.");
|
|
205
259
|
}
|
|
206
260
|
|
|
207
|
-
const
|
|
208
|
-
|
|
261
|
+
const sourceHead = runGitChecked(toplevel, ["rev-parse", "HEAD"]).trim();
|
|
262
|
+
const sourceCheckout = snapshotSourceCheckout(toplevel, sourceHead);
|
|
263
|
+
const baseRef = normalizeWorktreeBaseRef(requestedBaseRef) ?? DEFAULT_WORKTREE_BASE_REF;
|
|
264
|
+
let baseCommit: string;
|
|
265
|
+
try {
|
|
266
|
+
baseCommit = runGitChecked(toplevel, ["rev-parse", "--verify", "--end-of-options", `${baseRef}^{commit}`]).trim();
|
|
267
|
+
} catch (error) {
|
|
268
|
+
throw new Error(`baseRef '${baseRef}' could not be resolved to a commit: ${error instanceof Error ? error.message : String(error)}`, { cause: error instanceof Error ? error : undefined });
|
|
269
|
+
}
|
|
270
|
+
if (!baseCommit) throw new Error(`baseRef '${baseRef}' could not be resolved to a commit`);
|
|
271
|
+
return { toplevel, cwdRelative, sourceCheckout, baseCommit };
|
|
209
272
|
}
|
|
210
273
|
|
|
211
274
|
function normalizeComparableCwd(cwd: string): string {
|
|
@@ -214,7 +277,13 @@ function normalizeComparableCwd(cwd: string): string {
|
|
|
214
277
|
const missingSegments: string[] = [];
|
|
215
278
|
while (true) {
|
|
216
279
|
try {
|
|
217
|
-
|
|
280
|
+
let realpath: string;
|
|
281
|
+
try {
|
|
282
|
+
realpath = fs.realpathSync.native(existing);
|
|
283
|
+
} catch {
|
|
284
|
+
realpath = fs.realpathSync(existing);
|
|
285
|
+
}
|
|
286
|
+
return path.join(realpath, ...missingSegments.reverse());
|
|
218
287
|
} catch {
|
|
219
288
|
const parent = path.dirname(existing);
|
|
220
289
|
if (parent === existing) return resolved;
|
|
@@ -279,11 +348,19 @@ export function sanitizeWorktreePathComponent(value: string, maxBytes = WORKTREE
|
|
|
279
348
|
}
|
|
280
349
|
|
|
281
350
|
function validGitRef(ref: string): boolean {
|
|
282
|
-
if (!ref || Buffer.byteLength(ref, "utf-8") > 1024 || ref.startsWith("/") || ref.endsWith("/") || ref.includes("//") || ref.includes("..") || ref.includes("@{")) return false;
|
|
283
|
-
if (
|
|
351
|
+
if (!ref || ref === "@" || Buffer.byteLength(ref, "utf-8") > 1024 || ref.startsWith("/") || ref.endsWith("/") || ref.includes("//") || ref.includes("..") || ref.includes("@{")) return false;
|
|
352
|
+
if (/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu.test(ref)) return false;
|
|
353
|
+
if (/[[\]\\~^:?*\u0000-\u0020\u007f]/u.test(ref) || ref.endsWith(".") || ref.endsWith(".lock")) return false;
|
|
284
354
|
return ref.split("/").every((component) => component.length > 0 && component !== "." && component !== ".." && !component.startsWith(".") && !component.endsWith(".") && !component.endsWith(".lock"));
|
|
285
355
|
}
|
|
286
356
|
|
|
357
|
+
/** Normalize and validate a configured worktree base ref without resolving it. */
|
|
358
|
+
export function normalizeWorktreeBaseRef(value: unknown): string | undefined {
|
|
359
|
+
if (value === undefined) return undefined;
|
|
360
|
+
if (typeof value !== "string" || !validGitRef(value)) throw new Error("baseRef must be a valid Git ref");
|
|
361
|
+
return value;
|
|
362
|
+
}
|
|
363
|
+
|
|
287
364
|
/** Normalize and validate the configured Git branch namespace. */
|
|
288
365
|
export function normalizeWorktreeBranchPrefix(value: string | undefined): string {
|
|
289
366
|
const raw = value === undefined ? DEFAULT_WORKTREE_BRANCH_PREFIX : value.trim();
|
|
@@ -428,31 +505,85 @@ export function shouldDeferWorktreeCwd(requested: WorktreeProvider | undefined,
|
|
|
428
505
|
return (requested ?? DEFAULT_WORKTREE_PROVIDER) !== "native" && !hasConfiguredWorktreeBaseDir(baseDir);
|
|
429
506
|
}
|
|
430
507
|
|
|
431
|
-
|
|
508
|
+
/**
|
|
509
|
+
* Resolves the dedicated worktree root: the configured base directory or
|
|
510
|
+
* PI_SUBAGENTS_WORKTREE_DIR when set, otherwise a `worktrees` folder sibling
|
|
511
|
+
* to the repository. Managed leaves always nest one level deeper under the
|
|
512
|
+
* project folder (`basename(repoRoot)`).
|
|
513
|
+
*/
|
|
514
|
+
function resolveWorktreeDedicatedRoot(configuredBaseDir: string | undefined, repoRoot: string): string {
|
|
432
515
|
const rawBaseDir = configuredBaseDir ?? process.env.PI_SUBAGENTS_WORKTREE_DIR;
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
516
|
+
let expanded: string;
|
|
517
|
+
if (rawBaseDir === undefined || (configuredBaseDir === undefined && !rawBaseDir.trim())) {
|
|
518
|
+
expanded = path.join(path.dirname(repoRoot), "worktrees");
|
|
519
|
+
} else {
|
|
520
|
+
const trimmed = rawBaseDir.trim();
|
|
521
|
+
if (!trimmed) throw new Error("worktree base directory cannot be empty");
|
|
437
522
|
|
|
438
|
-
|
|
439
|
-
|
|
523
|
+
const candidate = trimmed.startsWith("~/") ? path.join(os.homedir(), trimmed.slice(2)) : trimmed;
|
|
524
|
+
expanded = path.isAbsolute(candidate) ? candidate : path.resolve(repoRoot, candidate);
|
|
525
|
+
}
|
|
440
526
|
const extensionsDir = normalizeComparableCwd(path.join(getAgentDir(), "extensions"));
|
|
441
|
-
|
|
442
|
-
if (!relativeToExtensions || (!relativeToExtensions.startsWith(`..${path.sep}`) && relativeToExtensions !== ".." && !path.isAbsolute(relativeToExtensions))) {
|
|
527
|
+
if (isPathInside(extensionsDir, normalizeComparableCwd(expanded))) {
|
|
443
528
|
throw new Error(`worktree base directory cannot be inside Pi extensions directory: ${extensionsDir}. Choose a directory outside it.`);
|
|
444
529
|
}
|
|
530
|
+
return expanded;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function buildNativeProjectPath(dedicatedRoot: string, repoRoot: string): string {
|
|
534
|
+
return path.join(dedicatedRoot, path.basename(repoRoot));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Creates the project folder (parents included) so `git worktree add` can create
|
|
539
|
+
* the leaf inside it. Must only run after `assertSafeWorktreeLocation` accepted
|
|
540
|
+
* the planned leaf — an unsafe base must never be materialized on disk.
|
|
541
|
+
*/
|
|
542
|
+
function ensureProjectWorktreeDir(dedicatedRoot: string, repoRoot: string): void {
|
|
445
543
|
try {
|
|
446
|
-
fs.mkdirSync(
|
|
544
|
+
fs.mkdirSync(buildNativeProjectPath(dedicatedRoot, repoRoot), { recursive: true });
|
|
447
545
|
} catch (error) {
|
|
448
546
|
const message = error instanceof Error ? error.message : String(error);
|
|
449
|
-
throw new Error(`failed to create worktree base directory ${
|
|
547
|
+
throw new Error(`failed to create worktree base directory ${dedicatedRoot}: ${message}`);
|
|
450
548
|
}
|
|
451
|
-
return resolved;
|
|
452
549
|
}
|
|
453
550
|
|
|
454
|
-
function buildNativeWorktreePath(
|
|
455
|
-
return path.join(
|
|
551
|
+
function buildNativeWorktreePath(dedicatedRoot: string, repoRoot: string, runId: string, index: number): string {
|
|
552
|
+
return path.join(buildNativeProjectPath(dedicatedRoot, repoRoot), `pi-worktree-${sanitizeWorktreePathComponent(runId, 120)}-${index}`);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function isPathInside(parent: string, child: string): boolean {
|
|
556
|
+
const relative = path.relative(parent, child);
|
|
557
|
+
if (!relative || relative === ".") return true;
|
|
558
|
+
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function isStrictChildPath(parent: string, child: string): boolean {
|
|
562
|
+
const relative = path.relative(parent, child);
|
|
563
|
+
return Boolean(relative) && relative !== "." && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Fail closed before `git worktree add`: reject unsafe planned/realpath locations. */
|
|
567
|
+
function assertSafeWorktreeLocation(worktreePath: string, repoRoot: string, dedicatedRoot: string): void {
|
|
568
|
+
const resolvedLeaf = normalizeComparableCwd(worktreePath);
|
|
569
|
+
const resolvedRepoRoot = normalizeComparableCwd(repoRoot);
|
|
570
|
+
const projectDir = normalizeComparableCwd(buildNativeProjectPath(dedicatedRoot, repoRoot));
|
|
571
|
+
const repoParent = normalizeComparableCwd(path.dirname(resolvedRepoRoot));
|
|
572
|
+
const leafParent = normalizeComparableCwd(path.dirname(resolvedLeaf));
|
|
573
|
+
const extensionsDir = normalizeComparableCwd(path.join(getAgentDir(), "extensions"));
|
|
574
|
+
|
|
575
|
+
if (isPathInside(extensionsDir, projectDir) || isPathInside(extensionsDir, resolvedLeaf)) {
|
|
576
|
+
throw new Error(`worktree path cannot be inside Pi extensions directory: ${extensionsDir}. Choose a directory outside it.`);
|
|
577
|
+
}
|
|
578
|
+
if (isPathInside(resolvedRepoRoot, resolvedLeaf)) {
|
|
579
|
+
throw new Error(`worktree path would land inside the repository checkout: ${resolvedLeaf}`);
|
|
580
|
+
}
|
|
581
|
+
if (leafParent === repoParent) {
|
|
582
|
+
throw new Error(`worktree path would be a direct child of the repository parent: ${resolvedLeaf}`);
|
|
583
|
+
}
|
|
584
|
+
if (!isStrictChildPath(projectDir, resolvedLeaf)) {
|
|
585
|
+
throw new Error(`worktree path must be a strict child of the project worktree directory ${projectDir}: ${resolvedLeaf}`);
|
|
586
|
+
}
|
|
456
587
|
}
|
|
457
588
|
|
|
458
589
|
function resolveRepoCwdRelative(cwd: string): string {
|
|
@@ -470,7 +601,9 @@ function resolveRepoCwdRelative(cwd: string): string {
|
|
|
470
601
|
export function resolveExpectedWorktreeAgentCwd(cwd: string, runId: string, index: number, baseDir?: string): string {
|
|
471
602
|
const cwdRelative = resolveRepoCwdRelative(cwd);
|
|
472
603
|
const repoRoot = runGitChecked(cwd, ["rev-parse", "--show-toplevel"]).trim();
|
|
473
|
-
const
|
|
604
|
+
const dedicatedRoot = resolveWorktreeDedicatedRoot(baseDir, repoRoot);
|
|
605
|
+
const worktreePath = buildNativeWorktreePath(dedicatedRoot, repoRoot, runId, index);
|
|
606
|
+
assertSafeWorktreeLocation(worktreePath, repoRoot, dedicatedRoot);
|
|
474
607
|
return cwdRelative ? path.join(worktreePath, cwdRelative) : worktreePath;
|
|
475
608
|
}
|
|
476
609
|
|
|
@@ -660,13 +793,15 @@ function createNativeWorktree(
|
|
|
660
793
|
baseCommit: string,
|
|
661
794
|
setupHook: ResolvedWorktreeSetupHook | undefined,
|
|
662
795
|
agent: string | undefined,
|
|
663
|
-
|
|
796
|
+
dedicatedRoot: string,
|
|
664
797
|
labels: Array<string | undefined> | undefined,
|
|
665
798
|
tasks: Array<string | undefined> | undefined,
|
|
666
799
|
branchPrefix: string | undefined,
|
|
667
800
|
): WorktreeInfo {
|
|
668
801
|
const naming = buildWorktreeNaming({ runId, index, agent, label: labels?.[index], task: tasks?.[index], branchPrefix });
|
|
669
|
-
const worktreePath = buildNativeWorktreePath(
|
|
802
|
+
const worktreePath = buildNativeWorktreePath(dedicatedRoot, toplevel, runId, index);
|
|
803
|
+
assertSafeWorktreeLocation(worktreePath, toplevel, dedicatedRoot);
|
|
804
|
+
ensureProjectWorktreeDir(dedicatedRoot, toplevel);
|
|
670
805
|
const add = runGit(toplevel, ["worktree", "add", worktreePath, "-b", naming.requestedBranch, baseCommit]);
|
|
671
806
|
if (add.status !== 0) {
|
|
672
807
|
const message = add.stderr.trim() || add.stdout.trim() || `failed to create worktree ${worktreePath}`;
|
|
@@ -712,13 +847,13 @@ function createWorktrunkWorktree(
|
|
|
712
847
|
runId: string,
|
|
713
848
|
index: number,
|
|
714
849
|
baseCommit: string,
|
|
850
|
+
sourceCheckout: SourceCheckoutSnapshot,
|
|
715
851
|
agents: string[] | undefined,
|
|
716
852
|
labels: Array<string | undefined> | undefined,
|
|
717
853
|
tasks: Array<string | undefined> | undefined,
|
|
718
854
|
branchPrefix: string | undefined,
|
|
719
855
|
): WorktreeInfo {
|
|
720
856
|
const naming = buildWorktreeNaming({ runId, index, agent: agents?.[index], label: labels?.[index], task: tasks?.[index], branchPrefix });
|
|
721
|
-
const sourceCheckout = snapshotSourceCheckout(toplevel, baseCommit);
|
|
722
857
|
const args = ["-C", toplevel, "switch", "--create", naming.requestedBranch, "--base", baseCommit, "--no-cd", "--no-hooks", "--format", "json"];
|
|
723
858
|
const result = runWorktrunk(args, toplevel);
|
|
724
859
|
if (result.status !== 0) {
|
|
@@ -872,15 +1007,18 @@ function captureWorktreeDiff(
|
|
|
872
1007
|
): WorktreeDiff {
|
|
873
1008
|
removeSyntheticPathsBeforeDiff(worktree);
|
|
874
1009
|
runGitChecked(worktree.path, ["add", "-A"]);
|
|
875
|
-
const diffStat = runGitChecked(worktree.path, ["diff", "--cached", "--stat", setup.baseCommit]).trim();
|
|
876
|
-
const patch = runGitChecked(worktree.path, ["diff", "--cached", setup.baseCommit]);
|
|
877
|
-
const numstat = runGitChecked(worktree.path, ["diff", "--cached", "--numstat", setup.baseCommit]);
|
|
1010
|
+
const diffStat = runGitChecked(worktree.path, ["diff", "--cached", ...MACHINE_DIFF_OPTIONS, "--stat", setup.baseCommit]).trim();
|
|
1011
|
+
const patch = runGitChecked(worktree.path, ["diff", "--cached", ...MACHINE_PATCH_OPTIONS, setup.baseCommit]);
|
|
1012
|
+
const numstat = runGitChecked(worktree.path, ["diff", "--cached", ...MACHINE_DIFF_OPTIONS, "--numstat", setup.baseCommit]);
|
|
878
1013
|
fs.writeFileSync(patchPath, patch, "utf-8");
|
|
879
1014
|
|
|
880
1015
|
if (!patch.trim()) {
|
|
881
1016
|
return emptyDiff(worktree.index, agent, worktree.branch, patchPath);
|
|
882
1017
|
}
|
|
883
1018
|
|
|
1019
|
+
const validationError = validateWorktreePatch(worktree.path, patchPath);
|
|
1020
|
+
if (validationError) throw new Error(`captured worktree patch is not machine-applyable: ${validationError}`);
|
|
1021
|
+
|
|
884
1022
|
const parsed = parseNumstat(numstat);
|
|
885
1023
|
return {
|
|
886
1024
|
index: worktree.index,
|
|
@@ -949,7 +1087,7 @@ function cleanupSingleWorktree(
|
|
|
949
1087
|
errors.push(`synthetic path cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
950
1088
|
}
|
|
951
1089
|
const status = runGit(worktree.path, ["status", "--porcelain"]);
|
|
952
|
-
const baseDiff = runGit(worktree.path, ["diff", "--quiet", setup.baseCommit, "--"]);
|
|
1090
|
+
const baseDiff = runGit(worktree.path, ["diff", "--quiet", ...MACHINE_DIFF_OPTIONS, setup.baseCommit, "--"]);
|
|
953
1091
|
if (status.status !== 0 || (baseDiff.status !== 0 && baseDiff.status !== 1)) {
|
|
954
1092
|
const reason = status.status !== 0
|
|
955
1093
|
? status.stderr.trim() || status.stdout.trim() || "git status failed"
|
|
@@ -970,13 +1108,25 @@ function cleanupSingleWorktree(
|
|
|
970
1108
|
const hasWork = status.stdout.trim().length > 0 || baseDiff.status === 1;
|
|
971
1109
|
if (hasWork && intent.kind === "preserve") {
|
|
972
1110
|
const captured = (intent.capturedDiffs ?? setup.capturedDiffs)?.find((diff) => diff.index === worktree.index);
|
|
973
|
-
|
|
1111
|
+
let patchValidationError: string | undefined;
|
|
1112
|
+
let patchCaptured = false;
|
|
1113
|
+
if (captured !== undefined
|
|
974
1114
|
&& captured.error === undefined
|
|
975
1115
|
&& fs.existsSync(captured.patchPath)
|
|
976
|
-
&&
|
|
977
|
-
|
|
1116
|
+
&& handoffRecordsPatch(intent.handoffManifestPath, captured.patchPath)) {
|
|
1117
|
+
try {
|
|
1118
|
+
if (fs.statSync(captured.patchPath).size > 0) {
|
|
1119
|
+
patchValidationError = validateWorktreePatchRepresentsCurrentWorktree(worktree.path, setup.baseCommit, captured.patchPath);
|
|
1120
|
+
patchCaptured = patchValidationError === undefined;
|
|
1121
|
+
}
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
patchValidationError = error instanceof Error ? error.message : String(error);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
978
1126
|
if (!patchCaptured) {
|
|
979
|
-
const reason =
|
|
1127
|
+
const reason = patchValidationError
|
|
1128
|
+
? `captured handoff patch failed validation: ${patchValidationError}`
|
|
1129
|
+
: "worktree contains changes that are not represented by a captured handoff patch";
|
|
980
1130
|
return {
|
|
981
1131
|
index: worktree.index,
|
|
982
1132
|
path: worktree.path,
|
|
@@ -1045,11 +1195,11 @@ function hasWorktreeChanges(diff: WorktreeDiff): boolean {
|
|
|
1045
1195
|
|
|
1046
1196
|
export function createWorktrees(cwd: string, runId: string, count: number, options?: CreateWorktreesOptions): WorktreeSetup {
|
|
1047
1197
|
if (!Number.isSafeInteger(count) || count < 0) throw new Error("worktree count must be a non-negative integer");
|
|
1048
|
-
const repo = resolveRepoState(cwd);
|
|
1198
|
+
const repo = resolveRepoState(cwd, options?.baseRef);
|
|
1049
1199
|
const setupHook = resolveWorktreeSetupHook(repo.toplevel, options?.setupHook);
|
|
1050
|
-
|
|
1200
|
+
const provider = resolveWorktreeProvider(options?.provider, options?.baseDir);
|
|
1051
1201
|
const branchPrefix = normalizeWorktreeBranchPrefix(options?.branchPrefix);
|
|
1052
|
-
|
|
1202
|
+
const dedicatedRoot = provider === "native" ? resolveWorktreeDedicatedRoot(options?.baseDir, repo.toplevel) : undefined;
|
|
1053
1203
|
const worktrees: WorktreeInfo[] = [];
|
|
1054
1204
|
|
|
1055
1205
|
try {
|
|
@@ -1059,7 +1209,8 @@ export function createWorktrees(cwd: string, runId: string, count: number, optio
|
|
|
1059
1209
|
baseCommit: repo.baseCommit,
|
|
1060
1210
|
worktrees: Array.from({ length: count }, (_, index) => {
|
|
1061
1211
|
const naming = buildWorktreeNaming({ runId, index, agent: options?.agents?.[index], label: options?.labels?.[index], task: options?.tasks?.[index], branchPrefix });
|
|
1062
|
-
const worktreePath = buildNativeWorktreePath(
|
|
1212
|
+
const worktreePath = buildNativeWorktreePath(dedicatedRoot!, repo.toplevel, runId, index);
|
|
1213
|
+
assertSafeWorktreeLocation(worktreePath, repo.toplevel, dedicatedRoot!);
|
|
1063
1214
|
return {
|
|
1064
1215
|
path: worktreePath,
|
|
1065
1216
|
agentCwd: repo.cwdRelative ? path.join(worktreePath, repo.cwdRelative) : worktreePath,
|
|
@@ -1075,14 +1226,14 @@ export function createWorktrees(cwd: string, runId: string, count: number, optio
|
|
|
1075
1226
|
options?.beforeCreate?.(plannedSetup);
|
|
1076
1227
|
for (let index = 0; index < count; index++) {
|
|
1077
1228
|
worktrees.push(createNativeWorktree(
|
|
1078
|
-
|
|
1229
|
+
repo.toplevel,
|
|
1079
1230
|
repo.cwdRelative,
|
|
1080
1231
|
runId,
|
|
1081
1232
|
index,
|
|
1082
1233
|
repo.baseCommit,
|
|
1083
1234
|
setupHook,
|
|
1084
1235
|
options?.agents?.[index],
|
|
1085
|
-
|
|
1236
|
+
dedicatedRoot!,
|
|
1086
1237
|
options?.labels,
|
|
1087
1238
|
options?.tasks,
|
|
1088
1239
|
branchPrefix,
|
|
@@ -1096,6 +1247,7 @@ export function createWorktrees(cwd: string, runId: string, count: number, optio
|
|
|
1096
1247
|
runId,
|
|
1097
1248
|
index,
|
|
1098
1249
|
repo.baseCommit,
|
|
1250
|
+
repo.sourceCheckout,
|
|
1099
1251
|
options?.agents,
|
|
1100
1252
|
options?.labels,
|
|
1101
1253
|
options?.tasks,
|
|
@@ -6,7 +6,7 @@ import { PROMPT_REDACTED } from "./utils.ts";
|
|
|
6
6
|
* the agent name and its task (or workflow node label). The parent computes it
|
|
7
7
|
* once and threads it two ways:
|
|
8
8
|
*
|
|
9
|
-
* 1. Into the child
|
|
9
|
+
* 1. Into the child's runtime config as `sessionName`, where the prompt
|
|
10
10
|
* runtime calls `pi.setSessionName(...)` so the child's own session file
|
|
11
11
|
* is identifiable in `pi --resume` and host session browsers.
|
|
12
12
|
* 2. Into the `sessionName` field of result/progress payloads, so hosts
|