pi-subagents 0.65.1 → 0.67.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 (148) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/README.md +5 -4
  3. package/agents/evidence-auditor.md +34 -0
  4. package/agents/researcher.md +23 -13
  5. package/agents/reviewer.md +3 -2
  6. package/docs/agents.md +20 -3
  7. package/docs/configuration.md +25 -5
  8. package/docs/extension-api.md +124 -18
  9. package/docs/missions.md +8 -0
  10. package/docs/models.md +59 -2
  11. package/docs/observability.md +46 -6
  12. package/docs/standalone-background.md +49 -0
  13. package/docs/tool-reference.md +20 -10
  14. package/docs/watchdog.md +35 -4
  15. package/docs/workflows.md +40 -19
  16. package/inspector-runner.mjs +2 -2
  17. package/package.json +2 -1
  18. package/prompts/parallel-review.md +1 -1
  19. package/{runner-server-preload.mjs → runner-peer-preload.mjs} +8 -3
  20. package/skills/pi-subagents/SKILL.md +14 -0
  21. package/skills/pi-subagents/references/execution-controls.md +20 -5
  22. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
  23. package/skills/pi-subagents/references/prompting-and-roles.md +2 -2
  24. package/src/agents/advertised-agent-prompt.ts +94 -0
  25. package/src/agents/agent-management.ts +14 -1
  26. package/src/agents/agent-serializer.ts +2 -0
  27. package/src/agents/agents.ts +14 -0
  28. package/src/agents/builtin-names.ts +1 -0
  29. package/src/api/delegation.ts +4 -0
  30. package/src/api/preflight.ts +76 -45
  31. package/src/api/shared-types.ts +3 -1
  32. package/src/api/workflow-resources.ts +6 -0
  33. package/src/extension/fanout-child.ts +63 -4
  34. package/src/extension/index.ts +58 -8
  35. package/src/extension/public-execution.ts +4 -3
  36. package/src/extension/rpc.ts +8 -21
  37. package/src/extension/schemas.ts +71 -80
  38. package/src/extension/tool-description.ts +29 -81
  39. package/src/inspectors/actions.ts +148 -0
  40. package/src/inspectors/ghostty/actions.ts +74 -0
  41. package/src/inspectors/ghostty/plugin.ts +17 -0
  42. package/src/inspectors/herdr/actions.ts +99 -179
  43. package/src/inspectors/herdr/plugin.ts +20 -0
  44. package/src/inspectors/herdr/project-panes.ts +1 -1
  45. package/src/inspectors/{herdr/inspector-runner.ts → inspector-runner.ts} +12 -12
  46. package/src/inspectors/plugins.ts +8 -0
  47. package/src/inspectors/{herdr/session-roots-codec.ts → session-roots-codec.ts} +3 -14
  48. package/src/inspectors/types.ts +51 -0
  49. package/src/intercom/intercom-bridge.ts +50 -8
  50. package/src/intercom/native-supervisor-channel.ts +104 -67
  51. package/src/runs/background/active-async-capacity.ts +22 -18
  52. package/src/runs/background/async-execution.ts +45 -56
  53. package/src/runs/background/async-job-tracker.ts +35 -3
  54. package/src/runs/background/async-resume.ts +5 -9
  55. package/src/runs/background/async-status-snapshot.ts +10 -12
  56. package/src/runs/background/async-status.ts +17 -9
  57. package/src/runs/background/auto-drain.ts +44 -30
  58. package/src/runs/background/binary-bootstrap.ts +33 -0
  59. package/src/runs/background/chain-root-attachment.ts +8 -0
  60. package/src/runs/background/control-channel.ts +78 -44
  61. package/src/runs/background/fleet-view.ts +30 -2
  62. package/src/runs/background/notify.ts +117 -13
  63. package/src/runs/background/owned-process-tree.ts +35 -8
  64. package/src/runs/background/process-terminal.ts +23 -23
  65. package/src/runs/background/run-child-session.ts +121 -36
  66. package/src/runs/background/run-status.ts +78 -5
  67. package/src/runs/background/runner-aliases.ts +28 -9
  68. package/src/runs/background/runner-child-launch.ts +88 -0
  69. package/src/runs/background/runner-child-sessions.ts +5 -4
  70. package/src/runs/background/scheduled-runs.ts +40 -13
  71. package/src/runs/background/stale-run-reconciler.ts +3 -1
  72. package/src/runs/background/steering.ts +20 -2
  73. package/src/runs/background/subagent-runner.ts +458 -239
  74. package/src/runs/background/subagent-wait.ts +54 -8
  75. package/src/runs/background/wait-completions.ts +4 -0
  76. package/src/runs/background/wait-tool.ts +1 -1
  77. package/src/runs/foreground/async-steering-action.ts +37 -7
  78. package/src/runs/foreground/execution.ts +145 -56
  79. package/src/runs/foreground/prompt-audit.ts +3 -1
  80. package/src/runs/foreground/subagent-executor.ts +584 -297
  81. package/src/runs/foreground/workflow-detach-reconcile.ts +10 -5
  82. package/src/runs/foreground/workflow-foreground-steering.ts +57 -2
  83. package/src/runs/shared/acceptance.ts +7 -4
  84. package/src/runs/shared/agent-contract.ts +1 -1
  85. package/src/runs/shared/async-status-projection.ts +51 -47
  86. package/src/runs/shared/capability-ceiling.ts +2 -0
  87. package/src/runs/shared/child-hooks.ts +167 -3
  88. package/src/runs/shared/child-launch.ts +28 -13
  89. package/src/runs/shared/child-lifecycle.ts +6 -3
  90. package/src/runs/shared/child-runtime-config.ts +3 -1
  91. package/src/runs/shared/child-session.ts +75 -8
  92. package/src/runs/shared/child-tool-plan.ts +124 -5
  93. package/src/runs/shared/completion-evidence.ts +2 -2
  94. package/src/runs/shared/completion-guard.ts +6 -3
  95. package/src/runs/shared/effective-system-prompt.ts +33 -0
  96. package/src/runs/shared/external-cli-runner.ts +9 -7
  97. package/src/runs/shared/host-step-status.ts +11 -11
  98. package/src/runs/shared/llm-intent-arbiter.ts +21 -11
  99. package/src/runs/shared/model-fallback.ts +12 -6
  100. package/src/runs/shared/nested-events.ts +5 -5
  101. package/src/runs/shared/orca-progress-tabs.ts +7 -1
  102. package/src/runs/shared/parallel-handoff.ts +57 -12
  103. package/src/runs/shared/parallel-utils.ts +2 -2
  104. package/src/runs/shared/pi-spawn.ts +10 -0
  105. package/src/runs/shared/readonly-drain-observation.ts +42 -0
  106. package/src/runs/shared/readonly-model-continuation.ts +69 -0
  107. package/src/runs/shared/readonly-session-evidence.ts +307 -0
  108. package/src/runs/shared/run-fanout-budget.ts +8 -8
  109. package/src/runs/shared/runtime-acknowledged-extensions.ts +3 -3
  110. package/src/runs/shared/subagent-prompt-runtime.ts +20 -4
  111. package/src/runs/shared/task-intent.ts +46 -13
  112. package/src/runs/shared/workflow-async-child-guidance.ts +18 -0
  113. package/src/runs/shared/worktree-setup-command.ts +190 -0
  114. package/src/runs/shared/worktree.ts +366 -208
  115. package/src/shared/fork-context.ts +15 -72
  116. package/src/shared/launch-contract.ts +65 -2
  117. package/src/shared/opencode-session-headers.ts +30 -0
  118. package/src/shared/types.ts +85 -61
  119. package/src/shared/utils.ts +7 -2
  120. package/src/shared/workflow-child-permit.ts +18 -13
  121. package/src/slash/delegation-adapters.ts +3 -1
  122. package/src/slash/delegation-request.ts +14 -0
  123. package/src/slash/slash-commands.ts +2 -1
  124. package/src/slash/subagents-admin.ts +11 -4
  125. package/src/tui/fleet-status.ts +164 -19
  126. package/src/tui/fleet.ts +27 -19
  127. package/src/tui/render.ts +172 -33
  128. package/src/watchdog/child-status.ts +8 -0
  129. package/src/watchdog/model-selection.ts +20 -0
  130. package/src/watchdog/permission-arbiter.ts +3 -1
  131. package/src/watchdog/register-child.ts +1 -0
  132. package/src/watchdog/register-main.ts +31 -27
  133. package/src/watchdog/review.ts +132 -67
  134. package/src/watchdog/runtime.ts +82 -20
  135. package/src/watchdog/scope.ts +1 -1
  136. package/src/watchdog/settings.ts +9 -3
  137. package/src/watchdog/tool-actions.ts +13 -12
  138. package/src/watchdog/turn-delta.ts +23 -0
  139. package/src/watchdog/types.ts +4 -0
  140. package/src/workflows/chat-progress.ts +3 -3
  141. package/src/workflows/scripted-workflow.ts +275 -17
  142. package/src/workflows/workflow-checklist.ts +13 -17
  143. package/src/workflows/workflow-child-summary.ts +57 -8
  144. package/src/workflows/workflow-preflight.ts +19 -19
  145. package/src/workflows/workflow-receipt.ts +3 -3
  146. package/src/workflows/workflow-resources.ts +96 -21
  147. package/src/workflows/workflow-settlement.ts +3 -0
  148. /package/src/inspectors/{herdr/shell-command.ts → shell-command.ts} +0 -0
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { spawnSync } from "node:child_process";
3
+ import { runSetupCommand, type SetupCommandOptions, type SetupCommandResult } from "./worktree-setup-command.ts";
3
4
  import * as fs from "node:fs";
