pi-subagents 0.61.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/docs/agents.md +11 -6
  3. package/docs/configuration.md +31 -5
  4. package/docs/extension-api.md +2 -2
  5. package/docs/models.md +5 -5
  6. package/docs/observability.md +5 -2
  7. package/docs/tool-reference.md +3 -3
  8. package/install.mjs +0 -1
  9. package/package.json +1 -1
  10. package/skills/pi-subagents/references/execution-controls.md +3 -4
  11. package/skills/pi-subagents/references/management-authoring-rpc.md +1 -1
  12. package/skills/pi-subagents/references/prompting-and-roles.md +3 -3
  13. package/src/agents/agent-management.ts +41 -4
  14. package/src/agents/agent-serializer.ts +3 -0
  15. package/src/agents/agents.ts +120 -124
  16. package/src/agents/runtime-agent-registry.ts +5 -1
  17. package/src/api/preflight.ts +4 -0
  18. package/src/api/shared-types.ts +3 -0
  19. package/src/extension/config.ts +20 -0
  20. package/src/extension/public-execution.ts +1 -0
  21. package/src/extension/schemas.ts +6 -2
  22. package/src/extension/tool-description.ts +1 -1
  23. package/src/inspectors/herdr/inspector-runner.ts +19 -13
  24. package/src/runs/background/active-async-capacity.ts +26 -8
  25. package/src/runs/background/async-execution.ts +100 -23
  26. package/src/runs/background/async-resume.ts +6 -2
  27. package/src/runs/background/async-status.ts +18 -2
  28. package/src/runs/background/notify.ts +13 -1
  29. package/src/runs/background/process-terminal.ts +16 -0
  30. package/src/runs/background/run-status.ts +22 -2
  31. package/src/runs/background/scheduled-runs.ts +63 -6
  32. package/src/runs/background/steering.ts +4 -1
  33. package/src/runs/background/subagent-runner.ts +44 -9
  34. package/src/runs/background/wait-completions.ts +13 -0
  35. package/src/runs/background/wait-tool.ts +1 -7
  36. package/src/runs/foreground/execution.ts +14 -7
  37. package/src/runs/foreground/subagent-executor.ts +38 -5
  38. package/src/runs/shared/acceptance.ts +85 -18
  39. package/src/runs/shared/capability-ceiling.ts +1 -0
  40. package/src/runs/shared/dynamic-fanout.ts +1 -1
  41. package/src/runs/shared/lane-metadata.ts +24 -3
  42. package/src/runs/shared/parallel-handoff.ts +4 -0
  43. package/src/runs/shared/parallel-utils.ts +2 -6
  44. package/src/runs/shared/permissions.ts +1 -1
  45. package/src/runs/shared/pi-args.ts +32 -14
  46. package/src/runs/shared/pi-spawn.ts +69 -35
  47. package/src/runs/shared/structured-output.ts +33 -6
  48. package/src/runs/shared/subagent-prompt-runtime.ts +20 -3
  49. package/src/runs/shared/task-intent.ts +21 -7
  50. package/src/runs/shared/tool-timeout.ts +1 -1
  51. package/src/runs/shared/worktree.ts +467 -63
  52. package/src/shared/atomic-json.ts +3 -1
  53. package/src/shared/fork-context.ts +0 -12
  54. package/src/shared/fork-session-cwd.ts +27 -0
  55. package/src/shared/launch-contract.ts +3 -0
  56. package/src/shared/types.ts +32 -1
  57. package/src/shared/utils.ts +18 -7
  58. package/src/slash/slash-commands.ts +1 -1
  59. package/src/slash/subagents-admin.ts +26 -12
  60. package/src/tui/fleet-status.ts +61 -2
  61. package/src/tui/fleet.ts +12 -7
  62. package/src/tui/render.ts +222 -14
  63. 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 any worktree is created. */
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 buildWorktreeBranch(runId: string, index: number): string {
196
- return `pi-parallel-${runId}-${index}`;
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 buildWorktreePath(baseDir: string, runId: string, index: number): string {
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 = buildWorktreePath(resolveWorktreeBaseDir(baseDir, repoRoot), runId, index);
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 createSingleWorktree(
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
- baseDir: string,
623
+ worktree: WorktreeInfo,
393
624
  ): WorktreeInfo {
394
- const branch = buildWorktreeBranch(runId, index);
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, worktreePath);
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
- try { runGitChecked(toplevel, ["worktree", "remove", "--force", worktreePath]); } catch {
432
- // Best-effort rollback; preserve the original setup failure.
433
- }
434
- try { runGitChecked(toplevel, ["branch", "-D", branch]); } catch {
435
- // Best-effort rollback; preserve the original setup failure.
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
- const baseDir = resolveWorktreeBaseDir(options?.baseDir, repo.toplevel);
685
- const plannedSetup: WorktreeSetup = {
686
- cwd: repo.toplevel,
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
- for (let index = 0; index < count; index++) {
705
- worktrees.push(createSingleWorktree(
706
- repo.toplevel,
707
- repo.cwdRelative,
708
- runId,
709
- index,
710
- repo.baseCommit,
711
- setupHook,
712
- options?.agents?.[index],
713
- baseDir,
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({