pi-subagents 0.62.0 → 0.63.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 +28 -0
- package/docs/agents.md +5 -4
- package/docs/configuration.md +28 -2
- package/docs/models.md +5 -5
- package/docs/observability.md +4 -1
- package/docs/tool-reference.md +2 -2
- package/package.json +1 -1
- package/skills/pi-subagents/references/management-authoring-rpc.md +1 -1
- package/skills/pi-subagents/references/prompting-and-roles.md +3 -3
- package/src/agents/agent-management.ts +22 -4
- package/src/agents/agents.ts +107 -125
- package/src/api/shared-types.ts +3 -0
- package/src/extension/config.ts +20 -0
- package/src/inspectors/herdr/inspector-runner.ts +19 -13
- package/src/runs/background/active-async-capacity.ts +0 -1
- package/src/runs/background/async-execution.ts +53 -7
- package/src/runs/background/async-resume.ts +3 -0
- package/src/runs/background/async-status.ts +18 -2
- package/src/runs/background/notify.ts +13 -1
- package/src/runs/background/run-status.ts +22 -2
- package/src/runs/background/subagent-runner.ts +30 -2
- package/src/runs/background/wait-completions.ts +13 -0
- package/src/runs/foreground/subagent-executor.ts +35 -3
- package/src/runs/shared/acceptance.ts +15 -9
- package/src/runs/shared/lane-metadata.ts +24 -3
- package/src/runs/shared/parallel-handoff.ts +4 -0
- package/src/runs/shared/pi-args.ts +8 -2
- package/src/runs/shared/task-intent.ts +5 -2
- package/src/runs/shared/worktree.ts +467 -63
- package/src/shared/types.ts +29 -0
- package/src/shared/utils.ts +18 -7
- package/src/slash/subagents-admin.ts +24 -12
- package/src/tui/fleet-status.ts +61 -2
- package/src/tui/fleet.ts +12 -7
- package/src/tui/render.ts +222 -14
- package/src/workflows/workflow-checklist.ts +441 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { spawnSync } from "node:child_process";
|
|
2
3
|
import * as fs from "node:fs";
|
|
3
4
|
import * as os from "node:os";
|
|
@@ -5,6 +6,41 @@ import * as path from "node:path";
|
|
|
5
6
|
import { resolveAuthorityDecision, type AuthorityPolicyConfig } from "../../policy/authority.ts";
|
|
6
7
|
import { PROJECT_SUBAGENTS_RELATIVE_DIR } from "../../shared/artifacts.ts";
|
|
7
8
|
import { getAgentDir } from "../../shared/utils.ts";
|
|
9
|
+
import type { ManagedWorktreeProvider, WorktreeNaming, WorktreeProvider } from "../../shared/types.ts";
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_WORKTREE_PROVIDER: WorktreeProvider = "auto";
|
|
12
|
+
export const DEFAULT_WORKTREE_BRANCH_PREFIX = "pi-subagents/";
|
|
13
|
+
/** Internal marker used to defer Worktrunk-dependent instruction paths to launch time. */
|
|
14
|
+
export const WORKTREE_AGENT_CWD_PLACEHOLDER = path.join(path.parse(process.cwd()).root, "__pi_subagents_worktree_cwd__");
|
|
15
|
+
const WORKTREE_NAMING_COMPONENT_MAX_BYTES = 96;
|
|
16
|
+
const WORKTREE_NAMING_LABEL_MAX_BYTES = 256;
|
|
17
|
+
const WORKTREE_NAMING_BRANCH_MAX_BYTES = 256;
|
|
18
|
+
const WORKTREE_COMMAND_OUTPUT_MAX_BYTES = 128 * 1024;
|
|
19
|
+
|
|
20
|
+
export interface WorktreeNamingInput {
|
|
21
|
+
runId: string;
|
|
22
|
+
index: number;
|
|
23
|
+
/** Explicit step index; otherwise a trailing `-sN` in runId is used. */
|
|
24
|
+
stepIndex?: number;
|
|
25
|
+
/** Explicit task index; defaults to index. */
|
|
26
|
+
taskIndex?: number;
|
|
27
|
+
/** Label precedence is lane/workflow key, output/stable key, then this label. */
|
|
28
|
+
agent?: string;
|
|
29
|
+
label?: string;
|
|
30
|
+
laneKey?: string;
|
|
31
|
+
workflowKey?: string;
|
|
32
|
+
outputName?: string;
|
|
33
|
+
taskKey?: string;
|
|
34
|
+
task?: string;
|
|
35
|
+
branchPrefix?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface WorktreeCommandResult {
|
|
39
|
+
stdout: string;
|
|
40
|
+
stderr: string;
|
|
41
|
+
status: number | null;
|
|
42
|
+
error?: Error;
|
|
43
|
+
}
|
|
8
44
|
|
|
9
45
|
export interface WorktreeSetup {
|
|
10
46
|
cwd: string;
|
|
@@ -20,6 +56,8 @@ export interface WorktreeInfo {
|
|
|
20
56
|
index: number;
|
|
21
57
|
nodeModulesLinked: boolean;
|
|
22
58
|
syntheticPaths: string[];
|
|
59
|
+
provider?: ManagedWorktreeProvider;
|
|
60
|
+
naming?: WorktreeNaming;
|
|
23
61
|
}
|
|
24
62
|
|
|
25
63
|
export interface WorktreeDiff {
|
|
@@ -38,6 +76,8 @@ export interface WorktreeCleanupTask {
|
|
|
38
76
|
index: number;
|
|
39
77
|
path: string;
|
|
40
78
|
branch: string;
|
|
79
|
+
provider?: ManagedWorktreeProvider;
|
|
80
|
+
naming?: WorktreeNaming;
|
|
41
81
|
worktreeRemoved: boolean;
|
|
42
82
|
branchRemoved: boolean;
|
|
43
83
|
preserved?: boolean;
|
|
@@ -73,11 +113,19 @@ interface WorktreeSetupHookConfig {
|
|
|
73
113
|
timeoutMs?: number;
|
|
74
114
|
}
|
|
75
115
|
|
|
76
|
-
interface CreateWorktreesOptions {
|
|
116
|
+
export interface CreateWorktreesOptions {
|
|
77
117
|
agents?: string[];
|
|
118
|
+
/** Optional stable labels used to make branch identity readable. */
|
|
119
|
+
labels?: Array<string | undefined>;
|
|
120
|
+
/** Original task text used for the agent-plus-slug naming fallback. */
|
|
121
|
+
tasks?: Array<string | undefined>;
|
|
122
|
+
/** Worktree allocator selection; auto prefers Worktrunk when available. */
|
|
123
|
+
provider?: WorktreeProvider;
|
|
124
|
+
/** Branch namespace; defaults to `pi-subagents/`. */
|
|
125
|
+
branchPrefix?: string;
|
|
78
126
|
setupHook?: WorktreeSetupHookConfig;
|
|
79
127
|
baseDir?: string;
|
|
80
|
-
/** Called with deterministic ownership metadata before
|
|
128
|
+
/** Called with deterministic ownership metadata before setup hooks and child launch; native reports planned paths before allocation, while Worktrunk reports its returned paths after allocation. */
|
|
81
129
|
beforeCreate?: (setup: WorktreeSetup) => void;
|
|
82
130
|
}
|
|
83
131
|
|
|
@@ -117,7 +165,7 @@ interface RepoState {
|
|
|
117
165
|
const DEFAULT_WORKTREE_SETUP_HOOK_TIMEOUT_MS = 30000;
|
|
118
166
|
|
|
119
167
|
function runGit(cwd: string, args: string[]): GitResult {
|
|
120
|
-
const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8", windowsHide: true });
|
|
168
|
+
const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8", windowsHide: true, shell: false });
|
|
121
169
|
return {
|
|
122
170
|
stdout: result.stdout ?? "",
|
|
123
171
|
stderr: result.stderr ?? "",
|
|
@@ -135,6 +183,16 @@ function runGitChecked(cwd: string, args: string[]): string {
|
|
|
135
183
|
return result.stdout;
|
|
136
184
|
}
|
|
137
185
|
|
|
186
|
+
function findGitWorktreePath(cwd: string, branch: string): string | undefined {
|
|
187
|
+
const targetBranch = `branch refs/heads/${branch}`;
|
|
188
|
+
let currentPath: string | undefined;
|
|
189
|
+
for (const line of runGitChecked(cwd, ["worktree", "list", "--porcelain"]).split("\n")) {
|
|
190
|
+
if (line.startsWith("worktree ")) currentPath = line.slice("worktree ".length).trim();
|
|
191
|
+
else if (line.trim() === targetBranch) return currentPath;
|
|
192
|
+
}
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
138
196
|
function resolveRepoState(cwd: string): RepoState {
|
|
139
197
|
const cwdRelative = resolveRepoCwdRelative(cwd);
|
|
140
198
|
const toplevel = runGitChecked(cwd, ["rev-parse", "--show-toplevel"]).trim();
|
|
@@ -192,13 +250,187 @@ function safePatchAgentName(agent: string): string {
|
|
|
192
250
|
return agent.replace(/[^\w.-]/g, "_");
|
|
193
251
|
}
|
|
194
252
|
|
|
195
|
-
function
|
|
196
|
-
return
|
|
253
|
+
function shortWorktreeHash(value: string): string {
|
|
254
|
+
return createHash("sha256").update(value, "utf-8").digest("hex").slice(0, 8);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function truncateUtf8(value: string, maxBytes: number): string {
|
|
258
|
+
if (Buffer.byteLength(value, "utf-8") <= maxBytes) return value;
|
|
259
|
+
const truncated = Buffer.from(value, "utf-8").subarray(0, maxBytes).toString("utf-8");
|
|
260
|
+
return /[\uD800-\uDFFF]$/u.test(truncated) ? truncated.slice(0, -1) : truncated;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Convert an arbitrary label to a single safe filesystem/branch component. */
|
|
264
|
+
export function sanitizeWorktreePathComponent(value: string, maxBytes = WORKTREE_NAMING_COMPONENT_MAX_BYTES): string {
|
|
265
|
+
const raw = value.trim();
|
|
266
|
+
let normalized = raw
|
|
267
|
+
.replace(/[\\/\s]+/g, "-")
|
|
268
|
+
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
269
|
+
.replace(/-+/g, "-")
|
|
270
|
+
.replace(/^[._-]+|[._-]+$/g, "");
|
|
271
|
+
if (!normalized) return "task";
|
|
272
|
+
const changed = normalized !== raw;
|
|
273
|
+
if (changed || Buffer.byteLength(normalized, "utf-8") > maxBytes) {
|
|
274
|
+
const suffix = `-${shortWorktreeHash(raw || "task")}`;
|
|
275
|
+
const prefix = truncateUtf8(normalized, Math.max(1, maxBytes - Buffer.byteLength(suffix, "utf-8"))).replace(/[._-]+$/g, "");
|
|
276
|
+
normalized = `${prefix || "task"}${suffix}`;
|
|
277
|
+
}
|
|
278
|
+
return truncateUtf8(normalized, maxBytes).replace(/^[._-]+|[._-]+$/g, "") || "task";
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
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 (/[[\]\\~^:?*\u0000-\u0020]/u.test(ref) || ref.endsWith(".") || ref.endsWith(".lock")) return false;
|
|
284
|
+
return ref.split("/").every((component) => component.length > 0 && component !== "." && component !== ".." && !component.startsWith(".") && !component.endsWith(".") && !component.endsWith(".lock"));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Normalize and validate the configured Git branch namespace. */
|
|
288
|
+
export function normalizeWorktreeBranchPrefix(value: string | undefined): string {
|
|
289
|
+
const raw = value === undefined ? DEFAULT_WORKTREE_BRANCH_PREFIX : value.trim();
|
|
290
|
+
if (!raw) throw new Error("worktree branch prefix cannot be empty");
|
|
291
|
+
if (raw.includes("\\") || /[\u0000-\u001f\u007f\s]/u.test(raw) || raw.startsWith("/") || raw.includes("//") || raw.includes("..") || raw.includes("@{")) {
|
|
292
|
+
throw new Error("worktree branch prefix contains invalid Git ref characters");
|
|
293
|
+
}
|
|
294
|
+
const withoutTrailingSlash = raw.replace(/\/+$/u, "");
|
|
295
|
+
if (!withoutTrailingSlash || withoutTrailingSlash.startsWith("-") || withoutTrailingSlash.split("/").some((component) => component === "." || component === ".." || component.startsWith("."))) {
|
|
296
|
+
throw new Error("worktree branch prefix contains an invalid Git ref component");
|
|
297
|
+
}
|
|
298
|
+
const prefix = `${withoutTrailingSlash}/`;
|
|
299
|
+
if (!validGitRef(`${prefix}task`)) throw new Error("worktree branch prefix is not a valid Git ref namespace");
|
|
300
|
+
return prefix;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function runShortId(runId: string): string {
|
|
304
|
+
const base = runId.replace(/-s\d+$/u, "");
|
|
305
|
+
const normalized = sanitizeWorktreePathComponent(base || runId, 16);
|
|
306
|
+
return normalized.length > 12 ? normalized.slice(0, 12) : normalized;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function nonNegativeNamingIndex(value: number | undefined, label: string, fallback: number): number {
|
|
310
|
+
const resolved = value ?? fallback;
|
|
311
|
+
if (!Number.isSafeInteger(resolved) || resolved < 0) throw new Error(`${label} must be a non-negative integer`);
|
|
312
|
+
return resolved;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Build the shared branch identity used by native and Worktrunk allocation. */
|
|
316
|
+
export function buildWorktreeNaming(input: WorktreeNamingInput): WorktreeNaming {
|
|
317
|
+
if (!input.runId.trim()) throw new Error("worktree run id cannot be empty");
|
|
318
|
+
const index = nonNegativeNamingIndex(input.index, "worktree index", 0);
|
|
319
|
+
const stepIndex = nonNegativeNamingIndex(input.stepIndex, "worktree step index", Number(input.runId.match(/-s(\d+)$/u)?.[1] ?? 0));
|
|
320
|
+
const taskIndex = nonNegativeNamingIndex(input.taskIndex, "worktree task index", index);
|
|
321
|
+
const label = input.laneKey?.trim()
|
|
322
|
+
|| input.workflowKey?.trim()
|
|
323
|
+
|| input.outputName?.trim()
|
|
324
|
+
|| input.taskKey?.trim()
|
|
325
|
+
|| input.label?.trim()
|
|
326
|
+
|| (input.agent?.trim() && input.task?.trim() ? `${input.agent.trim()}-${input.task.trim()}` : undefined)
|
|
327
|
+
|| input.agent?.trim()
|
|
328
|
+
|| "task";
|
|
329
|
+
const branchPrefix = normalizeWorktreeBranchPrefix(input.branchPrefix);
|
|
330
|
+
const labelComponent = sanitizeWorktreePathComponent(label);
|
|
331
|
+
const pathComponentBase = `${labelComponent}-${runShortId(input.runId)}-s${stepIndex}-t${taskIndex}`;
|
|
332
|
+
const sanitizedPathComponent = validGitRef(`${branchPrefix}${pathComponentBase}`)
|
|
333
|
+
? pathComponentBase
|
|
334
|
+
: sanitizeWorktreePathComponent(pathComponentBase, WORKTREE_NAMING_COMPONENT_MAX_BYTES);
|
|
335
|
+
let requestedBranch = `${branchPrefix}${sanitizedPathComponent}`;
|
|
336
|
+
if (!validGitRef(requestedBranch) || Buffer.byteLength(requestedBranch, "utf-8") > WORKTREE_NAMING_BRANCH_MAX_BYTES) {
|
|
337
|
+
const suffix = `-${shortWorktreeHash(pathComponentBase)}`;
|
|
338
|
+
const available = Math.max(1, WORKTREE_NAMING_BRANCH_MAX_BYTES - Buffer.byteLength(branchPrefix, "utf-8") - Buffer.byteLength(suffix, "utf-8"));
|
|
339
|
+
requestedBranch = `${branchPrefix}${truncateUtf8(pathComponentBase, available).replace(/[._-]+$/g, "")}${suffix}`;
|
|
340
|
+
}
|
|
341
|
+
if (!validGitRef(requestedBranch) || Buffer.byteLength(requestedBranch, "utf-8") > WORKTREE_NAMING_BRANCH_MAX_BYTES) throw new Error(`generated worktree branch is not a valid Git ref: ${requestedBranch}`);
|
|
342
|
+
const metadataLabel = truncateUtf8(label.replace(/[\u0000-\u001f\u007f]+/gu, " ").replace(/\s+/gu, " ").trim() || "task", WORKTREE_NAMING_LABEL_MAX_BYTES);
|
|
343
|
+
return {
|
|
344
|
+
requestedBranch,
|
|
345
|
+
branchPrefix,
|
|
346
|
+
label: metadataLabel,
|
|
347
|
+
sanitizedPathComponent: requestedBranch.slice(branchPrefix.length),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function hasConfiguredWorktreeBaseDir(baseDir: string | undefined): boolean {
|
|
352
|
+
return baseDir !== undefined
|
|
353
|
+
? true
|
|
354
|
+
: (process.env.PI_SUBAGENTS_WORKTREE_DIR?.trim().length ?? 0) > 0;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
interface WorktrunkCapability {
|
|
358
|
+
available: boolean;
|
|
359
|
+
reason?: string;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
interface SourceCheckoutSnapshot {
|
|
363
|
+
head: string;
|
|
364
|
+
branch?: string;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function runWorktrunk(args: string[], cwd?: string): WorktreeCommandResult {
|
|
368
|
+
try {
|
|
369
|
+
const result = spawnSync("wt", args, {
|
|
370
|
+
cwd,
|
|
371
|
+
encoding: "utf-8",
|
|
372
|
+
windowsHide: true,
|
|
373
|
+
shell: false,
|
|
374
|
+
maxBuffer: WORKTREE_COMMAND_OUTPUT_MAX_BYTES,
|
|
375
|
+
});
|
|
376
|
+
const stdout = result.stdout ?? "";
|
|
377
|
+
const stderr = result.stderr ?? "";
|
|
378
|
+
if (Buffer.byteLength(stdout, "utf-8") > WORKTREE_COMMAND_OUTPUT_MAX_BYTES) throw new Error("Worktrunk stdout exceeds the output limit");
|
|
379
|
+
return { stdout, stderr, status: result.status, ...(result.error ? { error: result.error } : {}) };
|
|
380
|
+
} catch (error) {
|
|
381
|
+
return { stdout: "", stderr: "", status: null, error: error instanceof Error ? error : new Error(String(error)) };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function probeWorktrunk(): WorktrunkCapability {
|
|
386
|
+
const result = runWorktrunk(["--version"]);
|
|
387
|
+
if (result.status !== 0) return { available: false, reason: result.error?.message || result.stderr.trim() || "Worktrunk is unavailable" };
|
|
388
|
+
const version = result.stdout.trim().match(/\b(?:wt\s+)?v?(\d+\.\d+(?:\.\d+)?)\b/i)?.[1];
|
|
389
|
+
if (!version) return { available: false, reason: "Worktrunk returned an invalid version" };
|
|
390
|
+
const help = runWorktrunk(["switch", "--help"]);
|
|
391
|
+
if (help.status !== 0) return { available: false, reason: help.error?.message || help.stderr.trim() || "Worktrunk switch capability is unavailable" };
|
|
392
|
+
const helpText = `${help.stdout}\n${help.stderr}`;
|
|
393
|
+
const requiredCapabilities = ["--create", "--base", "--no-cd", "--no-hooks", "--format"];
|
|
394
|
+
const missing = requiredCapabilities.filter((flag) => !helpText.includes(flag));
|
|
395
|
+
if (missing.length > 0) return { available: false, reason: `Worktrunk switch is missing required capabilities: ${missing.join(", ")}` };
|
|
396
|
+
return { available: true };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function snapshotSourceCheckout(toplevel: string, head: string): SourceCheckoutSnapshot {
|
|
400
|
+
const branch = runGit(toplevel, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
401
|
+
const branchName = branch.status === 0 ? branch.stdout.trim() : "";
|
|
402
|
+
return branchName ? { head, branch: branchName } : { head };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function restoreSourceCheckoutIfWorktrunkSwitchedIt(toplevel: string, createdBranch: string, sourceCheckout: SourceCheckoutSnapshot): void {
|
|
406
|
+
const current = runGit(toplevel, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
407
|
+
if (current.status !== 0 || current.stdout.trim() !== createdBranch) return;
|
|
408
|
+
runGitChecked(toplevel, sourceCheckout.branch ? ["checkout", sourceCheckout.branch] : ["checkout", "--detach", sourceCheckout.head]);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Resolve a requested provider without silently switching after allocation starts. */
|
|
412
|
+
export function resolveWorktreeProvider(requested: WorktreeProvider | undefined, baseDir?: string): ManagedWorktreeProvider {
|
|
413
|
+
const selection = requested ?? DEFAULT_WORKTREE_PROVIDER;
|
|
414
|
+
if (selection !== "auto" && selection !== "native" && selection !== "worktrunk") throw new Error(`worktree provider must be "auto", "native", or "worktrunk"`);
|
|
415
|
+
if (selection === "native") return "native";
|
|
416
|
+
if (hasConfiguredWorktreeBaseDir(baseDir)) {
|
|
417
|
+
if (selection === "worktrunk") throw new Error("worktreeProvider='worktrunk' cannot be combined with worktreeBaseDir or PI_SUBAGENTS_WORKTREE_DIR");
|
|
418
|
+
return "native";
|
|
419
|
+
}
|
|
420
|
+
const capability = probeWorktrunk();
|
|
421
|
+
if (capability.available) return "worktrunk";
|
|
422
|
+
if (selection === "worktrunk") throw new Error(`Worktrunk provider is unavailable: ${capability.reason ?? "unknown capability failure"}`);
|
|
423
|
+
return "native";
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Whether a launch must bind its worktree-dependent paths after allocation. */
|
|
427
|
+
export function shouldDeferWorktreeCwd(requested: WorktreeProvider | undefined, baseDir?: string): boolean {
|
|
428
|
+
return (requested ?? DEFAULT_WORKTREE_PROVIDER) !== "native" && !hasConfiguredWorktreeBaseDir(baseDir);
|
|
197
429
|
}
|
|
198
430
|
|
|
199
431
|
function resolveWorktreeBaseDir(configuredBaseDir: string | undefined, repoRoot: string): string {
|
|
200
432
|
const rawBaseDir = configuredBaseDir ?? process.env.PI_SUBAGENTS_WORKTREE_DIR;
|
|
201
|
-
if (rawBaseDir === undefined) return os.tmpdir();
|
|
433
|
+
if (rawBaseDir === undefined || (configuredBaseDir === undefined && !rawBaseDir.trim())) return os.tmpdir();
|
|
202
434
|
|
|
203
435
|
const trimmed = rawBaseDir.trim();
|
|
204
436
|
if (!trimmed) throw new Error("worktree base directory cannot be empty");
|
|
@@ -219,8 +451,8 @@ function resolveWorktreeBaseDir(configuredBaseDir: string | undefined, repoRoot:
|
|
|
219
451
|
return resolved;
|
|
220
452
|
}
|
|
221
453
|
|
|
222
|
-
function
|
|
223
|
-
return path.join(baseDir, `pi-worktree-${runId}-${index}`);
|
|
454
|
+
function buildNativeWorktreePath(baseDir: string, runId: string, index: number): string {
|
|
455
|
+
return path.join(baseDir, `pi-worktree-${sanitizeWorktreePathComponent(runId, 120)}-${index}`);
|
|
224
456
|
}
|
|
225
457
|
|
|
226
458
|
function resolveRepoCwdRelative(cwd: string): string {
|
|
@@ -238,7 +470,7 @@ function resolveRepoCwdRelative(cwd: string): string {
|
|
|
238
470
|
export function resolveExpectedWorktreeAgentCwd(cwd: string, runId: string, index: number, baseDir?: string): string {
|
|
239
471
|
const cwdRelative = resolveRepoCwdRelative(cwd);
|
|
240
472
|
const repoRoot = runGitChecked(cwd, ["rev-parse", "--show-toplevel"]).trim();
|
|
241
|
-
const worktreePath =
|
|
473
|
+
const worktreePath = buildNativeWorktreePath(resolveWorktreeBaseDir(baseDir, repoRoot), runId, index);
|
|
242
474
|
return cwdRelative ? path.join(worktreePath, cwdRelative) : worktreePath;
|
|
243
475
|
}
|
|
244
476
|
|
|
@@ -381,58 +613,181 @@ function runWorktreeSetupHook(
|
|
|
381
613
|
return [...uniquePaths];
|
|
382
614
|
}
|
|
383
615
|
|
|
384
|
-
function
|
|
616
|
+
function finalizeCreatedWorktree(
|
|
385
617
|
toplevel: string,
|
|
386
618
|
cwdRelative: string,
|
|
387
619
|
runId: string,
|
|
388
|
-
index: number,
|
|
389
620
|
baseCommit: string,
|
|
390
621
|
setupHook: ResolvedWorktreeSetupHook | undefined,
|
|
391
622
|
agent: string | undefined,
|
|
392
|
-
|
|
623
|
+
worktree: WorktreeInfo,
|
|
393
624
|
): WorktreeInfo {
|
|
394
|
-
const
|
|
395
|
-
const worktreePath = buildWorktreePath(baseDir, runId, index);
|
|
396
|
-
const add = runGit(toplevel, ["worktree", "add", worktreePath, "-b", branch, "HEAD"]);
|
|
397
|
-
if (add.status !== 0) {
|
|
398
|
-
const message = add.stderr.trim() || add.stdout.trim() || `failed to create worktree ${worktreePath}`;
|
|
399
|
-
throw new Error(message);
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
const agentCwd = cwdRelative ? path.join(worktreePath, cwdRelative) : worktreePath;
|
|
625
|
+
const agentCwd = cwdRelative ? path.join(worktree.path, cwdRelative) : worktree.path;
|
|
403
626
|
try {
|
|
404
|
-
const nodeModulesLinked = linkNodeModulesIfPresent(toplevel,
|
|
627
|
+
const nodeModulesLinked = linkNodeModulesIfPresent(toplevel, worktree.path);
|
|
405
628
|
const syntheticPaths = nodeModulesLinked ? ["node_modules"] : [];
|
|
406
|
-
|
|
407
629
|
if (setupHook) {
|
|
408
630
|
const hookSyntheticPaths = runWorktreeSetupHook(setupHook, {
|
|
409
631
|
version: 1,
|
|
410
632
|
repoRoot: toplevel,
|
|
411
|
-
worktreePath,
|
|
633
|
+
worktreePath: worktree.path,
|
|
412
634
|
agentCwd,
|
|
413
|
-
branch,
|
|
414
|
-
index,
|
|
635
|
+
branch: worktree.branch,
|
|
636
|
+
index: worktree.index,
|
|
415
637
|
runId,
|
|
416
638
|
baseCommit,
|
|
417
639
|
agent,
|
|
418
640
|
});
|
|
419
641
|
syntheticPaths.push(...hookSyntheticPaths);
|
|
420
642
|
}
|
|
643
|
+
return { ...worktree, agentCwd, nodeModulesLinked, syntheticPaths };
|
|
644
|
+
} catch (error) {
|
|
645
|
+
try { runGitChecked(toplevel, ["worktree", "remove", "--force", worktree.path]); } catch {
|
|
646
|
+
// Best-effort rollback; preserve the original setup failure.
|
|
647
|
+
}
|
|
648
|
+
try { runGitChecked(toplevel, ["branch", "-D", worktree.branch]); } catch {
|
|
649
|
+
// Best-effort rollback; preserve the original setup failure.
|
|
650
|
+
}
|
|
651
|
+
throw error;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function createNativeWorktree(
|
|
656
|
+
toplevel: string,
|
|
657
|
+
cwdRelative: string,
|
|
658
|
+
runId: string,
|
|
659
|
+
index: number,
|
|
660
|
+
baseCommit: string,
|
|
661
|
+
setupHook: ResolvedWorktreeSetupHook | undefined,
|
|
662
|
+
agent: string | undefined,
|
|
663
|
+
baseDir: string,
|
|
664
|
+
labels: Array<string | undefined> | undefined,
|
|
665
|
+
tasks: Array<string | undefined> | undefined,
|
|
666
|
+
branchPrefix: string | undefined,
|
|
667
|
+
): WorktreeInfo {
|
|
668
|
+
const naming = buildWorktreeNaming({ runId, index, agent, label: labels?.[index], task: tasks?.[index], branchPrefix });
|
|
669
|
+
const worktreePath = buildNativeWorktreePath(baseDir, runId, index);
|
|
670
|
+
const add = runGit(toplevel, ["worktree", "add", worktreePath, "-b", naming.requestedBranch, baseCommit]);
|
|
671
|
+
if (add.status !== 0) {
|
|
672
|
+
const message = add.stderr.trim() || add.stdout.trim() || `failed to create worktree ${worktreePath}`;
|
|
673
|
+
throw new Error(message);
|
|
674
|
+
}
|
|
675
|
+
return finalizeCreatedWorktree(toplevel, cwdRelative, runId, baseCommit, setupHook, agent, {
|
|
676
|
+
path: worktreePath,
|
|
677
|
+
agentCwd: worktreePath,
|
|
678
|
+
branch: naming.requestedBranch,
|
|
679
|
+
index,
|
|
680
|
+
nodeModulesLinked: false,
|
|
681
|
+
syntheticPaths: [],
|
|
682
|
+
provider: "native",
|
|
683
|
+
naming,
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
interface WorktrunkSwitchOutput {
|
|
688
|
+
action?: unknown;
|
|
689
|
+
branch?: unknown;
|
|
690
|
+
path?: unknown;
|
|
691
|
+
created_branch?: unknown;
|
|
692
|
+
base_branch?: unknown;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function parseWorktrunkSwitchOutput(rawStdout: string): WorktrunkSwitchOutput {
|
|
696
|
+
if (Buffer.byteLength(rawStdout, "utf-8") > WORKTREE_COMMAND_OUTPUT_MAX_BYTES) throw new Error("Worktrunk provisioning output exceeds the output limit");
|
|
697
|
+
const trimmed = rawStdout.trim();
|
|
698
|
+
if (!trimmed) throw new Error("Worktrunk provisioning returned empty stdout; expected JSON object");
|
|
699
|
+
let parsed: unknown;
|
|
700
|
+
try {
|
|
701
|
+
parsed = JSON.parse(trimmed);
|
|
702
|
+
} catch (error) {
|
|
703
|
+
throw new Error(`Worktrunk provisioning returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
704
|
+
}
|
|
705
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Worktrunk provisioning stdout must be a JSON object");
|
|
706
|
+
return parsed as WorktrunkSwitchOutput;
|
|
707
|
+
}
|
|
421
708
|
|
|
709
|
+
function createWorktrunkWorktree(
|
|
710
|
+
toplevel: string,
|
|
711
|
+
cwdRelative: string,
|
|
712
|
+
runId: string,
|
|
713
|
+
index: number,
|
|
714
|
+
baseCommit: string,
|
|
715
|
+
agents: string[] | undefined,
|
|
716
|
+
labels: Array<string | undefined> | undefined,
|
|
717
|
+
tasks: Array<string | undefined> | undefined,
|
|
718
|
+
branchPrefix: string | undefined,
|
|
719
|
+
): WorktreeInfo {
|
|
720
|
+
const naming = buildWorktreeNaming({ runId, index, agent: agents?.[index], label: labels?.[index], task: tasks?.[index], branchPrefix });
|
|
721
|
+
const sourceCheckout = snapshotSourceCheckout(toplevel, baseCommit);
|
|
722
|
+
const args = ["-C", toplevel, "switch", "--create", naming.requestedBranch, "--base", baseCommit, "--no-cd", "--no-hooks", "--format", "json"];
|
|
723
|
+
const result = runWorktrunk(args, toplevel);
|
|
724
|
+
if (result.status !== 0) {
|
|
725
|
+
const message = result.error?.message || result.stderr.trim() || result.stdout.trim() || "Worktrunk provisioning failed";
|
|
726
|
+
throw new Error(`Worktrunk provisioning failed: ${message}`);
|
|
727
|
+
}
|
|
728
|
+
let createdAllocation = false;
|
|
729
|
+
let returnedPath: string | undefined;
|
|
730
|
+
try {
|
|
731
|
+
const output = parseWorktrunkSwitchOutput(result.stdout);
|
|
732
|
+
createdAllocation = output.action === "created" && output.created_branch === true;
|
|
733
|
+
if (typeof output.path === "string" && path.isAbsolute(output.path)) returnedPath = path.resolve(output.path);
|
|
734
|
+
if (!createdAllocation || output.branch !== naming.requestedBranch || output.base_branch !== baseCommit) {
|
|
735
|
+
throw new Error("Worktrunk provisioning returned inconsistent creation metadata");
|
|
736
|
+
}
|
|
737
|
+
if (typeof output.path !== "string" || !path.isAbsolute(output.path)) throw new Error("Worktrunk provisioning returned a non-absolute worktree path");
|
|
738
|
+
const worktreePathCandidate = returnedPath;
|
|
739
|
+
if (!worktreePathCandidate) throw new Error("Worktrunk provisioning returned a non-absolute worktree path");
|
|
740
|
+
let stat: fs.Stats;
|
|
741
|
+
try {
|
|
742
|
+
stat = fs.lstatSync(worktreePathCandidate);
|
|
743
|
+
} catch (error) {
|
|
744
|
+
throw new Error(`Worktrunk provisioning returned a missing worktree path: ${worktreePathCandidate}`, { cause: error instanceof Error ? error : undefined });
|
|
745
|
+
}
|
|
746
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Worktrunk provisioning returned a path that is not a real directory");
|
|
747
|
+
const worktreePath = normalizeComparableCwd(worktreePathCandidate);
|
|
748
|
+
if (worktreePath === normalizeComparableCwd(toplevel)) throw new Error("Worktrunk provisioning returned the source checkout path");
|
|
749
|
+
const sourceCommonDirRaw = runGitChecked(toplevel, ["rev-parse", "--git-common-dir"]).trim();
|
|
750
|
+
const returnedCommonDirRaw = runGitChecked(worktreePath, ["rev-parse", "--git-common-dir"]).trim();
|
|
751
|
+
const sourceCommonDir = normalizeComparableCwd(path.isAbsolute(sourceCommonDirRaw) ? sourceCommonDirRaw : path.resolve(toplevel, sourceCommonDirRaw));
|
|
752
|
+
const returnedCommonDir = normalizeComparableCwd(path.isAbsolute(returnedCommonDirRaw) ? returnedCommonDirRaw : path.resolve(worktreePath, returnedCommonDirRaw));
|
|
753
|
+
if (returnedCommonDir !== sourceCommonDir) throw new Error("Worktrunk provisioning returned a worktree for a different repository");
|
|
754
|
+
const returnedBranch = runGitChecked(worktreePath, ["symbolic-ref", "--quiet", "--short", "HEAD"]).trim();
|
|
755
|
+
if (returnedBranch !== naming.requestedBranch) throw new Error("Worktrunk provisioning returned a worktree on a different branch");
|
|
756
|
+
const returnedHead = runGitChecked(worktreePath, ["rev-parse", "HEAD"]).trim();
|
|
757
|
+
if (returnedHead !== baseCommit) throw new Error("Worktrunk provisioning returned a worktree at a different base commit");
|
|
422
758
|
return {
|
|
423
759
|
path: worktreePath,
|
|
424
|
-
agentCwd,
|
|
425
|
-
branch,
|
|
760
|
+
agentCwd: cwdRelative ? path.join(worktreePath, cwdRelative) : worktreePath,
|
|
761
|
+
branch: naming.requestedBranch,
|
|
426
762
|
index,
|
|
427
|
-
nodeModulesLinked,
|
|
428
|
-
syntheticPaths,
|
|
763
|
+
nodeModulesLinked: false,
|
|
764
|
+
syntheticPaths: [],
|
|
765
|
+
provider: "worktrunk",
|
|
766
|
+
naming,
|
|
429
767
|
};
|
|
430
768
|
} catch (error) {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
769
|
+
if (createdAllocation) {
|
|
770
|
+
try { restoreSourceCheckoutIfWorktrunkSwitchedIt(toplevel, naming.requestedBranch, sourceCheckout); } catch {
|
|
771
|
+
// Best-effort rollback; preserve the validation failure.
|
|
772
|
+
}
|
|
773
|
+
try {
|
|
774
|
+
const listedPath = findGitWorktreePath(toplevel, naming.requestedBranch);
|
|
775
|
+
const candidates = [listedPath, returnedPath].filter((candidate, candidateIndex, all): candidate is string => Boolean(candidate) && all.indexOf(candidate) === candidateIndex);
|
|
776
|
+
for (const candidate of candidates) {
|
|
777
|
+
if (normalizeComparableCwd(candidate) === normalizeComparableCwd(toplevel)) continue;
|
|
778
|
+
try {
|
|
779
|
+
runGitChecked(toplevel, ["worktree", "remove", "--force", candidate]);
|
|
780
|
+
break;
|
|
781
|
+
} catch {
|
|
782
|
+
// Try another provider-reported/listed path before giving up.
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
} catch {
|
|
786
|
+
// Best-effort rollback; preserve the validation failure.
|
|
787
|
+
}
|
|
788
|
+
try { runGitChecked(toplevel, ["branch", "-D", naming.requestedBranch]); } catch {
|
|
789
|
+
// Best-effort rollback; preserve the validation failure.
|
|
790
|
+
}
|
|
436
791
|
}
|
|
437
792
|
throw error;
|
|
438
793
|
}
|
|
@@ -579,6 +934,8 @@ function cleanupSingleWorktree(
|
|
|
579
934
|
index: worktree.index,
|
|
580
935
|
path: worktree.path,
|
|
581
936
|
branch: worktree.branch,
|
|
937
|
+
...(worktree.provider ? { provider: worktree.provider } : {}),
|
|
938
|
+
...(worktree.naming ? { naming: worktree.naming } : {}),
|
|
582
939
|
worktreeRemoved: false,
|
|
583
940
|
branchRemoved: false,
|
|
584
941
|
preserved: true,
|
|
@@ -601,6 +958,8 @@ function cleanupSingleWorktree(
|
|
|
601
958
|
index: worktree.index,
|
|
602
959
|
path: worktree.path,
|
|
603
960
|
branch: worktree.branch,
|
|
961
|
+
...(worktree.provider ? { provider: worktree.provider } : {}),
|
|
962
|
+
...(worktree.naming ? { naming: worktree.naming } : {}),
|
|
604
963
|
worktreeRemoved: false,
|
|
605
964
|
branchRemoved: false,
|
|
606
965
|
preserved: true,
|
|
@@ -622,6 +981,8 @@ function cleanupSingleWorktree(
|
|
|
622
981
|
index: worktree.index,
|
|
623
982
|
path: worktree.path,
|
|
624
983
|
branch: worktree.branch,
|
|
984
|
+
...(worktree.provider ? { provider: worktree.provider } : {}),
|
|
985
|
+
...(worktree.naming ? { naming: worktree.naming } : {}),
|
|
625
986
|
worktreeRemoved: false,
|
|
626
987
|
branchRemoved: false,
|
|
627
988
|
preserved: true,
|
|
@@ -641,6 +1002,8 @@ function cleanupSingleWorktree(
|
|
|
641
1002
|
index: worktree.index,
|
|
642
1003
|
path: worktree.path,
|
|
643
1004
|
branch: worktree.branch,
|
|
1005
|
+
...(worktree.provider ? { provider: worktree.provider } : {}),
|
|
1006
|
+
...(worktree.naming ? { naming: worktree.naming } : {}),
|
|
644
1007
|
worktreeRemoved: false,
|
|
645
1008
|
branchRemoved: false,
|
|
646
1009
|
preserved: true,
|
|
@@ -668,6 +1031,8 @@ function cleanupSingleWorktree(
|
|
|
668
1031
|
index: worktree.index,
|
|
669
1032
|
path: worktree.path,
|
|
670
1033
|
branch: worktree.branch,
|
|
1034
|
+
...(worktree.provider ? { provider: worktree.provider } : {}),
|
|
1035
|
+
...(worktree.naming ? { naming: worktree.naming } : {}),
|
|
671
1036
|
worktreeRemoved,
|
|
672
1037
|
branchRemoved,
|
|
673
1038
|
...(errors.length ? { errors } : {}),
|
|
@@ -679,39 +1044,78 @@ function hasWorktreeChanges(diff: WorktreeDiff): boolean {
|
|
|
679
1044
|
}
|
|
680
1045
|
|
|
681
1046
|
export function createWorktrees(cwd: string, runId: string, count: number, options?: CreateWorktreesOptions): WorktreeSetup {
|
|
1047
|
+
if (!Number.isSafeInteger(count) || count < 0) throw new Error("worktree count must be a non-negative integer");
|
|
682
1048
|
const repo = resolveRepoState(cwd);
|
|
683
1049
|
const setupHook = resolveWorktreeSetupHook(repo.toplevel, options?.setupHook);
|
|
684
|
-
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
baseCommit: repo.baseCommit,
|
|
688
|
-
worktrees: Array.from({ length: count }, (_, index) => {
|
|
689
|
-
const worktreePath = buildWorktreePath(baseDir, runId, index);
|
|
690
|
-
return {
|
|
691
|
-
path: worktreePath,
|
|
692
|
-
agentCwd: repo.cwdRelative ? path.join(worktreePath, repo.cwdRelative) : worktreePath,
|
|
693
|
-
branch: buildWorktreeBranch(runId, index),
|
|
694
|
-
index,
|
|
695
|
-
nodeModulesLinked: false,
|
|
696
|
-
syntheticPaths: [],
|
|
697
|
-
};
|
|
698
|
-
}),
|
|
699
|
-
};
|
|
700
|
-
options?.beforeCreate?.(plannedSetup);
|
|
1050
|
+
let provider = resolveWorktreeProvider(options?.provider, options?.baseDir);
|
|
1051
|
+
const branchPrefix = normalizeWorktreeBranchPrefix(options?.branchPrefix);
|
|
1052
|
+
let baseDir = provider === "native" ? resolveWorktreeBaseDir(options?.baseDir, repo.toplevel) : undefined;
|
|
701
1053
|
const worktrees: WorktreeInfo[] = [];
|
|
702
1054
|
|
|
703
1055
|
try {
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
repo.toplevel,
|
|
707
|
-
repo.
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
1056
|
+
if (provider === "native") {
|
|
1057
|
+
const plannedSetup: WorktreeSetup = {
|
|
1058
|
+
cwd: repo.toplevel,
|
|
1059
|
+
baseCommit: repo.baseCommit,
|
|
1060
|
+
worktrees: Array.from({ length: count }, (_, index) => {
|
|
1061
|
+
const naming = buildWorktreeNaming({ runId, index, agent: options?.agents?.[index], label: options?.labels?.[index], task: options?.tasks?.[index], branchPrefix });
|
|
1062
|
+
const worktreePath = buildNativeWorktreePath(baseDir!, runId, index);
|
|
1063
|
+
return {
|
|
1064
|
+
path: worktreePath,
|
|
1065
|
+
agentCwd: repo.cwdRelative ? path.join(worktreePath, repo.cwdRelative) : worktreePath,
|
|
1066
|
+
branch: naming.requestedBranch,
|
|
1067
|
+
index,
|
|
1068
|
+
nodeModulesLinked: false,
|
|
1069
|
+
syntheticPaths: [],
|
|
1070
|
+
provider: "native",
|
|
1071
|
+
naming,
|
|
1072
|
+
};
|
|
1073
|
+
}),
|
|
1074
|
+
};
|
|
1075
|
+
options?.beforeCreate?.(plannedSetup);
|
|
1076
|
+
for (let index = 0; index < count; index++) {
|
|
1077
|
+
worktrees.push(createNativeWorktree(
|
|
1078
|
+
repo.toplevel,
|
|
1079
|
+
repo.cwdRelative,
|
|
1080
|
+
runId,
|
|
1081
|
+
index,
|
|
1082
|
+
repo.baseCommit,
|
|
1083
|
+
setupHook,
|
|
1084
|
+
options?.agents?.[index],
|
|
1085
|
+
baseDir!,
|
|
1086
|
+
options?.labels,
|
|
1087
|
+
options?.tasks,
|
|
1088
|
+
branchPrefix,
|
|
1089
|
+
));
|
|
1090
|
+
}
|
|
1091
|
+
} else {
|
|
1092
|
+
for (let index = 0; index < count; index++) {
|
|
1093
|
+
worktrees.push(createWorktrunkWorktree(
|
|
1094
|
+
repo.toplevel,
|
|
1095
|
+
repo.cwdRelative,
|
|
1096
|
+
runId,
|
|
1097
|
+
index,
|
|
1098
|
+
repo.baseCommit,
|
|
1099
|
+
options?.agents,
|
|
1100
|
+
options?.labels,
|
|
1101
|
+
options?.tasks,
|
|
1102
|
+
branchPrefix,
|
|
1103
|
+
));
|
|
1104
|
+
}
|
|
1105
|
+
// Worktrunk determines its path only after creation. Journal the exact
|
|
1106
|
+
// returned ownership before Pi runs setup hooks or launches a child.
|
|
1107
|
+
options?.beforeCreate?.({ cwd: repo.toplevel, baseCommit: repo.baseCommit, worktrees: [...worktrees] });
|
|
1108
|
+
for (let index = 0; index < worktrees.length; index++) {
|
|
1109
|
+
worktrees[index] = finalizeCreatedWorktree(
|
|
1110
|
+
repo.toplevel,
|
|
1111
|
+
repo.cwdRelative,
|
|
1112
|
+
runId,
|
|
1113
|
+
repo.baseCommit,
|
|
1114
|
+
setupHook,
|
|
1115
|
+
options?.agents?.[index],
|
|
1116
|
+
worktrees[index]!,
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
715
1119
|
}
|
|
716
1120
|
} catch (error) {
|
|
717
1121
|
cleanupWorktrees({
|