4
5
  import * as os from "node:os";
5
6
  import * as path from "node:path";
@@ -17,6 +18,8 @@ const WORKTREE_NAMING_COMPONENT_MAX_BYTES = 96;
17
18
  const WORKTREE_NAMING_LABEL_MAX_BYTES = 256;
18
19
  const WORKTREE_NAMING_BRANCH_MAX_BYTES = 256;
19
20
  const WORKTREE_COMMAND_OUTPUT_MAX_BYTES = 128 * 1024;
21
+ const WORKTRUNK_COMMAND = process.platform === "win32" ? "git" : "wt";
22
+ const WORKTRUNK_ARG_PREFIX = process.platform === "win32" ? ["wt"] : [];
20
23
  export const MACHINE_DIFF_OPTIONS = ["--no-color", "--no-ext-diff", "--no-textconv", "--default-prefix", "--line-prefix=", "--no-relative"] as const;
21
24
  const MACHINE_PATCH_OPTIONS = [...MACHINE_DIFF_OPTIONS, "--binary"] as const;
22
25
  const PATCH_VALIDATION_OPTIONS = ["apply", "--check", "--cached", "--reverse", "--binary", "--whitespace=nowarn"] as const;
@@ -118,6 +121,10 @@ interface WorktreeSetupHookConfig {
118
121
  }
119
122
 
120
123
  export interface CreateWorktreesOptions {
124
+ signal?: AbortSignal;
125
+ /** Existing absolute run deadline only; setup does not start a child budget. */
126
+ deadlineAt?: number;
127
+ onProgress?: (snapshot: WorktreeSetupProgress) => void;
121
128
  agents?: string[];
122
129
  /** Optional stable labels used to make branch identity readable. */
123
130
  labels?: Array<string | undefined>;
@@ -131,8 +138,6 @@ export interface CreateWorktreesOptions {
131
138
  branchPrefix?: string;
132
139
  setupHook?: WorktreeSetupHookConfig;
133
140
  baseDir?: string;
134
- /** 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. */
135
- beforeCreate?: (setup: WorktreeSetup) => void;
136
141
  }
137
142
 
138
143
  interface ResolvedWorktreeSetupHook {
@@ -165,12 +170,110 @@ interface GitResult {
165
170
  interface RepoState {
166
171
  toplevel: string;
167
172
  cwdRelative: string;
168
- sourceCheckout: SourceCheckoutSnapshot;
169
173
  baseCommit: string;
170
174
  }
171
175
 
172
176
  const DEFAULT_WORKTREE_SETUP_HOOK_TIMEOUT_MS = 30000;
173
177
 
178
+ /** In-memory evidence only. Callers project this into existing handoff diagnostics. */
179
+ export interface WorktreeSetupProgress {
180
+ setup: WorktreeSetup;
181
+ attempts: Array<{ index: number; branch: string; path?: string; validated: boolean; command?: WorktreeSetupProgress["command"]; hookCommand?: WorktreeSetupProgress["command"] }>;
182
+ phase: string;
183
+ command?: { command: string; args: string[]; pid?: number; processGroupId?: number; result?: Omit<SetupCommandResult, "stdout" | "stderr"> };
184
+ unknown?: string;
185
+ cleanup?: WorktreeCleanupReport;
186
+ }
187
+
188
+ export class WorktreeSetupError extends Error {
189
+ readonly snapshot: WorktreeSetupProgress;
190
+ constructor(error: unknown, snapshot: WorktreeSetupProgress) {
191
+ super(`${error instanceof Error ? error.message : String(error)}${snapshot.unknown ? `; setup settlement unknown: ${snapshot.unknown}; manual reconciliation required` : ""}`, { cause: error });
192
+ this.name = "WorktreeSetupError";
193
+ this.snapshot = snapshot;
194
+ }
195
+ }
196
+
197
+ let worktreeTurn: Promise<void> = Promise.resolve();
198
+ let setupActive = false;
199
+ let setupPoison: string | undefined;
200
+
201
+ function assertWorktreeMutationAllowed(): void {
202
+ if (setupPoison) throw new Error(`Worktree mutation blocked: ${setupPoison}`);
203
+ if (setupActive) throw new Error("Worktree setup is active; finalization must await withWorktreeTransaction");
204
+ }
205
+
206
+ /** Later async finalization owners wrap their synchronous diff/cleanup as ONE turn. */
207
+ export async function withWorktreeTransaction<T>(action: () => T | Promise<T>): Promise<T> {
208
+ const previous = worktreeTurn;
209
+ let release!: () => void;
210
+ worktreeTurn = new Promise<void>((resolve) => { release = resolve; });
211
+ await previous;
212
+ try {
213
+ if (setupPoison) throw new Error(`Worktree mutation blocked: ${setupPoison}`);
214
+ return await action();
215
+ } finally { release(); }
216
+ }
217
+
218
+ class SetupTransaction {
219
+ readonly progress: WorktreeSetupProgress;
220
+ readonly options: CreateWorktreesOptions;
221
+ constructor(cwd: string, options: CreateWorktreesOptions) {
222
+ this.options = options;
223
+ this.progress = { setup: { cwd, baseCommit: "", worktrees: [] }, attempts: [], phase: "preflight" };
224
+ }
225
+ snapshot(): WorktreeSetupProgress {
226
+ return structuredClone(this.progress);
227
+ }
228
+ publish(): void { this.options.onProgress?.(this.snapshot()); }
229
+ check(): void {
230
+ if (setupPoison) throw new Error(setupPoison);
231
+ if (this.options.signal?.aborted) throw new Error("Worktree setup aborted");
232
+ if (this.options.deadlineAt !== undefined && Date.now() >= this.options.deadlineAt) throw new Error("Worktree setup deadline exceeded");
233
+ }
234
+ unknown(error: unknown): void {
235
+ const reason = error instanceof Error ? error.message : String(error);
236
+ this.progress.unknown ??= reason;
237
+ setupPoison ??= reason;
238
+ }
239
+ async command(command: string, args: string[], options: SetupCommandOptions = {}): Promise<SetupCommandResult> {
240
+ this.check();
241
+ this.progress.command = { command, args };
242
+ if (this.progress.phase === "allocation") this.progress.attempts.at(-1)!.command = this.progress.command;
243
+ if (this.progress.phase === "hook") this.progress.attempts.find((attempt) => attempt.path === options.cwd)!.hookCommand = this.progress.command;
244
+ const result = await runSetupCommand(command, args, {
245
+ ...options, signal: this.options.signal, deadlineAt: this.options.deadlineAt,
246
+ onSpawn: (process) => { Object.assign(this.progress.command!, process); this.publish(); },
247
+ });
248
+ const { stdout: _stdout, stderr: _stderr, ...metadata } = result;
249
+ this.progress.command.result = metadata;
250
+ if (result.processTree?.state === "unknown") this.unknown(result.error ?? "Command tree settlement unverified");
251
+ this.publish();
252
+ if (result.error) throw result.error;
253
+ return result;
254
+ }
255
+ async git(cwd: string, args: string[], acceptedExitCodes?: readonly number[]): Promise<SetupCommandResult> {
256
+ return this.command("git", ["-C", cwd, ...args], { acceptedExitCodes });
257
+ }
258
+ async gitChecked(cwd: string, args: string[]): Promise<string> {
259
+ const result = await this.git(cwd, args);
260
+ return result.stdout;
261
+ }
262
+ attempt(index: number, branch: string, worktreePath?: string): void {
263
+ this.check();
264
+ this.progress.phase = "allocation";
265
+ this.progress.command = undefined;
266
+ this.progress.attempts.push({ index, branch, path: worktreePath, validated: false });
267
+ this.publish();
268
+ }
269
+ validated(worktree: WorktreeInfo): void {
270
+ this.progress.phase = "validated";
271
+ Object.assign(this.progress.attempts.at(-1)!, { path: worktree.path, validated: true });
272
+ this.progress.setup.worktrees.push(worktree);
273
+ this.publish();
274
+ }
275
+ }
276
+
174
277
  function runGit(cwd: string, args: string[], env?: NodeJS.ProcessEnv): GitResult {
175
278
  const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8", windowsHide: true, shell: false, ...(env ? { env: { ...process.env, ...env } } : {}) });
176
279
  return {
@@ -198,6 +301,7 @@ export function validateWorktreePatch(worktreePath: string, patchPath: string):
198
301
  }
199
302
 
200
303
  function currentWorktreePatch(worktreePath: string, baseCommit: string): { patch: string } | { error: string } {
304
+ assertWorktreeMutationAllowed();
201
305
  let tempDir: string;
202
306
  try {
203
307
  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-worktree-index-"));
@@ -237,38 +341,49 @@ export function validateWorktreePatchRepresentsCurrentWorktree(worktreePath: str
237
341
  return undefined;
238
342
  }
239
343
 
240
- function findGitWorktreePath(cwd: string, branch: string): string | undefined {
241
- const targetBranch = `branch refs/heads/${branch}`;
242
- let currentPath: string | undefined;
243
- for (const line of runGitChecked(cwd, ["worktree", "list", "--porcelain"]).split("\n")) {
244
- if (line.startsWith("worktree ")) currentPath = line.slice("worktree ".length).trim();
245
- else if (line.trim() === targetBranch) return currentPath;
246
- }
247
- return undefined;
248
- }
249
-
250
- function resolveRepoState(cwd: string, requestedBaseRef: string | undefined): RepoState {
251
- const cwdRelative = resolveRepoCwdRelative(cwd);
252
- const toplevel = runGitChecked(cwd, ["rev-parse", "--show-toplevel"]).trim();
344
+ async function probeWorktreeSource(tx: Pick<SetupTransaction, "git" | "gitChecked">, cwd: string): Promise<string> {
345
+ const repoCheck = await tx.git(cwd, ["rev-parse", "--is-inside-work-tree"], [0, 128]);
346
+ if (repoCheck.status !== 0 || repoCheck.stdout.trim() !== "true") throw new Error("worktree isolation requires a git repository");
347
+ const toplevel = (await tx.gitChecked(cwd, ["rev-parse", "--show-toplevel"])).trim();
253
348
 
254
349
  // pi-subagents writes durable runtime state under .pi/subagents/ by default;
255
350
  // that state must not make managed isolation unusable for later runs.
256
- const status = runGitChecked(toplevel, ["status", "--porcelain", "--", `:!${PROJECT_SUBAGENTS_RELATIVE_DIR}`]);
351
+ const status = await tx.gitChecked(toplevel, ["status", "--porcelain", "--", `:!${PROJECT_SUBAGENTS_RELATIVE_DIR}`]);
257
352
  if (status.trim().length > 0) {
258
353
  throw new Error("worktree isolation requires a clean git working tree. Commit or stash changes first.");
259
354
  }
355
+ return toplevel;
356
+ }
357
+
358
+ /** Read-only admission check; allocation repeats it because source state can change. */
359
+ export async function preflightWorktreeSource(cwd: string, options: Pick<SetupCommandOptions, "signal" | "deadlineAt"> = {}): Promise<void> {
360
+ const git = async (cwd: string, args: string[], acceptedExitCodes: readonly number[] = [0]): Promise<SetupCommandResult> => {
361
+ const result = await runSetupCommand("git", ["-C", cwd, ...args], { ...options, acceptedExitCodes });
362
+ if (result.error) throw result.error;
363
+ if (result.outputIncomplete || result.status === null || !acceptedExitCodes.includes(result.status)) {
364
+ throw new Error(result.stderr.trim().slice(0, 2000) || "Worktree source probe failed");
365
+ }
366
+ return result;
367
+ };
368
+ await probeWorktreeSource({ git, gitChecked: async (cwd, args) => (await git(cwd, args)).stdout }, cwd);
369
+ }
370
+
371
+ async function resolveRepoState(tx: SetupTransaction, cwd: string, requestedBaseRef: string | undefined): Promise<RepoState> {
372
+ const toplevel = await probeWorktreeSource(tx, cwd);
373
+ const rawPrefix = (await tx.gitChecked(cwd, ["rev-parse", "--show-prefix"])).trim();
374
+ const cwdRelative = rawPrefix ? path.normalize(rawPrefix.replace(/[\\/]+$/, "")) : "";
260
375
 
261
- const sourceHead = runGitChecked(toplevel, ["rev-parse", "HEAD"]).trim();
262
- const sourceCheckout = snapshotSourceCheckout(toplevel, sourceHead);
263
376
  const baseRef = normalizeWorktreeBaseRef(requestedBaseRef) ?? DEFAULT_WORKTREE_BASE_REF;
264
377
  let baseCommit: string;
265
378
  try {
266
- baseCommit = runGitChecked(toplevel, ["rev-parse", "--verify", "--end-of-options", `${baseRef}^{commit}`]).trim();
379
+ const base = await tx.git(toplevel, ["rev-parse", "--verify", "--end-of-options", `${baseRef}^{commit}`], [0, 128]);
380
+ if (base.status !== 0) throw new Error(base.stderr.trim() || "ref not found");
381
+ baseCommit = base.stdout.trim();
267
382
  } catch (error) {
268
383
  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
384
  }
270
385
  if (!baseCommit) throw new Error(`baseRef '${baseRef}' could not be resolved to a commit`);
271
- return { toplevel, cwdRelative, sourceCheckout, baseCommit };
386
+ return { toplevel, cwdRelative, baseCommit };
272
387
  }
273
388
 
274
389
  function normalizeComparableCwd(cwd: string): string {
@@ -357,7 +472,7 @@ function validGitRef(ref: string): boolean {
357
472
  /** Normalize and validate a configured worktree base ref without resolving it. */
358
473
  export function normalizeWorktreeBaseRef(value: unknown): string | undefined {
359
474
  if (value === undefined) return undefined;
360
- if (typeof value !== "string" || !validGitRef(value)) throw new Error("baseRef must be a valid Git ref");
475
+ if (typeof value !== "string" || !validGitRef(value)) throw new Error("baseRef must be a valid Git ref: use HEAD or a supported named ref (for example, refs/heads/main). Full 40/64-character commit IDs and revision expressions are unsupported.");
361
476
  return value;
362
477
  }
363
478
 
@@ -431,19 +546,19 @@ function hasConfiguredWorktreeBaseDir(baseDir: string | undefined): boolean {
431
546
  : (process.env.PI_SUBAGENTS_WORKTREE_DIR?.trim().length ?? 0) > 0;
432
547
  }
433
548
 
549
+ function isInsidePiExtensionsDirectory(targetPath: string): boolean {
550
+ const extensionsDir = normalizeComparableCwd(path.join(getAgentDir(), "extensions"));
551
+ return isPathInside(extensionsDir, normalizeComparableCwd(targetPath));
552
+ }
553
+
434
554
  interface WorktrunkCapability {
435
555
  available: boolean;
436
556
  reason?: string;
437
557
  }
438
558
 
439
- interface SourceCheckoutSnapshot {
440
- head: string;
441
- branch?: string;
442
- }
443
-
444
559
  function runWorktrunk(args: string[], cwd?: string): WorktreeCommandResult {
445
560
  try {
446
- const result = spawnSync("wt", args, {
561
+ const result = spawnSync(WORKTRUNK_COMMAND, [...WORKTRUNK_ARG_PREFIX, ...args], {
447
562
  cwd,
448
563
  encoding: "utf-8",
449
564
  windowsHide: true,
@@ -473,18 +588,6 @@ function probeWorktrunk(): WorktrunkCapability {
473
588
  return { available: true };
474
589
  }
475
590
 
476
- function snapshotSourceCheckout(toplevel: string, head: string): SourceCheckoutSnapshot {
477
- const branch = runGit(toplevel, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
478
- const branchName = branch.status === 0 ? branch.stdout.trim() : "";
479
- return branchName ? { head, branch: branchName } : { head };
480
- }
481
-
482
- function restoreSourceCheckoutIfWorktrunkSwitchedIt(toplevel: string, createdBranch: string, sourceCheckout: SourceCheckoutSnapshot): void {
483
- const current = runGit(toplevel, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
484
- if (current.status !== 0 || current.stdout.trim() !== createdBranch) return;
485
- runGitChecked(toplevel, sourceCheckout.branch ? ["checkout", sourceCheckout.branch] : ["checkout", "--detach", sourceCheckout.head]);
486
- }
487
-
488
591
  /** Resolve a requested provider without silently switching after allocation starts. */
489
592
  export function resolveWorktreeProvider(requested: WorktreeProvider | undefined, baseDir?: string): ManagedWorktreeProvider {
490
593
  const selection = requested ?? DEFAULT_WORKTREE_PROVIDER;
@@ -500,6 +603,34 @@ export function resolveWorktreeProvider(requested: WorktreeProvider | undefined,
500
603
  return "native";
501
604
  }
502
605
 
606
+ async function resolveSetupProvider(tx: SetupTransaction, requested: WorktreeProvider | undefined, baseDir: string | undefined, repoRoot: string): Promise<ManagedWorktreeProvider> {
607
+ const selection = requested ?? DEFAULT_WORKTREE_PROVIDER;
608
+ if (selection !== "auto" && selection !== "native" && selection !== "worktrunk") throw new Error('worktree provider must be "auto", "native", or "worktrunk"');
609
+ if (selection === "native") return "native";
610
+ if (hasConfiguredWorktreeBaseDir(baseDir)) {
611
+ if (selection === "worktrunk") throw new Error("worktreeProvider='worktrunk' cannot be combined with worktreeBaseDir or PI_SUBAGENTS_WORKTREE_DIR");
612
+ return "native";
613
+ }
614
+ if (selection === "auto" && isInsidePiExtensionsDirectory(repoRoot)) return "native";
615
+ let reason: string | undefined;
616
+ try {
617
+ const probeExitCodes = Array.from({ length: 256 }, (_, code) => code);
618
+ const version = await tx.command(WORKTRUNK_COMMAND, [...WORKTRUNK_ARG_PREFIX, "--version"], { maxBuffer: WORKTREE_COMMAND_OUTPUT_MAX_BYTES, acceptedExitCodes: probeExitCodes });
619
+ if (version.status !== 0 || !/\b(?:wt\s+)?v?(\d+\.\d+(?:\.\d+)?)\b/i.test(version.stdout.trim())) reason = "Worktrunk is unavailable or returned an invalid version";
620
+ else {
621
+ const help = await tx.command(WORKTRUNK_COMMAND, [...WORKTRUNK_ARG_PREFIX, "switch", "--help"], { maxBuffer: WORKTREE_COMMAND_OUTPUT_MAX_BYTES, acceptedExitCodes: probeExitCodes });
622
+ const missing = ["--create", "--base", "--no-cd", "--no-hooks", "--format"].filter((flag) => !`${help.stdout}\n${help.stderr}`.includes(flag));
623
+ if (help.status !== 0 || missing.length) reason = `Worktrunk switch capability unavailable: ${missing.join(", ")}`;
624
+ }
625
+ } catch (error) {
626
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error;
627
+ reason = error.message;
628
+ }
629
+ if (!reason) return "worktrunk";
630
+ if (selection === "worktrunk") throw new Error(`Worktrunk provider is unavailable: ${reason}`);
631
+ return "native";
632
+ }
633
+
503
634
  /** Whether a launch must bind its worktree-dependent paths after allocation. */
504
635
  export function shouldDeferWorktreeCwd(requested: WorktreeProvider | undefined, baseDir?: string): boolean {
505
636
  return (requested ?? DEFAULT_WORKTREE_PROVIDER) !== "native" && !hasConfiguredWorktreeBaseDir(baseDir);
@@ -511,11 +642,13 @@ export function shouldDeferWorktreeCwd(requested: WorktreeProvider | undefined,
511
642
  * to the repository. Managed leaves always nest one level deeper under the
512
643
  * project folder (`basename(repoRoot)`).
513
644
  */
514
- function resolveWorktreeDedicatedRoot(configuredBaseDir: string | undefined, repoRoot: string): string {
645
+ function resolveWorktreeDedicatedRoot(configuredBaseDir: string | undefined, repoRoot: string, relocateExtensionRepo = true): string {
515
646
  const rawBaseDir = configuredBaseDir ?? process.env.PI_SUBAGENTS_WORKTREE_DIR;
516
647
  let expanded: string;
517
648
  if (rawBaseDir === undefined || (configuredBaseDir === undefined && !rawBaseDir.trim())) {
518
- expanded = path.join(path.dirname(repoRoot), "worktrees");
649
+ expanded = relocateExtensionRepo && isInsidePiExtensionsDirectory(repoRoot)
650
+ ? path.join(getAgentDir(), "worktrees")
651
+ : path.join(path.dirname(repoRoot), "worktrees");
519
652
  } else {
520
653
  const trimmed = rawBaseDir.trim();
521
654
  if (!trimmed) throw new Error("worktree base directory cannot be empty");
@@ -677,11 +810,6 @@ function normalizeSyntheticPath(worktreePath: string, rawPath: string): string {
677
810
  return path.normalize(relative);
678
811
  }
679
812
 
680
- function hasTrackedEntries(worktreePath: string, relativePath: string): boolean {
681
- const result = runGit(worktreePath, ["ls-files", "--", relativePath]);
682
- return result.status === 0 && result.stdout.trim().length > 0;
683
- }
684
-
685
813
  function parseWorktreeSetupHookOutput(rawStdout: string): WorktreeSetupHookOutput {
686
814
  const trimmed = rawStdout.trim();
687
815
  if (!trimmed) {
@@ -700,53 +828,49 @@ function parseWorktreeSetupHookOutput(rawStdout: string): WorktreeSetupHookOutpu
700
828
  return parsed as WorktreeSetupHookOutput;
701
829
  }
702
830
 
703
- function runWorktreeSetupHook(
831
+ async function runWorktreeSetupHook(
832
+ tx: SetupTransaction,
704
833
  hook: ResolvedWorktreeSetupHook,
705
834
  input: WorktreeSetupHookInput,
706
- ): string[] {
707
- const result = spawnSync(hook.hookPath, [], {
708
- windowsHide: true,
709
- cwd: input.worktreePath,
710
- encoding: "utf-8",
711
- input: JSON.stringify(input),
712
- timeout: hook.timeoutMs,
713
- shell: false,
714
- });
715
-
716
- if (result.error) {
717
- const code = "code" in result.error ? result.error.code : undefined;
718
- if (code === "ETIMEDOUT") {
719
- throw new Error(`worktree setup hook timed out after ${hook.timeoutMs}ms`);
720
- }
721
- throw new Error(`worktree setup hook failed: ${result.error.message}`);
722
- }
723
-
724
- if (result.status !== 0) {
725
- const details = result.stderr.trim() || result.stdout.trim() || "no output";
726
- throw new Error(`worktree setup hook failed with exit code ${result.status}: ${details}`);
727
- }
728
-
729
- const output = parseWorktreeSetupHookOutput(result.stdout);
730
- if (output.syntheticPaths === undefined) return [];
731
- if (!Array.isArray(output.syntheticPaths)) {
732
- throw new Error("worktree setup hook output field 'syntheticPaths' must be an array of relative paths");
835
+ ): Promise<string[]> {
836
+ tx.progress.phase = "hook";
837
+ let result: SetupCommandResult;
838
+ try {
839
+ result = await tx.command(hook.hookPath, [], {
840
+ cwd: input.worktreePath,
841
+ input: JSON.stringify(input),
842
+ hookTimeoutMs: hook.timeoutMs,
843
+ });
844
+ } catch (error) {
845
+ throw new Error(`worktree setup hook failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
733
846
  }
847
+ tx.progress.phase = "hook-validation";
734
848
 
735
- const uniquePaths = new Set<string>();
736
- for (const candidate of output.syntheticPaths) {
737
- if (typeof candidate !== "string") {
738
- throw new Error("worktree setup hook output field 'syntheticPaths' must contain only strings");
849
+ try {
850
+ const output = parseWorktreeSetupHookOutput(result.stdout);
851
+ if (output.syntheticPaths === undefined) return [];
852
+ if (!Array.isArray(output.syntheticPaths)) {
853
+ throw new Error("worktree setup hook output field 'syntheticPaths' must be an array of relative paths");
739
854
  }
740
- const normalizedPath = normalizeSyntheticPath(input.worktreePath, candidate);
741
- if (hasTrackedEntries(input.worktreePath, normalizedPath)) {
742
- throw new Error(`worktree setup hook cannot mark tracked paths as synthetic: ${normalizedPath}`);
855
+ const uniquePaths = new Set<string>();
856
+ for (const candidate of output.syntheticPaths) {
857
+ if (typeof candidate !== "string") throw new Error("worktree setup hook output field 'syntheticPaths' must contain only strings");
858
+ const normalizedPath = normalizeSyntheticPath(input.worktreePath, candidate);
859
+ if ((await tx.gitChecked(input.worktreePath, ["ls-files", "--", normalizedPath])).trim()) {
860
+ throw new Error(`worktree setup hook cannot mark tracked paths as synthetic: ${normalizedPath}`);
861
+ }
862
+ uniquePaths.add(normalizedPath);
743
863
  }
744
- uniquePaths.add(normalizedPath);
864
+ return [...uniquePaths];
865
+ } catch (error) {
866
+ // Semantic rejection occurs after exit: retain uncertainty, never signal an old PID.
867
+ tx.unknown(error);
868
+ throw error;
745
869
  }
746
- return [...uniquePaths];
747
870
  }
748
871
 
749
- function finalizeCreatedWorktree(
872
+ async function finalizeCreatedWorktree(
873
+ tx: SetupTransaction,
750
874
  toplevel: string,
751
875
  cwdRelative: string,
752
876
  runId: string,
@@ -754,38 +878,23 @@ function finalizeCreatedWorktree(
754
878
  setupHook: ResolvedWorktreeSetupHook | undefined,
755
879
  agent: string | undefined,
756
880
  worktree: WorktreeInfo,
757
- ): WorktreeInfo {
881
+ ): Promise<WorktreeInfo> {
758
882
  const agentCwd = cwdRelative ? path.join(worktree.path, cwdRelative) : worktree.path;
759
- try {
760
- const nodeModulesLinked = linkNodeModulesIfPresent(toplevel, worktree.path);
761
- const syntheticPaths = nodeModulesLinked ? ["node_modules"] : [];
762
- if (setupHook) {
763
- const hookSyntheticPaths = runWorktreeSetupHook(setupHook, {
764
- version: 1,
765
- repoRoot: toplevel,
766
- worktreePath: worktree.path,
767
- agentCwd,
768
- branch: worktree.branch,
769
- index: worktree.index,
770
- runId,
771
- baseCommit,
772
- agent,
773
- });
774
- syntheticPaths.push(...hookSyntheticPaths);
775
- }
776
- return { ...worktree, agentCwd, nodeModulesLinked, syntheticPaths };
777
- } catch (error) {
778
- try { runGitChecked(toplevel, ["worktree", "remove", "--force", worktree.path]); } catch {
779
- // Best-effort rollback; preserve the original setup failure.
780
- }
781
- try { runGitChecked(toplevel, ["branch", "-D", worktree.branch]); } catch {
782
- // Best-effort rollback; preserve the original setup failure.
783
- }
784
- throw error;
883
+ tx.check();
884
+ const nodeModulesLinked = linkNodeModulesIfPresent(toplevel, worktree.path);
885
+ const syntheticPaths = nodeModulesLinked ? ["node_modules"] : [];
886
+ if (setupHook) {
887
+ const hookSyntheticPaths = await runWorktreeSetupHook(tx, setupHook, {
888
+ version: 1, repoRoot: toplevel, worktreePath: worktree.path, agentCwd,
889
+ branch: worktree.branch, index: worktree.index, runId, baseCommit, agent,
890
+ });
891
+ syntheticPaths.push(...hookSyntheticPaths);
785
892
  }
893
+ return { ...worktree, agentCwd, nodeModulesLinked, syntheticPaths };
786
894
  }
787
895
 
788
- function createNativeWorktree(
896
+ async function createNativeWorktree(
897
+ tx: SetupTransaction,
789
898
  toplevel: string,
790
899
  cwdRelative: string,
791
900
  runId: string,
@@ -797,17 +906,20 @@ function createNativeWorktree(
797
906
  labels: Array<string | undefined> | undefined,
798
907
  tasks: Array<string | undefined> | undefined,
799
908
  branchPrefix: string | undefined,
800
- ): WorktreeInfo {
909
+ ): Promise<WorktreeInfo> {
801
910
  const naming = buildWorktreeNaming({ runId, index, agent, label: labels?.[index], task: tasks?.[index], branchPrefix });
802
911
  const worktreePath = buildNativeWorktreePath(dedicatedRoot, toplevel, runId, index);
803
912
  assertSafeWorktreeLocation(worktreePath, toplevel, dedicatedRoot);
913
+ // Absence is required before claiming any partial artifacts from this attempt.
914
+ const branch = await tx.git(toplevel, ["show-ref", "--verify", "--quiet", `refs/heads/${naming.requestedBranch}`], [0, 1]);
915
+ let pathExists = false;
916
+ try { fs.lstatSync(worktreePath); pathExists = true; }
917
+ catch (error) { if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error; }
918
+ if (branch.status !== 1 || pathExists) throw new Error(`worktree path or branch already exists: ${worktreePath}, ${naming.requestedBranch}`);
919
+ tx.attempt(index, naming.requestedBranch, worktreePath);
804
920
  ensureProjectWorktreeDir(dedicatedRoot, toplevel);
805
- const add = runGit(toplevel, ["worktree", "add", worktreePath, "-b", naming.requestedBranch, baseCommit]);
806
- if (add.status !== 0) {
807
- const message = add.stderr.trim() || add.stdout.trim() || `failed to create worktree ${worktreePath}`;
808
- throw new Error(message);
809
- }
810
- return finalizeCreatedWorktree(toplevel, cwdRelative, runId, baseCommit, setupHook, agent, {
921
+ await tx.git(toplevel, ["worktree", "add", worktreePath, "-b", naming.requestedBranch, baseCommit]);
922
+ const worktree: WorktreeInfo = {
811
923
  path: worktreePath,
812
924
  agentCwd: worktreePath,
813
925
  branch: naming.requestedBranch,
@@ -816,7 +928,9 @@ function createNativeWorktree(
816
928
  syntheticPaths: [],
817
929
  provider: "native",
818
930
  naming,
819
- });
931
+ };
932
+ tx.validated(worktree);
933
+ return finalizeCreatedWorktree(tx, toplevel, cwdRelative, runId, baseCommit, setupHook, agent, worktree);
820
934
  }
821
935
 
822
936
  interface WorktrunkSwitchOutput {
@@ -841,37 +955,30 @@ function parseWorktrunkSwitchOutput(rawStdout: string): WorktrunkSwitchOutput {
841
955
  return parsed as WorktrunkSwitchOutput;
842
956
  }
843
957
 
844
- function createWorktrunkWorktree(
958
+ async function createWorktrunkWorktree(
959
+ tx: SetupTransaction,
845
960
  toplevel: string,
846
961
  cwdRelative: string,
847
962
  runId: string,
848
963
  index: number,
849
964
  baseCommit: string,
850
- sourceCheckout: SourceCheckoutSnapshot,
851
965
  agents: string[] | undefined,
852
966
  labels: Array<string | undefined> | undefined,
853
967
  tasks: Array<string | undefined> | undefined,
854
968
  branchPrefix: string | undefined,
855
- ): WorktreeInfo {
969
+ ): Promise<WorktreeInfo> {
856
970
  const naming = buildWorktreeNaming({ runId, index, agent: agents?.[index], label: labels?.[index], task: tasks?.[index], branchPrefix });
857
971
  const args = ["-C", toplevel, "switch", "--create", naming.requestedBranch, "--base", baseCommit, "--no-cd", "--no-hooks", "--format", "json"];
858
- const result = runWorktrunk(args, toplevel);
859
- if (result.status !== 0) {
860
- const message = result.error?.message || result.stderr.trim() || result.stdout.trim() || "Worktrunk provisioning failed";
861
- throw new Error(`Worktrunk provisioning failed: ${message}`);
862
- }
863
- let createdAllocation = false;
864
- let returnedPath: string | undefined;
972
+ tx.attempt(index, naming.requestedBranch);
973
+ const result = await tx.command(WORKTRUNK_COMMAND, [...WORKTRUNK_ARG_PREFIX, ...args], { cwd: toplevel, maxBuffer: WORKTREE_COMMAND_OUTPUT_MAX_BYTES });
974
+ tx.progress.phase = "validation";
865
975
  try {
866
976
  const output = parseWorktrunkSwitchOutput(result.stdout);
867
- createdAllocation = output.action === "created" && output.created_branch === true;
868
- if (typeof output.path === "string" && path.isAbsolute(output.path)) returnedPath = path.resolve(output.path);
869
- if (!createdAllocation || output.branch !== naming.requestedBranch || output.base_branch !== baseCommit) {
977
+ if (output.action !== "created" || output.created_branch !== true || output.branch !== naming.requestedBranch || output.base_branch !== baseCommit) {
870
978
  throw new Error("Worktrunk provisioning returned inconsistent creation metadata");
871
979
  }
872
980
  if (typeof output.path !== "string" || !path.isAbsolute(output.path)) throw new Error("Worktrunk provisioning returned a non-absolute worktree path");
873
- const worktreePathCandidate = returnedPath;
874
- if (!worktreePathCandidate) throw new Error("Worktrunk provisioning returned a non-absolute worktree path");
981
+ const worktreePathCandidate = path.resolve(output.path);
875
982
  let stat: fs.Stats;
876
983
  try {
877
984
  stat = fs.lstatSync(worktreePathCandidate);
@@ -881,16 +988,16 @@ function createWorktrunkWorktree(
881
988
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Worktrunk provisioning returned a path that is not a real directory");
882
989
  const worktreePath = normalizeComparableCwd(worktreePathCandidate);
883
990
  if (worktreePath === normalizeComparableCwd(toplevel)) throw new Error("Worktrunk provisioning returned the source checkout path");
884
- const sourceCommonDirRaw = runGitChecked(toplevel, ["rev-parse", "--git-common-dir"]).trim();
885
- const returnedCommonDirRaw = runGitChecked(worktreePath, ["rev-parse", "--git-common-dir"]).trim();
991
+ const sourceCommonDirRaw = (await tx.gitChecked(toplevel, ["rev-parse", "--git-common-dir"])).trim();
992
+ const returnedCommonDirRaw = (await tx.gitChecked(worktreePath, ["rev-parse", "--git-common-dir"])).trim();
886
993
  const sourceCommonDir = normalizeComparableCwd(path.isAbsolute(sourceCommonDirRaw) ? sourceCommonDirRaw : path.resolve(toplevel, sourceCommonDirRaw));
887
994
  const returnedCommonDir = normalizeComparableCwd(path.isAbsolute(returnedCommonDirRaw) ? returnedCommonDirRaw : path.resolve(worktreePath, returnedCommonDirRaw));
888
995
  if (returnedCommonDir !== sourceCommonDir) throw new Error("Worktrunk provisioning returned a worktree for a different repository");
889
- const returnedBranch = runGitChecked(worktreePath, ["symbolic-ref", "--quiet", "--short", "HEAD"]).trim();
996
+ const returnedBranch = (await tx.gitChecked(worktreePath, ["symbolic-ref", "--quiet", "--short", "HEAD"])).trim();
890
997
  if (returnedBranch !== naming.requestedBranch) throw new Error("Worktrunk provisioning returned a worktree on a different branch");
891
- const returnedHead = runGitChecked(worktreePath, ["rev-parse", "HEAD"]).trim();
998
+ const returnedHead = (await tx.gitChecked(worktreePath, ["rev-parse", "HEAD"])).trim();
892
999
  if (returnedHead !== baseCommit) throw new Error("Worktrunk provisioning returned a worktree at a different base commit");
893
- return {
1000
+ const worktree: WorktreeInfo = {
894
1001
  path: worktreePath,
895
1002
  agentCwd: cwdRelative ? path.join(worktreePath, cwdRelative) : worktreePath,
896
1003
  branch: naming.requestedBranch,
@@ -900,35 +1007,16 @@ function createWorktrunkWorktree(
900
1007
  provider: "worktrunk",
901
1008
  naming,
902
1009
  };
1010
+ tx.validated(worktree);
1011
+ return worktree;
903
1012
  } catch (error) {
904
- if (createdAllocation) {
905
- try { restoreSourceCheckoutIfWorktrunkSwitchedIt(toplevel, naming.requestedBranch, sourceCheckout); } catch {
906
- // Best-effort rollback; preserve the validation failure.
907
- }
908
- try {
909
- const listedPath = findGitWorktreePath(toplevel, naming.requestedBranch);
910
- const candidates = [listedPath, returnedPath].filter((candidate, candidateIndex, all): candidate is string => Boolean(candidate) && all.indexOf(candidate) === candidateIndex);
911
- for (const candidate of candidates) {
912
- if (normalizeComparableCwd(candidate) === normalizeComparableCwd(toplevel)) continue;
913
- try {
914
- runGitChecked(toplevel, ["worktree", "remove", "--force", candidate]);
915
- break;
916
- } catch {
917
- // Try another provider-reported/listed path before giving up.
918
- }
919
- }
920
- } catch {
921
- // Best-effort rollback; preserve the validation failure.
922
- }
923
- try { runGitChecked(toplevel, ["branch", "-D", naming.requestedBranch]); } catch {
924
- // Best-effort rollback; preserve the validation failure.
925
- }
926
- }
1013
+ tx.unknown(error);
927
1014
  throw error;
928
1015
  }
929
1016
  }
930
1017
 
931
1018
  function removeSyntheticPath(worktree: WorktreeInfo, syntheticPath: string): void {
1019
+ assertWorktreeMutationAllowed();
932
1020
  const resolved = path.resolve(worktree.path, syntheticPath);
933
1021
  const relative = path.relative(worktree.path, resolved);
934
1022
  if (!relative || relative === "." || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
@@ -1193,39 +1281,96 @@ function hasWorktreeChanges(diff: WorktreeDiff): boolean {
1193
1281
  return diff.filesChanged > 0 || diff.insertions > 0 || diff.deletions > 0 || diff.diffStat.trim().length > 0;
1194
1282
  }
1195
1283
 
1196
- export function createWorktrees(cwd: string, runId: string, count: number, options?: CreateWorktreesOptions): WorktreeSetup {
1284
+ async function compensateSetup(tx: SetupTransaction): Promise<WorktreeCleanupReport> {
1285
+ const { setup, attempts } = tx.progress;
1286
+ const report: WorktreeCleanupReport = { state: "partial", tasks: [], pruned: false, errors: [] };
1287
+ tx.progress.cleanup = report;
1288
+ tx.progress.phase = "rollback";
1289
+ // Cancellation stops allocation, not accounting. Compensation has ONLY the
1290
+ // remaining original absolute deadline; it does not reuse the launch signal.
1291
+ const git = async (args: string[], acceptedExitCodes: readonly number[] = [0]) => {
1292
+ if (setupPoison) throw new Error(setupPoison);
1293
+ tx.progress.command = { command: "git", args: ["-C", setup.cwd, ...args] };
1294
+ const result = await runSetupCommand("git", tx.progress.command.args, {
1295
+ deadlineAt: tx.options.deadlineAt, acceptedExitCodes,
1296
+ onSpawn: (process) => { Object.assign(tx.progress.command!, process); tx.publish(); },
1297
+ });
1298
+ const { stdout: _stdout, stderr: _stderr, ...metadata } = result;
1299
+ tx.progress.command.result = metadata;
1300
+ if (result.processTree?.state === "unknown") tx.unknown(result.error ?? "Rollback command settlement unverified");
1301
+ tx.publish();
1302
+ if (result.error || result.status === null || !acceptedExitCodes.includes(result.status)) throw result.error ?? new Error(result.stderr.trim() || "Rollback command failed");
1303
+ return result;
1304
+ };
1305
+ for (const attempt of [...attempts].reverse()) {
1306
+ if (!attempt.path) {
1307
+ tx.unknown(`Allocation path unconfirmed for branch ${attempt.branch}`);
1308
+ report.errors!.push(tx.progress.unknown!);
1309
+ continue;
1310
+ }
1311
+ const known = setup.worktrees.find((worktree) => worktree.index === attempt.index);
1312
+ const task: WorktreeCleanupTask = { index: attempt.index, path: attempt.path, branch: attempt.branch,
1313
+ provider: known?.provider ?? "native", naming: known?.naming, worktreeRemoved: false, branchRemoved: false };
1314
+ report.tasks.push(task);
1315
+ try {
1316
+ if (normalizeComparableCwd(attempt.path) === normalizeComparableCwd(setup.cwd)) throw new Error("Refusing source-checkout removal");
1317
+ if (attempt.validated) {
1318
+ await git(["worktree", "remove", "--force", attempt.path]);
1319
+ task.worktreeRemoved = true;
1320
+ } else {
1321
+ // Native attempts were absent before mutation. Reconcile only exact
1322
+ // registered path/branch identity; never recursively delete a guessed leaf.
1323
+ const listed = (await git(["worktree", "list", "--porcelain"])).stdout;
1324
+ const entry = listed.split("\n\n").find((block) => block.split("\n").includes(`worktree ${attempt.path}`));
1325
+ if (entry) {
1326
+ if (!entry.split("\n").includes(`branch refs/heads/${attempt.branch}`)) {
1327
+ tx.unknown("Partial allocation ownership mismatch");
1328
+ throw new Error(tx.progress.unknown);
1329
+ }
1330
+ await git(["worktree", "remove", "--force", attempt.path]);
1331
+ } else if (fs.existsSync(attempt.path)) {
1332
+ tx.unknown("Unregistered partial allocation retained");
1333
+ throw new Error(tx.progress.unknown);
1334
+ }
1335
+ task.worktreeRemoved = true;
1336
+ }
1337
+ const branch = await git(["show-ref", "--verify", "--quiet", `refs/heads/${attempt.branch}`], [0, 1]);
1338
+ if (branch.status === 0) await git(["branch", "-D", attempt.branch]);
1339
+ task.branchRemoved = true;
1340
+ } catch (error) {
1341
+ task.preserved = true;
1342
+ task.errors = [error instanceof Error ? error.message : String(error)];
1343
+ }
1344
+ }
1345
+ try {
1346
+ if (attempts.length > 0) { await git(["worktree", "prune"]); report.pruned = true; }
1347
+ } catch (error) { report.errors!.push(error instanceof Error ? error.message : String(error)); }
1348
+ if (tx.progress.unknown) report.errors!.push(tx.progress.unknown);
1349
+ report.tasks.sort((a, b) => a.index - b.index);
1350
+ if (!setupPoison && (report.pruned || attempts.length === 0) && report.tasks.every((task) => task.worktreeRemoved && task.branchRemoved) && !report.errors!.length) report.state = "complete";
1351
+ return report;
1352
+ }
1353
+
1354
+ async function allocateWorktrees(tx: SetupTransaction, cwd: string, runId: string, count: number): Promise<WorktreeSetup> {
1355
+ const options = tx.options;
1356
+ tx.check();
1197
1357
  if (!Number.isSafeInteger(count) || count < 0) throw new Error("worktree count must be a non-negative integer");
1198
- const repo = resolveRepoState(cwd, options?.baseRef);
1358
+ const repo = await resolveRepoState(tx, cwd, options?.baseRef);
1359
+ tx.progress.setup.cwd = repo.toplevel;
1360
+ tx.progress.setup.baseCommit = repo.baseCommit;
1199
1361
  const setupHook = resolveWorktreeSetupHook(repo.toplevel, options?.setupHook);
1200
- const provider = resolveWorktreeProvider(options?.provider, options?.baseDir);
1362
+ const provider = await resolveSetupProvider(tx, options?.provider, options?.baseDir, repo.toplevel);
1201
1363
  const branchPrefix = normalizeWorktreeBranchPrefix(options?.branchPrefix);
1202
- const dedicatedRoot = provider === "native" ? resolveWorktreeDedicatedRoot(options?.baseDir, repo.toplevel) : undefined;
1203
- const worktrees: WorktreeInfo[] = [];
1364
+ const dedicatedRoot = provider === "native"
1365
+ ? resolveWorktreeDedicatedRoot(options?.baseDir, repo.toplevel, (options?.provider ?? DEFAULT_WORKTREE_PROVIDER) === "auto")
1366
+ : undefined;
1367
+ const worktrees = tx.progress.setup.worktrees;
1204
1368
 
1205
1369
  try {
1206
1370
  if (provider === "native") {
1207
- const plannedSetup: WorktreeSetup = {
1208
- cwd: repo.toplevel,
1209
- baseCommit: repo.baseCommit,
1210
- worktrees: Array.from({ length: count }, (_, index) => {
1211
- const naming = buildWorktreeNaming({ runId, index, agent: options?.agents?.[index], label: options?.labels?.[index], task: options?.tasks?.[index], branchPrefix });
1212
- const worktreePath = buildNativeWorktreePath(dedicatedRoot!, repo.toplevel, runId, index);
1213
- assertSafeWorktreeLocation(worktreePath, repo.toplevel, dedicatedRoot!);
1214
- return {
1215
- path: worktreePath,
1216
- agentCwd: repo.cwdRelative ? path.join(worktreePath, repo.cwdRelative) : worktreePath,
1217
- branch: naming.requestedBranch,
1218
- index,
1219
- nodeModulesLinked: false,
1220
- syntheticPaths: [],
1221
- provider: "native",
1222
- naming,
1223
- };
1224
- }),
1225
- };
1226
- options?.beforeCreate?.(plannedSetup);
1227
1371
  for (let index = 0; index < count; index++) {
1228
- worktrees.push(createNativeWorktree(
1372
+ worktrees[index] = await createNativeWorktree(
1373
+ tx,
1229
1374
  repo.toplevel,
1230
1375
  repo.cwdRelative,
1231
1376
  runId,
@@ -1237,28 +1382,26 @@ export function createWorktrees(cwd: string, runId: string, count: number, optio
1237
1382
  options?.labels,
1238
1383
  options?.tasks,
1239
1384
  branchPrefix,
1240
- ));
1385
+ );
1241
1386
  }
1242
1387
  } else {
1243
1388
  for (let index = 0; index < count; index++) {
1244
- worktrees.push(createWorktrunkWorktree(
1389
+ await createWorktrunkWorktree(
1390
+ tx,
1245
1391
  repo.toplevel,
1246
1392
  repo.cwdRelative,
1247
1393
  runId,
1248
1394
  index,
1249
1395
  repo.baseCommit,
1250
- repo.sourceCheckout,
1251
1396
  options?.agents,
1252
1397
  options?.labels,
1253
1398
  options?.tasks,
1254
1399
  branchPrefix,
1255
- ));
1400
+ );
1256
1401
  }
1257
- // Worktrunk determines its path only after creation. Journal the exact
1258
- // returned ownership before Pi runs setup hooks or launches a child.
1259
- options?.beforeCreate?.({ cwd: repo.toplevel, baseCommit: repo.baseCommit, worktrees: [...worktrees] });
1260
1402
  for (let index = 0; index < worktrees.length; index++) {
1261
- worktrees[index] = finalizeCreatedWorktree(
1403
+ worktrees[index] = await finalizeCreatedWorktree(
1404
+ tx,
1262
1405
  repo.toplevel,
1263
1406
  repo.cwdRelative,
1264
1407
  runId,
@@ -1269,23 +1412,37 @@ export function createWorktrees(cwd: string, runId: string, count: number, optio
1269
1412
  );
1270
1413
  }
1271
1414
  }
1415
+ tx.check();
1416
+ tx.progress.phase = "ready";
1417
+ tx.publish();
1418
+ tx.check();
1419
+ return tx.progress.setup;
1272
1420
  } catch (error) {
1273
- cleanupWorktrees({
1274
- cwd: repo.toplevel,
1275
- worktrees,
1276
- baseCommit: repo.baseCommit,
1277
- }, { kind: "setup-rollback" });
1421
+ await compensateSetup(tx);
1278
1422
  throw error;
1279
1423
  }
1424
+ }
1280
1425
 
1281
- return {
1282
- cwd: repo.toplevel,
1283
- worktrees,
1284
- baseCommit: repo.baseCommit,
1285
- };
1426
+ export async function createWorktrees(cwd: string, runId: string, count: number, options: CreateWorktreesOptions = {}): Promise<WorktreeSetup> {
1427
+ const tx = new SetupTransaction(cwd, options);
1428
+ try {
1429
+ return await withWorktreeTransaction(async () => {
1430
+ setupActive = true;
1431
+ try { return await allocateWorktrees(tx, cwd, runId, count); }
1432
+ finally { setupActive = false; }
1433
+ });
1434
+ } catch (error) {
1435
+ if (setupPoison) tx.progress.unknown ??= setupPoison;
1436
+ tx.progress.cleanup ??= { state: tx.progress.unknown ? "partial" : "complete", tasks: [], pruned: false, errors: tx.progress.unknown ? [tx.progress.unknown] : [] };
1437
+ try { tx.publish(); } catch (publicationError) {
1438
+ tx.progress.cleanup?.errors?.push(`Recovery publication failed: ${String(publicationError)}`);
1439
+ }
1440
+ throw new WorktreeSetupError(error, tx.snapshot());
1441
+ }
1286
1442
  }
1287
1443
 
1288
1444
  export function diffWorktrees(setup: WorktreeSetup, agents: string[], diffsDir: string): WorktreeDiff[] {
1445
+ assertWorktreeMutationAllowed();
1289
1446
  try {
1290
1447
  fs.mkdirSync(diffsDir, { recursive: true });
1291
1448
  } catch {
@@ -1315,6 +1472,7 @@ export function cleanupWorktrees(
1315
1472
  setup: WorktreeSetup,
1316
1473
  intent: WorktreeCleanupIntent = { kind: "preserve", ...(setup.capturedDiffs ? { capturedDiffs: setup.capturedDiffs } : {}) },
1317
1474
  ): WorktreeCleanupReport {
1475
+ assertWorktreeMutationAllowed();
1318
1476
  const tasks: WorktreeCleanupTask[] = [];
1319
1477
  for (let index = setup.worktrees.length - 1; index >= 0; index--) {
1320
1478
  tasks.push(cleanupSingleWorktree(setup, setup.worktrees[index]!, intent));