pi-subagents 0.65.0 → 0.66.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 (104) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +1 -1
  3. package/agents/researcher.md +23 -13
  4. package/docs/agents.md +17 -3
  5. package/docs/configuration.md +34 -0
  6. package/docs/extension-api.md +94 -0
  7. package/docs/models.md +58 -1
  8. package/docs/observability.md +42 -2
  9. package/docs/tool-reference.md +11 -5
  10. package/docs/workflows.md +22 -7
  11. package/package.json +4 -1
  12. package/runner-server-preload.mjs +13 -0
  13. package/skills/pi-subagents/SKILL.md +2 -1
  14. package/skills/pi-subagents/references/execution-controls.md +19 -2
  15. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
  16. package/skills/pi-subagents/references/multi-lane-orchestration.md +2 -0
  17. package/src/agents/advertised-agent-prompt.ts +63 -0
  18. package/src/agents/agent-management.ts +14 -1
  19. package/src/agents/agent-serializer.ts +2 -0
  20. package/src/agents/agents.ts +8 -0
  21. package/src/api/preflight.ts +5 -1
  22. package/src/api/shared-types.ts +1 -1
  23. package/src/api/workflow-resources.ts +6 -0
  24. package/src/extension/config.ts +4 -2
  25. package/src/extension/index.ts +71 -4
  26. package/src/extension/public-execution.ts +0 -1
  27. package/src/extension/rpc.ts +4 -21
  28. package/src/extension/schemas.ts +9 -7
  29. package/src/extension/tool-description.ts +10 -5
  30. package/src/integrations/pi-web-session-liveness.ts +73 -0
  31. package/src/intercom/native-supervisor-channel.ts +102 -88
  32. package/src/intercom/supervisor-ui.ts +3 -2
  33. package/src/missions/workflow-state.ts +37 -16
  34. package/src/runs/background/active-async-capacity.ts +18 -18
  35. package/src/runs/background/async-execution.ts +8 -1
  36. package/src/runs/background/async-job-tracker.ts +35 -3
  37. package/src/runs/background/async-resume.ts +3 -1
  38. package/src/runs/background/async-retention.ts +9 -0
  39. package/src/runs/background/async-status-snapshot.ts +10 -12
  40. package/src/runs/background/async-status.ts +17 -9
  41. package/src/runs/background/auto-drain.ts +40 -29
  42. package/src/runs/background/chain-root-attachment.ts +8 -0
  43. package/src/runs/background/control-channel.ts +78 -44
  44. package/src/runs/background/notify.ts +88 -12
  45. package/src/runs/background/owned-process-tree.ts +6 -6
  46. package/src/runs/background/process-terminal.ts +23 -23
  47. package/src/runs/background/retained-nested-route-tracker.ts +96 -0
  48. package/src/runs/background/run-child-session.ts +62 -33
  49. package/src/runs/background/run-status.ts +75 -5
  50. package/src/runs/background/runner-aliases.ts +46 -8
  51. package/src/runs/background/runner-child-launch.ts +86 -0
  52. package/src/runs/background/stale-run-reconciler.ts +3 -1
  53. package/src/runs/background/subagent-runner.ts +430 -208
  54. package/src/runs/background/subagent-wait.ts +3 -0
  55. package/src/runs/background/wait-completions.ts +4 -0
  56. package/src/runs/foreground/async-steering-action.ts +19 -0
  57. package/src/runs/foreground/execution.ts +116 -26
  58. package/src/runs/foreground/foreground-history.ts +3 -1
  59. package/src/runs/foreground/prompt-audit.ts +9 -5
  60. package/src/runs/foreground/subagent-executor.ts +531 -227
  61. package/src/runs/foreground/workflow-detach-reconcile.ts +8 -5
  62. package/src/runs/foreground/workflow-foreground-steering.ts +56 -2
  63. package/src/runs/shared/acceptance.ts +16 -3
  64. package/src/runs/shared/agent-contract.ts +1 -1
  65. package/src/runs/shared/async-status-projection.ts +47 -47
  66. package/src/runs/shared/child-hooks.ts +151 -2
  67. package/src/runs/shared/child-launch.ts +18 -13
  68. package/src/runs/shared/child-session.ts +55 -24
  69. package/src/runs/shared/child-tool-plan.ts +2 -2
  70. package/src/runs/shared/completion-evidence.ts +2 -2
  71. package/src/runs/shared/completion-guard.ts +1 -0
  72. package/src/runs/shared/host-step-status.ts +11 -11
  73. package/src/runs/shared/llm-intent-arbiter.ts +30 -20
  74. package/src/runs/shared/model-exclusions.ts +2 -1
  75. package/src/runs/shared/model-fallback.ts +41 -8
  76. package/src/runs/shared/nested-events.ts +8 -8
  77. package/src/runs/shared/orca-progress-tabs.ts +6 -0
  78. package/src/runs/shared/parallel-handoff.ts +57 -12
  79. package/src/runs/shared/parallel-utils.ts +3 -2
  80. package/src/runs/shared/readonly-drain-observation.ts +42 -0
  81. package/src/runs/shared/readonly-model-continuation.ts +69 -0
  82. package/src/runs/shared/readonly-session-evidence.ts +307 -0
  83. package/src/runs/shared/run-fanout-budget.ts +8 -8
  84. package/src/runs/shared/runtime-acknowledged-extensions.ts +3 -3
  85. package/src/runs/shared/subagent-prompt-runtime.ts +13 -3
  86. package/src/runs/shared/worktree-cleanup-plan.ts +6 -3
  87. package/src/runs/shared/worktree-setup-command.ts +190 -0
  88. package/src/runs/shared/worktree.ts +403 -210
  89. package/src/shared/model-response-aliases.ts +13 -0
  90. package/src/shared/types.ts +89 -60
  91. package/src/shared/utils.ts +10 -2
  92. package/src/shared/watch-strategy.ts +2 -0
  93. package/src/shared/workflow-child-permit.ts +18 -13
  94. package/src/tui/fleet-status.ts +1 -1
  95. package/src/tui/fleet.ts +11 -5
  96. package/src/tui/render.ts +44 -15
  97. package/src/workflows/chat-progress.ts +3 -3
  98. package/src/workflows/scripted-workflow.ts +70 -16
  99. package/src/workflows/workflow-checklist.ts +15 -18
  100. package/src/workflows/workflow-child-summary.ts +57 -8
  101. package/src/workflows/workflow-preflight.ts +19 -19
  102. package/src/workflows/workflow-receipt.ts +3 -3
  103. package/src/workflows/workflow-resources.ts +96 -21
  104. package/src/workflows/workflow-settlement.ts +3 -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,9 @@ 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
+ export const MACHINE_DIFF_OPTIONS = ["--no-color", "--no-ext-diff", "--no-textconv", "--default-prefix", "--line-prefix=", "--no-relative"] as const;
22
+ const MACHINE_PATCH_OPTIONS = [...MACHINE_DIFF_OPTIONS, "--binary"] as const;
23
+ const PATCH_VALIDATION_OPTIONS = ["apply", "--check", "--cached", "--reverse", "--binary", "--whitespace=nowarn"] as const;
20
24
 
21
25
  export interface WorktreeNamingInput {
22
26
  runId: string;
@@ -115,6 +119,10 @@ interface WorktreeSetupHookConfig {
115
119
  }
116
120
 
117
121
  export interface CreateWorktreesOptions {
122
+ signal?: AbortSignal;
123
+ /** Existing absolute run deadline only; setup does not start a child budget. */
124
+ deadlineAt?: number;
125
+ onProgress?: (snapshot: WorktreeSetupProgress) => void;
118
126
  agents?: string[];
119
127
  /** Optional stable labels used to make branch identity readable. */
120
128
  labels?: Array<string | undefined>;
@@ -128,8 +136,6 @@ export interface CreateWorktreesOptions {
128
136
  branchPrefix?: string;
129
137
  setupHook?: WorktreeSetupHookConfig;
130
138
  baseDir?: string;
131
- /** 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. */
132
- beforeCreate?: (setup: WorktreeSetup) => void;
133
139
  }
134
140
 
135
141
  interface ResolvedWorktreeSetupHook {
@@ -162,14 +168,112 @@ interface GitResult {
162
168
  interface RepoState {
163
169
  toplevel: string;
164
170
  cwdRelative: string;
165
- sourceCheckout: SourceCheckoutSnapshot;
166
171
  baseCommit: string;
167
172
  }
168
173
 
169
174
  const DEFAULT_WORKTREE_SETUP_HOOK_TIMEOUT_MS = 30000;
170
175
 
171
- function runGit(cwd: string, args: string[]): GitResult {
172
- const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8", windowsHide: true, shell: false });
176
+ /** In-memory evidence only. Callers project this into existing handoff diagnostics. */
177
+ export interface WorktreeSetupProgress {
178
+ setup: WorktreeSetup;
179
+ attempts: Array<{ index: number; branch: string; path?: string; validated: boolean; command?: WorktreeSetupProgress["command"]; hookCommand?: WorktreeSetupProgress["command"] }>;
180
+ phase: string;
181
+ command?: { command: string; args: string[]; pid?: number; processGroupId?: number; result?: Omit<SetupCommandResult, "stdout" | "stderr"> };
182
+ unknown?: string;
183
+ cleanup?: WorktreeCleanupReport;
184
+ }
185
+
186
+ export class WorktreeSetupError extends Error {
187
+ readonly snapshot: WorktreeSetupProgress;
188
+ constructor(error: unknown, snapshot: WorktreeSetupProgress) {
189
+ super(`${error instanceof Error ? error.message : String(error)}${snapshot.unknown ? `; setup settlement unknown: ${snapshot.unknown}; manual reconciliation required` : ""}`, { cause: error });
190
+ this.name = "WorktreeSetupError";
191
+ this.snapshot = snapshot;
192
+ }
193
+ }
194
+
195
+ let worktreeTurn: Promise<void> = Promise.resolve();
196
+ let setupActive = false;
197
+ let setupPoison: string | undefined;
198
+
199
+ function assertWorktreeMutationAllowed(): void {
200
+ if (setupPoison) throw new Error(`Worktree mutation blocked: ${setupPoison}`);
201
+ if (setupActive) throw new Error("Worktree setup is active; finalization must await withWorktreeTransaction");
202
+ }
203
+
204
+ /** Later async finalization owners wrap their synchronous diff/cleanup as ONE turn. */
205
+ export async function withWorktreeTransaction<T>(action: () => T | Promise<T>): Promise<T> {
206
+ const previous = worktreeTurn;
207
+ let release!: () => void;
208
+ worktreeTurn = new Promise<void>((resolve) => { release = resolve; });
209
+ await previous;
210
+ try {
211
+ if (setupPoison) throw new Error(`Worktree mutation blocked: ${setupPoison}`);
212
+ return await action();
213
+ } finally { release(); }
214
+ }
215
+
216
+ class SetupTransaction {
217
+ readonly progress: WorktreeSetupProgress;
218
+ readonly options: CreateWorktreesOptions;
219
+ constructor(cwd: string, options: CreateWorktreesOptions) {
220
+ this.options = options;
221
+ this.progress = { setup: { cwd, baseCommit: "", worktrees: [] }, attempts: [], phase: "preflight" };
222
+ }
223
+ snapshot(): WorktreeSetupProgress {
224
+ return structuredClone(this.progress);
225
+ }
226
+ publish(): void { this.options.onProgress?.(this.snapshot()); }
227
+ check(): void {
228
+ if (setupPoison) throw new Error(setupPoison);
229
+ if (this.options.signal?.aborted) throw new Error("Worktree setup aborted");
230
+ if (this.options.deadlineAt !== undefined && Date.now() >= this.options.deadlineAt) throw new Error("Worktree setup deadline exceeded");
231
+ }
232
+ unknown(error: unknown): void {
233
+ const reason = error instanceof Error ? error.message : String(error);
234
+ this.progress.unknown ??= reason;
235
+ setupPoison ??= reason;
236
+ }
237
+ async command(command: string, args: string[], options: SetupCommandOptions = {}): Promise<SetupCommandResult> {
238
+ this.check();
239
+ this.progress.command = { command, args };
240
+ if (this.progress.phase === "allocation") this.progress.attempts.at(-1)!.command = this.progress.command;
241
+ if (this.progress.phase === "hook") this.progress.attempts.find((attempt) => attempt.path === options.cwd)!.hookCommand = this.progress.command;
242
+ const result = await runSetupCommand(command, args, {
243
+ ...options, signal: this.options.signal, deadlineAt: this.options.deadlineAt,
244
+ onSpawn: (process) => { Object.assign(this.progress.command!, process); this.publish(); },
245
+ });
246
+ const { stdout: _stdout, stderr: _stderr, ...metadata } = result;
247
+ this.progress.command.result = metadata;
248
+ if (result.processTree?.state === "unknown") this.unknown(result.error ?? "Command tree settlement unverified");
249
+ this.publish();
250
+ if (result.error) throw result.error;
251
+ return result;
252
+ }
253
+ async git(cwd: string, args: string[], acceptedExitCodes?: readonly number[]): Promise<SetupCommandResult> {
254
+ return this.command("git", ["-C", cwd, ...args], { acceptedExitCodes });
255
+ }
256
+ async gitChecked(cwd: string, args: string[]): Promise<string> {
257
+ const result = await this.git(cwd, args);
258
+ return result.stdout;
259
+ }
260
+ attempt(index: number, branch: string, worktreePath?: string): void {
261
+ this.check();
262
+ this.progress.phase = "allocation";
263
+ this.progress.command = undefined;
264
+ this.progress.attempts.push({ index, branch, path: worktreePath, validated: false });
265
+ this.publish();
266
+ }
267
+ validated(worktree: WorktreeInfo): void {
268
+ this.progress.phase = "validated";
269
+ Object.assign(this.progress.attempts.at(-1)!, { path: worktree.path, validated: true });
270
+ this.progress.setup.worktrees.push(worktree);
271
+ this.publish();
272
+ }
273
+ }
274
+
275
+ function runGit(cwd: string, args: string[], env?: NodeJS.ProcessEnv): GitResult {
276
+ const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8", windowsHide: true, shell: false, ...(env ? { env: { ...process.env, ...env } } : {}) });
173
277
  return {
174
278
  stdout: result.stdout ?? "",
175
279
  stderr: result.stderr ?? "",
@@ -187,38 +291,79 @@ function runGitChecked(cwd: string, args: string[]): string {
187
291
  return result.stdout;
188
292
  }
189
293
 
190
- function findGitWorktreePath(cwd: string, branch: string): string | undefined {
191
- const targetBranch = `branch refs/heads/${branch}`;
192
- let currentPath: string | undefined;
193
- for (const line of runGitChecked(cwd, ["worktree", "list", "--porcelain"]).split("\n")) {
194
- if (line.startsWith("worktree ")) currentPath = line.slice("worktree ".length).trim();
195
- else if (line.trim() === targetBranch) return currentPath;
294
+ /** Validate a captured patch against the worktree index without changing either. */
295
+ export function validateWorktreePatch(worktreePath: string, patchPath: string): string | undefined {
296
+ const result = runGit(worktreePath, [...PATCH_VALIDATION_OPTIONS, patchPath]);
297
+ if (result.status === 0) return undefined;
298
+ return result.stderr.trim() || result.stdout.trim() || `git -C ${worktreePath} apply --check failed`;
299
+ }
300
+
301
+ function currentWorktreePatch(worktreePath: string, baseCommit: string): { patch: string } | { error: string } {
302
+ assertWorktreeMutationAllowed();
303
+ let tempDir: string;
304
+ try {
305
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-worktree-index-"));
306
+ } catch (error) {
307
+ return { error: error instanceof Error ? error.message : String(error) };
196
308
  }
309
+ const env = { GIT_INDEX_FILE: path.join(tempDir, "index") };
310
+ try {
311
+ const readTree = runGit(worktreePath, ["read-tree", "HEAD"], env);
312
+ if (readTree.status !== 0) return { error: readTree.stderr.trim() || readTree.stdout.trim() || "git read-tree failed" };
313
+ const add = runGit(worktreePath, ["add", "-A"], env);
314
+ if (add.status !== 0) return { error: add.stderr.trim() || add.stdout.trim() || "git add failed" };
315
+ const diff = runGit(worktreePath, ["diff", "--cached", ...MACHINE_PATCH_OPTIONS, baseCommit], env);
316
+ if (diff.status !== 0) return { error: diff.stderr.trim() || diff.stdout.trim() || "git diff failed" };
317
+ return { patch: diff.stdout };
318
+ } finally {
319
+ try {
320
+ fs.rmSync(tempDir, { recursive: true, force: true });
321
+ } catch {
322
+ // Cleanup safety depends on preserving the worktree, not on best-effort temp-index deletion.
323
+ }
324
+ }
325
+ }
326
+
327
+ export function validateWorktreePatchRepresentsCurrentWorktree(worktreePath: string, baseCommit: string, patchPath: string): string | undefined {
328
+ const validationError = validateWorktreePatch(worktreePath, patchPath);
329
+ if (validationError) return validationError;
330
+ let capturedPatch: string;
331
+ try {
332
+ capturedPatch = fs.readFileSync(patchPath, "utf-8");
333
+ } catch (error) {
334
+ return error instanceof Error ? error.message : String(error);
335
+ }
336
+ const current = currentWorktreePatch(worktreePath, baseCommit);
337
+ if ("error" in current) return current.error || "failed to capture current worktree patch";
338
+ if (capturedPatch !== current.patch) return "captured handoff patch does not match current worktree changes";
197
339
  return undefined;
198
340
  }
199
341
 
200
- function resolveRepoState(cwd: string, requestedBaseRef: string | undefined): RepoState {
201
- const cwdRelative = resolveRepoCwdRelative(cwd);
202
- const toplevel = runGitChecked(cwd, ["rev-parse", "--show-toplevel"]).trim();
342
+ async function resolveRepoState(tx: SetupTransaction, cwd: string, requestedBaseRef: string | undefined): Promise<RepoState> {
343
+ const repoCheck = await tx.git(cwd, ["rev-parse", "--is-inside-work-tree"], [0, 128]);
344
+ if (repoCheck.status !== 0 || repoCheck.stdout.trim() !== "true") throw new Error("worktree isolation requires a git repository");
345
+ const rawPrefix = (await tx.gitChecked(cwd, ["rev-parse", "--show-prefix"])).trim();
346
+ const cwdRelative = rawPrefix ? path.normalize(rawPrefix.replace(/[\\/]+$/, "")) : "";
347
+ const toplevel = (await tx.gitChecked(cwd, ["rev-parse", "--show-toplevel"])).trim();
203
348
 
204
349
  // pi-subagents writes durable runtime state under .pi/subagents/ by default;
205
350
  // that state must not make managed isolation unusable for later runs.
206
- const status = runGitChecked(toplevel, ["status", "--porcelain", "--", `:!${PROJECT_SUBAGENTS_RELATIVE_DIR}`]);
351
+ const status = await tx.gitChecked(toplevel, ["status", "--porcelain", "--", `:!${PROJECT_SUBAGENTS_RELATIVE_DIR}`]);
207
352
  if (status.trim().length > 0) {
208
353
  throw new Error("worktree isolation requires a clean git working tree. Commit or stash changes first.");
209
354
  }
210
355
 
211
- const sourceHead = runGitChecked(toplevel, ["rev-parse", "HEAD"]).trim();
212
- const sourceCheckout = snapshotSourceCheckout(toplevel, sourceHead);
213
356
  const baseRef = normalizeWorktreeBaseRef(requestedBaseRef) ?? DEFAULT_WORKTREE_BASE_REF;
214
357
  let baseCommit: string;
215
358
  try {
216
- baseCommit = runGitChecked(toplevel, ["rev-parse", "--verify", "--end-of-options", `${baseRef}^{commit}`]).trim();
359
+ const base = await tx.git(toplevel, ["rev-parse", "--verify", "--end-of-options", `${baseRef}^{commit}`], [0, 128]);
360
+ if (base.status !== 0) throw new Error(base.stderr.trim() || "ref not found");
361
+ baseCommit = base.stdout.trim();
217
362
  } catch (error) {
218
363
  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 });
219
364
  }
220
365
  if (!baseCommit) throw new Error(`baseRef '${baseRef}' could not be resolved to a commit`);
221
- return { toplevel, cwdRelative, sourceCheckout, baseCommit };
366
+ return { toplevel, cwdRelative, baseCommit };
222
367
  }
223
368
 
224
369
  function normalizeComparableCwd(cwd: string): string {
@@ -307,7 +452,7 @@ function validGitRef(ref: string): boolean {
307
452
  /** Normalize and validate a configured worktree base ref without resolving it. */
308
453
  export function normalizeWorktreeBaseRef(value: unknown): string | undefined {
309
454
  if (value === undefined) return undefined;
310
- if (typeof value !== "string" || !validGitRef(value)) throw new Error("baseRef must be a valid Git ref");
455
+ 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.");
311
456
  return value;
312
457
  }
313
458
 
@@ -386,11 +531,6 @@ interface WorktrunkCapability {
386
531
  reason?: string;
387
532
  }
388
533
 
389
- interface SourceCheckoutSnapshot {
390
- head: string;
391
- branch?: string;
392
- }
393
-
394
534
  function runWorktrunk(args: string[], cwd?: string): WorktreeCommandResult {
395
535
  try {
396
536
  const result = spawnSync("wt", args, {
@@ -423,18 +563,6 @@ function probeWorktrunk(): WorktrunkCapability {
423
563
  return { available: true };
424
564
  }
425
565
 
426
- function snapshotSourceCheckout(toplevel: string, head: string): SourceCheckoutSnapshot {
427
- const branch = runGit(toplevel, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
428
- const branchName = branch.status === 0 ? branch.stdout.trim() : "";
429
- return branchName ? { head, branch: branchName } : { head };
430
- }
431
-
432
- function restoreSourceCheckoutIfWorktrunkSwitchedIt(toplevel: string, createdBranch: string, sourceCheckout: SourceCheckoutSnapshot): void {
433
- const current = runGit(toplevel, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
434
- if (current.status !== 0 || current.stdout.trim() !== createdBranch) return;
435
- runGitChecked(toplevel, sourceCheckout.branch ? ["checkout", sourceCheckout.branch] : ["checkout", "--detach", sourceCheckout.head]);
436
- }
437
-
438
566
  /** Resolve a requested provider without silently switching after allocation starts. */
439
567
  export function resolveWorktreeProvider(requested: WorktreeProvider | undefined, baseDir?: string): ManagedWorktreeProvider {
440
568
  const selection = requested ?? DEFAULT_WORKTREE_PROVIDER;
@@ -450,6 +578,33 @@ export function resolveWorktreeProvider(requested: WorktreeProvider | undefined,
450
578
  return "native";
451
579
  }
452
580
 
581
+ async function resolveSetupProvider(tx: SetupTransaction, requested: WorktreeProvider | undefined, baseDir?: string): Promise<ManagedWorktreeProvider> {
582
+ const selection = requested ?? DEFAULT_WORKTREE_PROVIDER;
583
+ if (selection !== "auto" && selection !== "native" && selection !== "worktrunk") throw new Error('worktree provider must be "auto", "native", or "worktrunk"');
584
+ if (selection === "native") return "native";
585
+ if (hasConfiguredWorktreeBaseDir(baseDir)) {
586
+ if (selection === "worktrunk") throw new Error("worktreeProvider='worktrunk' cannot be combined with worktreeBaseDir or PI_SUBAGENTS_WORKTREE_DIR");
587
+ return "native";
588
+ }
589
+ let reason: string | undefined;
590
+ try {
591
+ const probeExitCodes = Array.from({ length: 256 }, (_, code) => code);
592
+ const version = await tx.command("wt", ["--version"], { maxBuffer: WORKTREE_COMMAND_OUTPUT_MAX_BYTES, acceptedExitCodes: probeExitCodes });
593
+ 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";
594
+ else {
595
+ const help = await tx.command("wt", ["switch", "--help"], { maxBuffer: WORKTREE_COMMAND_OUTPUT_MAX_BYTES, acceptedExitCodes: probeExitCodes });
596
+ const missing = ["--create", "--base", "--no-cd", "--no-hooks", "--format"].filter((flag) => !`${help.stdout}\n${help.stderr}`.includes(flag));
597
+ if (help.status !== 0 || missing.length) reason = `Worktrunk switch capability unavailable: ${missing.join(", ")}`;
598
+ }
599
+ } catch (error) {
600
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error;
601
+ reason = error.message;
602
+ }
603
+ if (!reason) return "worktrunk";
604
+ if (selection === "worktrunk") throw new Error(`Worktrunk provider is unavailable: ${reason}`);
605
+ return "native";
606
+ }
607
+
453
608
  /** Whether a launch must bind its worktree-dependent paths after allocation. */
454
609
  export function shouldDeferWorktreeCwd(requested: WorktreeProvider | undefined, baseDir?: string): boolean {
455
610
  return (requested ?? DEFAULT_WORKTREE_PROVIDER) !== "native" && !hasConfiguredWorktreeBaseDir(baseDir);
@@ -627,11 +782,6 @@ function normalizeSyntheticPath(worktreePath: string, rawPath: string): string {
627
782
  return path.normalize(relative);
628
783
  }
629
784
 
630
- function hasTrackedEntries(worktreePath: string, relativePath: string): boolean {
631
- const result = runGit(worktreePath, ["ls-files", "--", relativePath]);
632
- return result.status === 0 && result.stdout.trim().length > 0;
633
- }
634
-
635
785
  function parseWorktreeSetupHookOutput(rawStdout: string): WorktreeSetupHookOutput {
636
786
  const trimmed = rawStdout.trim();
637
787
  if (!trimmed) {
@@ -650,53 +800,49 @@ function parseWorktreeSetupHookOutput(rawStdout: string): WorktreeSetupHookOutpu
650
800
  return parsed as WorktreeSetupHookOutput;
651
801
  }
652
802
 
653
- function runWorktreeSetupHook(
803
+ async function runWorktreeSetupHook(
804
+ tx: SetupTransaction,
654
805
  hook: ResolvedWorktreeSetupHook,
655
806
  input: WorktreeSetupHookInput,
656
- ): string[] {
657
- const result = spawnSync(hook.hookPath, [], {
658
- windowsHide: true,
659
- cwd: input.worktreePath,
660
- encoding: "utf-8",
661
- input: JSON.stringify(input),
662
- timeout: hook.timeoutMs,
663
- shell: false,
664
- });
665
-
666
- if (result.error) {
667
- const code = "code" in result.error ? result.error.code : undefined;
668
- if (code === "ETIMEDOUT") {
669
- throw new Error(`worktree setup hook timed out after ${hook.timeoutMs}ms`);
670
- }
671
- throw new Error(`worktree setup hook failed: ${result.error.message}`);
672
- }
673
-
674
- if (result.status !== 0) {
675
- const details = result.stderr.trim() || result.stdout.trim() || "no output";
676
- throw new Error(`worktree setup hook failed with exit code ${result.status}: ${details}`);
677
- }
678
-
679
- const output = parseWorktreeSetupHookOutput(result.stdout);
680
- if (output.syntheticPaths === undefined) return [];
681
- if (!Array.isArray(output.syntheticPaths)) {
682
- throw new Error("worktree setup hook output field 'syntheticPaths' must be an array of relative paths");
807
+ ): Promise<string[]> {
808
+ tx.progress.phase = "hook";
809
+ let result: SetupCommandResult;
810
+ try {
811
+ result = await tx.command(hook.hookPath, [], {
812
+ cwd: input.worktreePath,
813
+ input: JSON.stringify(input),
814
+ hookTimeoutMs: hook.timeoutMs,
815
+ });
816
+ } catch (error) {
817
+ throw new Error(`worktree setup hook failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
683
818
  }
819
+ tx.progress.phase = "hook-validation";
684
820
 
685
- const uniquePaths = new Set<string>();
686
- for (const candidate of output.syntheticPaths) {
687
- if (typeof candidate !== "string") {
688
- throw new Error("worktree setup hook output field 'syntheticPaths' must contain only strings");
821
+ try {
822
+ const output = parseWorktreeSetupHookOutput(result.stdout);
823
+ if (output.syntheticPaths === undefined) return [];
824
+ if (!Array.isArray(output.syntheticPaths)) {
825
+ throw new Error("worktree setup hook output field 'syntheticPaths' must be an array of relative paths");
689
826
  }
690
- const normalizedPath = normalizeSyntheticPath(input.worktreePath, candidate);
691
- if (hasTrackedEntries(input.worktreePath, normalizedPath)) {
692
- throw new Error(`worktree setup hook cannot mark tracked paths as synthetic: ${normalizedPath}`);
827
+ const uniquePaths = new Set<string>();
828
+ for (const candidate of output.syntheticPaths) {
829
+ if (typeof candidate !== "string") throw new Error("worktree setup hook output field 'syntheticPaths' must contain only strings");
830
+ const normalizedPath = normalizeSyntheticPath(input.worktreePath, candidate);
831
+ if ((await tx.gitChecked(input.worktreePath, ["ls-files", "--", normalizedPath])).trim()) {
832
+ throw new Error(`worktree setup hook cannot mark tracked paths as synthetic: ${normalizedPath}`);
833
+ }
834
+ uniquePaths.add(normalizedPath);
693
835
  }
694
- uniquePaths.add(normalizedPath);
836
+ return [...uniquePaths];
837
+ } catch (error) {
838
+ // Semantic rejection occurs after exit: retain uncertainty, never signal an old PID.
839
+ tx.unknown(error);
840
+ throw error;
695
841
  }
696
- return [...uniquePaths];
697
842
  }
698
843
 
699
- function finalizeCreatedWorktree(
844
+ async function finalizeCreatedWorktree(
845
+ tx: SetupTransaction,
700
846
  toplevel: string,
701
847
  cwdRelative: string,
702
848
  runId: string,
@@ -704,38 +850,23 @@ function finalizeCreatedWorktree(
704
850
  setupHook: ResolvedWorktreeSetupHook | undefined,
705
851
  agent: string | undefined,
706
852
  worktree: WorktreeInfo,
707
- ): WorktreeInfo {
853
+ ): Promise<WorktreeInfo> {
708
854
  const agentCwd = cwdRelative ? path.join(worktree.path, cwdRelative) : worktree.path;
709
- try {
710
- const nodeModulesLinked = linkNodeModulesIfPresent(toplevel, worktree.path);
711
- const syntheticPaths = nodeModulesLinked ? ["node_modules"] : [];
712
- if (setupHook) {
713
- const hookSyntheticPaths = runWorktreeSetupHook(setupHook, {
714
- version: 1,
715
- repoRoot: toplevel,
716
- worktreePath: worktree.path,
717
- agentCwd,
718
- branch: worktree.branch,
719
- index: worktree.index,
720
- runId,
721
- baseCommit,
722
- agent,
723
- });
724
- syntheticPaths.push(...hookSyntheticPaths);
725
- }
726
- return { ...worktree, agentCwd, nodeModulesLinked, syntheticPaths };
727
- } catch (error) {
728
- try { runGitChecked(toplevel, ["worktree", "remove", "--force", worktree.path]); } catch {
729
- // Best-effort rollback; preserve the original setup failure.
730
- }
731
- try { runGitChecked(toplevel, ["branch", "-D", worktree.branch]); } catch {
732
- // Best-effort rollback; preserve the original setup failure.
733
- }
734
- throw error;
855
+ tx.check();
856
+ const nodeModulesLinked = linkNodeModulesIfPresent(toplevel, worktree.path);
857
+ const syntheticPaths = nodeModulesLinked ? ["node_modules"] : [];
858
+ if (setupHook) {
859
+ const hookSyntheticPaths = await runWorktreeSetupHook(tx, setupHook, {
860
+ version: 1, repoRoot: toplevel, worktreePath: worktree.path, agentCwd,
861
+ branch: worktree.branch, index: worktree.index, runId, baseCommit, agent,
862
+ });
863
+ syntheticPaths.push(...hookSyntheticPaths);
735
864
  }
865
+ return { ...worktree, agentCwd, nodeModulesLinked, syntheticPaths };
736
866
  }
737
867
 
738
- function createNativeWorktree(
868
+ async function createNativeWorktree(
869
+ tx: SetupTransaction,
739
870
  toplevel: string,
740
871
  cwdRelative: string,
741
872
  runId: string,
@@ -747,17 +878,20 @@ function createNativeWorktree(
747
878
  labels: Array<string | undefined> | undefined,
748
879
  tasks: Array<string | undefined> | undefined,
749
880
  branchPrefix: string | undefined,
750
- ): WorktreeInfo {
881
+ ): Promise<WorktreeInfo> {
751
882
  const naming = buildWorktreeNaming({ runId, index, agent, label: labels?.[index], task: tasks?.[index], branchPrefix });
752
883
  const worktreePath = buildNativeWorktreePath(dedicatedRoot, toplevel, runId, index);
753
884
  assertSafeWorktreeLocation(worktreePath, toplevel, dedicatedRoot);
885
+ // Absence is required before claiming any partial artifacts from this attempt.
886
+ const branch = await tx.git(toplevel, ["show-ref", "--verify", "--quiet", `refs/heads/${naming.requestedBranch}`], [0, 1]);
887
+ let pathExists = false;
888
+ try { fs.lstatSync(worktreePath); pathExists = true; }
889
+ catch (error) { if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error; }
890
+ if (branch.status !== 1 || pathExists) throw new Error(`worktree path or branch already exists: ${worktreePath}, ${naming.requestedBranch}`);
891
+ tx.attempt(index, naming.requestedBranch, worktreePath);
754
892
  ensureProjectWorktreeDir(dedicatedRoot, toplevel);
755
- const add = runGit(toplevel, ["worktree", "add", worktreePath, "-b", naming.requestedBranch, baseCommit]);
756
- if (add.status !== 0) {
757
- const message = add.stderr.trim() || add.stdout.trim() || `failed to create worktree ${worktreePath}`;
758
- throw new Error(message);
759
- }
760
- return finalizeCreatedWorktree(toplevel, cwdRelative, runId, baseCommit, setupHook, agent, {
893
+ await tx.git(toplevel, ["worktree", "add", worktreePath, "-b", naming.requestedBranch, baseCommit]);
894
+ const worktree: WorktreeInfo = {
761
895
  path: worktreePath,
762
896
  agentCwd: worktreePath,
763
897
  branch: naming.requestedBranch,
@@ -766,7 +900,9 @@ function createNativeWorktree(
766
900
  syntheticPaths: [],
767
901
  provider: "native",
768
902
  naming,
769
- });
903
+ };
904
+ tx.validated(worktree);
905
+ return finalizeCreatedWorktree(tx, toplevel, cwdRelative, runId, baseCommit, setupHook, agent, worktree);
770
906
  }
771
907
 
772
908
  interface WorktrunkSwitchOutput {
@@ -791,37 +927,30 @@ function parseWorktrunkSwitchOutput(rawStdout: string): WorktrunkSwitchOutput {
791
927
  return parsed as WorktrunkSwitchOutput;
792
928
  }
793
929
 
794
- function createWorktrunkWorktree(
930
+ async function createWorktrunkWorktree(
931
+ tx: SetupTransaction,
795
932
  toplevel: string,
796
933
  cwdRelative: string,
797
934
  runId: string,
798
935
  index: number,
799
936
  baseCommit: string,
800
- sourceCheckout: SourceCheckoutSnapshot,
801
937
  agents: string[] | undefined,
802
938
  labels: Array<string | undefined> | undefined,
803
939
  tasks: Array<string | undefined> | undefined,
804
940
  branchPrefix: string | undefined,
805
- ): WorktreeInfo {
941
+ ): Promise<WorktreeInfo> {
806
942
  const naming = buildWorktreeNaming({ runId, index, agent: agents?.[index], label: labels?.[index], task: tasks?.[index], branchPrefix });
807
943
  const args = ["-C", toplevel, "switch", "--create", naming.requestedBranch, "--base", baseCommit, "--no-cd", "--no-hooks", "--format", "json"];
808
- const result = runWorktrunk(args, toplevel);
809
- if (result.status !== 0) {
810
- const message = result.error?.message || result.stderr.trim() || result.stdout.trim() || "Worktrunk provisioning failed";
811
- throw new Error(`Worktrunk provisioning failed: ${message}`);
812
- }
813
- let createdAllocation = false;
814
- let returnedPath: string | undefined;
944
+ tx.attempt(index, naming.requestedBranch);
945
+ const result = await tx.command("wt", args, { cwd: toplevel, maxBuffer: WORKTREE_COMMAND_OUTPUT_MAX_BYTES });
946
+ tx.progress.phase = "validation";
815
947
  try {
816
948
  const output = parseWorktrunkSwitchOutput(result.stdout);
817
- createdAllocation = output.action === "created" && output.created_branch === true;
818
- if (typeof output.path === "string" && path.isAbsolute(output.path)) returnedPath = path.resolve(output.path);
819
- if (!createdAllocation || output.branch !== naming.requestedBranch || output.base_branch !== baseCommit) {
949
+ if (output.action !== "created" || output.created_branch !== true || output.branch !== naming.requestedBranch || output.base_branch !== baseCommit) {
820
950
  throw new Error("Worktrunk provisioning returned inconsistent creation metadata");
821
951
  }
822
952
  if (typeof output.path !== "string" || !path.isAbsolute(output.path)) throw new Error("Worktrunk provisioning returned a non-absolute worktree path");
823
- const worktreePathCandidate = returnedPath;
824
- if (!worktreePathCandidate) throw new Error("Worktrunk provisioning returned a non-absolute worktree path");
953
+ const worktreePathCandidate = path.resolve(output.path);
825
954
  let stat: fs.Stats;
826
955
  try {
827
956
  stat = fs.lstatSync(worktreePathCandidate);
@@ -831,16 +960,16 @@ function createWorktrunkWorktree(
831
960
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Worktrunk provisioning returned a path that is not a real directory");
832
961
  const worktreePath = normalizeComparableCwd(worktreePathCandidate);
833
962
  if (worktreePath === normalizeComparableCwd(toplevel)) throw new Error("Worktrunk provisioning returned the source checkout path");
834
- const sourceCommonDirRaw = runGitChecked(toplevel, ["rev-parse", "--git-common-dir"]).trim();
835
- const returnedCommonDirRaw = runGitChecked(worktreePath, ["rev-parse", "--git-common-dir"]).trim();
963
+ const sourceCommonDirRaw = (await tx.gitChecked(toplevel, ["rev-parse", "--git-common-dir"])).trim();
964
+ const returnedCommonDirRaw = (await tx.gitChecked(worktreePath, ["rev-parse", "--git-common-dir"])).trim();
836
965
  const sourceCommonDir = normalizeComparableCwd(path.isAbsolute(sourceCommonDirRaw) ? sourceCommonDirRaw : path.resolve(toplevel, sourceCommonDirRaw));
837
966
  const returnedCommonDir = normalizeComparableCwd(path.isAbsolute(returnedCommonDirRaw) ? returnedCommonDirRaw : path.resolve(worktreePath, returnedCommonDirRaw));
838
967
  if (returnedCommonDir !== sourceCommonDir) throw new Error("Worktrunk provisioning returned a worktree for a different repository");
839
- const returnedBranch = runGitChecked(worktreePath, ["symbolic-ref", "--quiet", "--short", "HEAD"]).trim();
968
+ const returnedBranch = (await tx.gitChecked(worktreePath, ["symbolic-ref", "--quiet", "--short", "HEAD"])).trim();
840
969
  if (returnedBranch !== naming.requestedBranch) throw new Error("Worktrunk provisioning returned a worktree on a different branch");
841
- const returnedHead = runGitChecked(worktreePath, ["rev-parse", "HEAD"]).trim();
970
+ const returnedHead = (await tx.gitChecked(worktreePath, ["rev-parse", "HEAD"])).trim();
842
971
  if (returnedHead !== baseCommit) throw new Error("Worktrunk provisioning returned a worktree at a different base commit");
843
- return {
972
+ const worktree: WorktreeInfo = {
844
973
  path: worktreePath,
845
974
  agentCwd: cwdRelative ? path.join(worktreePath, cwdRelative) : worktreePath,
846
975
  branch: naming.requestedBranch,
@@ -850,35 +979,16 @@ function createWorktrunkWorktree(
850
979
  provider: "worktrunk",
851
980
  naming,
852
981
  };
982
+ tx.validated(worktree);
983
+ return worktree;
853
984
  } catch (error) {
854
- if (createdAllocation) {
855
- try { restoreSourceCheckoutIfWorktrunkSwitchedIt(toplevel, naming.requestedBranch, sourceCheckout); } catch {
856
- // Best-effort rollback; preserve the validation failure.
857
- }
858
- try {
859
- const listedPath = findGitWorktreePath(toplevel, naming.requestedBranch);
860
- const candidates = [listedPath, returnedPath].filter((candidate, candidateIndex, all): candidate is string => Boolean(candidate) && all.indexOf(candidate) === candidateIndex);
861
- for (const candidate of candidates) {
862
- if (normalizeComparableCwd(candidate) === normalizeComparableCwd(toplevel)) continue;
863
- try {
864
- runGitChecked(toplevel, ["worktree", "remove", "--force", candidate]);
865
- break;
866
- } catch {
867
- // Try another provider-reported/listed path before giving up.
868
- }
869
- }
870
- } catch {
871
- // Best-effort rollback; preserve the validation failure.
872
- }
873
- try { runGitChecked(toplevel, ["branch", "-D", naming.requestedBranch]); } catch {
874
- // Best-effort rollback; preserve the validation failure.
875
- }
876
- }
985
+ tx.unknown(error);
877
986
  throw error;
878
987
  }
879
988
  }
880
989
 
881
990
  function removeSyntheticPath(worktree: WorktreeInfo, syntheticPath: string): void {
991
+ assertWorktreeMutationAllowed();
882
992
  const resolved = path.resolve(worktree.path, syntheticPath);
883
993
  const relative = path.relative(worktree.path, resolved);
884
994
  if (!relative || relative === "." || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
@@ -957,15 +1067,18 @@ function captureWorktreeDiff(
957
1067
  ): WorktreeDiff {
958
1068
  removeSyntheticPathsBeforeDiff(worktree);
959
1069
  runGitChecked(worktree.path, ["add", "-A"]);
960
- const diffStat = runGitChecked(worktree.path, ["diff", "--cached", "--stat", setup.baseCommit]).trim();
961
- const patch = runGitChecked(worktree.path, ["diff", "--cached", setup.baseCommit]);
962
- const numstat = runGitChecked(worktree.path, ["diff", "--cached", "--numstat", setup.baseCommit]);
1070
+ const diffStat = runGitChecked(worktree.path, ["diff", "--cached", ...MACHINE_DIFF_OPTIONS, "--stat", setup.baseCommit]).trim();
1071
+ const patch = runGitChecked(worktree.path, ["diff", "--cached", ...MACHINE_PATCH_OPTIONS, setup.baseCommit]);
1072
+ const numstat = runGitChecked(worktree.path, ["diff", "--cached", ...MACHINE_DIFF_OPTIONS, "--numstat", setup.baseCommit]);
963
1073
  fs.writeFileSync(patchPath, patch, "utf-8");
964
1074
 
965
1075
  if (!patch.trim()) {
966
1076
  return emptyDiff(worktree.index, agent, worktree.branch, patchPath);
967
1077
  }
968
1078
 
1079
+ const validationError = validateWorktreePatch(worktree.path, patchPath);
1080
+ if (validationError) throw new Error(`captured worktree patch is not machine-applyable: ${validationError}`);
1081
+
969
1082
  const parsed = parseNumstat(numstat);
970
1083
  return {
971
1084
  index: worktree.index,
@@ -1034,7 +1147,7 @@ function cleanupSingleWorktree(
1034
1147
  errors.push(`synthetic path cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
1035
1148
  }
1036
1149
  const status = runGit(worktree.path, ["status", "--porcelain"]);
1037
- const baseDiff = runGit(worktree.path, ["diff", "--quiet", setup.baseCommit, "--"]);
1150
+ const baseDiff = runGit(worktree.path, ["diff", "--quiet", ...MACHINE_DIFF_OPTIONS, setup.baseCommit, "--"]);
1038
1151
  if (status.status !== 0 || (baseDiff.status !== 0 && baseDiff.status !== 1)) {
1039
1152
  const reason = status.status !== 0
1040
1153
  ? status.stderr.trim() || status.stdout.trim() || "git status failed"
@@ -1055,13 +1168,25 @@ function cleanupSingleWorktree(
1055
1168
  const hasWork = status.stdout.trim().length > 0 || baseDiff.status === 1;
1056
1169
  if (hasWork && intent.kind === "preserve") {
1057
1170
  const captured = (intent.capturedDiffs ?? setup.capturedDiffs)?.find((diff) => diff.index === worktree.index);
1058
- const patchCaptured = captured !== undefined
1171
+ let patchValidationError: string | undefined;
1172
+ let patchCaptured = false;
1173
+ if (captured !== undefined
1059
1174
  && captured.error === undefined
1060
1175
  && fs.existsSync(captured.patchPath)
1061
- && fs.statSync(captured.patchPath).size > 0
1062
- && handoffRecordsPatch(intent.handoffManifestPath, captured.patchPath);
1176
+ && handoffRecordsPatch(intent.handoffManifestPath, captured.patchPath)) {
1177
+ try {
1178
+ if (fs.statSync(captured.patchPath).size > 0) {
1179
+ patchValidationError = validateWorktreePatchRepresentsCurrentWorktree(worktree.path, setup.baseCommit, captured.patchPath);
1180
+ patchCaptured = patchValidationError === undefined;
1181
+ }
1182
+ } catch (error) {
1183
+ patchValidationError = error instanceof Error ? error.message : String(error);
1184
+ }
1185
+ }
1063
1186
  if (!patchCaptured) {
1064
- const reason = "worktree contains changes that are not represented by a captured handoff patch";
1187
+ const reason = patchValidationError
1188
+ ? `captured handoff patch failed validation: ${patchValidationError}`
1189
+ : "worktree contains changes that are not represented by a captured handoff patch";
1065
1190
  return {
1066
1191
  index: worktree.index,
1067
1192
  path: worktree.path,
@@ -1128,39 +1253,94 @@ function hasWorktreeChanges(diff: WorktreeDiff): boolean {
1128
1253
  return diff.filesChanged > 0 || diff.insertions > 0 || diff.deletions > 0 || diff.diffStat.trim().length > 0;
1129
1254
  }
1130
1255
 
1131
- export function createWorktrees(cwd: string, runId: string, count: number, options?: CreateWorktreesOptions): WorktreeSetup {
1256
+ async function compensateSetup(tx: SetupTransaction): Promise<WorktreeCleanupReport> {
1257
+ const { setup, attempts } = tx.progress;
1258
+ const report: WorktreeCleanupReport = { state: "partial", tasks: [], pruned: false, errors: [] };
1259
+ tx.progress.cleanup = report;
1260
+ tx.progress.phase = "rollback";
1261
+ // Cancellation stops allocation, not accounting. Compensation has ONLY the
1262
+ // remaining original absolute deadline; it does not reuse the launch signal.
1263
+ const git = async (args: string[], acceptedExitCodes: readonly number[] = [0]) => {
1264
+ if (setupPoison) throw new Error(setupPoison);
1265
+ tx.progress.command = { command: "git", args: ["-C", setup.cwd, ...args] };
1266
+ const result = await runSetupCommand("git", tx.progress.command.args, {
1267
+ deadlineAt: tx.options.deadlineAt, acceptedExitCodes,
1268
+ onSpawn: (process) => { Object.assign(tx.progress.command!, process); tx.publish(); },
1269
+ });
1270
+ const { stdout: _stdout, stderr: _stderr, ...metadata } = result;
1271
+ tx.progress.command.result = metadata;
1272
+ if (result.processTree?.state === "unknown") tx.unknown(result.error ?? "Rollback command settlement unverified");
1273
+ tx.publish();
1274
+ if (result.error || result.status === null || !acceptedExitCodes.includes(result.status)) throw result.error ?? new Error(result.stderr.trim() || "Rollback command failed");
1275
+ return result;
1276
+ };
1277
+ for (const attempt of [...attempts].reverse()) {
1278
+ if (!attempt.path) {
1279
+ tx.unknown(`Allocation path unconfirmed for branch ${attempt.branch}`);
1280
+ report.errors!.push(tx.progress.unknown!);
1281
+ continue;
1282
+ }
1283
+ const known = setup.worktrees.find((worktree) => worktree.index === attempt.index);
1284
+ const task: WorktreeCleanupTask = { index: attempt.index, path: attempt.path, branch: attempt.branch,
1285
+ provider: known?.provider ?? "native", naming: known?.naming, worktreeRemoved: false, branchRemoved: false };
1286
+ report.tasks.push(task);
1287
+ try {
1288
+ if (normalizeComparableCwd(attempt.path) === normalizeComparableCwd(setup.cwd)) throw new Error("Refusing source-checkout removal");
1289
+ if (attempt.validated) {
1290
+ await git(["worktree", "remove", "--force", attempt.path]);
1291
+ task.worktreeRemoved = true;
1292
+ } else {
1293
+ // Native attempts were absent before mutation. Reconcile only exact
1294
+ // registered path/branch identity; never recursively delete a guessed leaf.
1295
+ const listed = (await git(["worktree", "list", "--porcelain"])).stdout;
1296
+ const entry = listed.split("\n\n").find((block) => block.split("\n").includes(`worktree ${attempt.path}`));
1297
+ if (entry) {
1298
+ if (!entry.split("\n").includes(`branch refs/heads/${attempt.branch}`)) {
1299
+ tx.unknown("Partial allocation ownership mismatch");
1300
+ throw new Error(tx.progress.unknown);
1301
+ }
1302
+ await git(["worktree", "remove", "--force", attempt.path]);
1303
+ } else if (fs.existsSync(attempt.path)) {
1304
+ tx.unknown("Unregistered partial allocation retained");
1305
+ throw new Error(tx.progress.unknown);
1306
+ }
1307
+ task.worktreeRemoved = true;
1308
+ }
1309
+ const branch = await git(["show-ref", "--verify", "--quiet", `refs/heads/${attempt.branch}`], [0, 1]);
1310
+ if (branch.status === 0) await git(["branch", "-D", attempt.branch]);
1311
+ task.branchRemoved = true;
1312
+ } catch (error) {
1313
+ task.preserved = true;
1314
+ task.errors = [error instanceof Error ? error.message : String(error)];
1315
+ }
1316
+ }
1317
+ try {
1318
+ if (attempts.length > 0) { await git(["worktree", "prune"]); report.pruned = true; }
1319
+ } catch (error) { report.errors!.push(error instanceof Error ? error.message : String(error)); }
1320
+ if (tx.progress.unknown) report.errors!.push(tx.progress.unknown);
1321
+ report.tasks.sort((a, b) => a.index - b.index);
1322
+ if (!setupPoison && (report.pruned || attempts.length === 0) && report.tasks.every((task) => task.worktreeRemoved && task.branchRemoved) && !report.errors!.length) report.state = "complete";
1323
+ return report;
1324
+ }
1325
+
1326
+ async function allocateWorktrees(tx: SetupTransaction, cwd: string, runId: string, count: number): Promise<WorktreeSetup> {
1327
+ const options = tx.options;
1328
+ tx.check();
1132
1329
  if (!Number.isSafeInteger(count) || count < 0) throw new Error("worktree count must be a non-negative integer");
1133
- const repo = resolveRepoState(cwd, options?.baseRef);
1330
+ const repo = await resolveRepoState(tx, cwd, options?.baseRef);
1331
+ tx.progress.setup.cwd = repo.toplevel;
1332
+ tx.progress.setup.baseCommit = repo.baseCommit;
1134
1333
  const setupHook = resolveWorktreeSetupHook(repo.toplevel, options?.setupHook);
1135
- const provider = resolveWorktreeProvider(options?.provider, options?.baseDir);
1334
+ const provider = await resolveSetupProvider(tx, options?.provider, options?.baseDir);
1136
1335
  const branchPrefix = normalizeWorktreeBranchPrefix(options?.branchPrefix);
1137
1336
  const dedicatedRoot = provider === "native" ? resolveWorktreeDedicatedRoot(options?.baseDir, repo.toplevel) : undefined;
1138
- const worktrees: WorktreeInfo[] = [];
1337
+ const worktrees = tx.progress.setup.worktrees;
1139
1338
 
1140
1339
  try {
1141
1340
  if (provider === "native") {
1142
- const plannedSetup: WorktreeSetup = {
1143
- cwd: repo.toplevel,
1144
- baseCommit: repo.baseCommit,
1145
- worktrees: Array.from({ length: count }, (_, index) => {
1146
- const naming = buildWorktreeNaming({ runId, index, agent: options?.agents?.[index], label: options?.labels?.[index], task: options?.tasks?.[index], branchPrefix });
1147
- const worktreePath = buildNativeWorktreePath(dedicatedRoot!, repo.toplevel, runId, index);
1148
- assertSafeWorktreeLocation(worktreePath, repo.toplevel, dedicatedRoot!);
1149
- return {
1150
- path: worktreePath,
1151
- agentCwd: repo.cwdRelative ? path.join(worktreePath, repo.cwdRelative) : worktreePath,
1152
- branch: naming.requestedBranch,
1153
- index,
1154
- nodeModulesLinked: false,
1155
- syntheticPaths: [],
1156
- provider: "native",
1157
- naming,
1158
- };
1159
- }),
1160
- };
1161
- options?.beforeCreate?.(plannedSetup);
1162
1341
  for (let index = 0; index < count; index++) {
1163
- worktrees.push(createNativeWorktree(
1342
+ worktrees[index] = await createNativeWorktree(
1343
+ tx,
1164
1344
  repo.toplevel,
1165
1345
  repo.cwdRelative,
1166
1346
  runId,
@@ -1172,28 +1352,26 @@ export function createWorktrees(cwd: string, runId: string, count: number, optio
1172
1352
  options?.labels,
1173
1353
  options?.tasks,
1174
1354
  branchPrefix,
1175
- ));
1355
+ );
1176
1356
  }
1177
1357
  } else {
1178
1358
  for (let index = 0; index < count; index++) {
1179
- worktrees.push(createWorktrunkWorktree(
1359
+ await createWorktrunkWorktree(
1360
+ tx,
1180
1361
  repo.toplevel,
1181
1362
  repo.cwdRelative,
1182
1363
  runId,
1183
1364
  index,
1184
1365
  repo.baseCommit,
1185
- repo.sourceCheckout,
1186
1366
  options?.agents,
1187
1367
  options?.labels,
1188
1368
  options?.tasks,
1189
1369
  branchPrefix,
1190
- ));
1370
+ );
1191
1371
  }
1192
- // Worktrunk determines its path only after creation. Journal the exact
1193
- // returned ownership before Pi runs setup hooks or launches a child.
1194
- options?.beforeCreate?.({ cwd: repo.toplevel, baseCommit: repo.baseCommit, worktrees: [...worktrees] });
1195
1372
  for (let index = 0; index < worktrees.length; index++) {
1196
- worktrees[index] = finalizeCreatedWorktree(
1373
+ worktrees[index] = await finalizeCreatedWorktree(
1374
+ tx,
1197
1375
  repo.toplevel,
1198
1376
  repo.cwdRelative,
1199
1377
  runId,
@@ -1204,23 +1382,37 @@ export function createWorktrees(cwd: string, runId: string, count: number, optio
1204
1382
  );
1205
1383
  }
1206
1384
  }
1385
+ tx.check();
1386
+ tx.progress.phase = "ready";
1387
+ tx.publish();
1388
+ tx.check();
1389
+ return tx.progress.setup;
1207
1390
  } catch (error) {
1208
- cleanupWorktrees({
1209
- cwd: repo.toplevel,
1210
- worktrees,
1211
- baseCommit: repo.baseCommit,
1212
- }, { kind: "setup-rollback" });
1391
+ await compensateSetup(tx);
1213
1392
  throw error;
1214
1393
  }
1394
+ }
1215
1395
 
1216
- return {
1217
- cwd: repo.toplevel,
1218
- worktrees,
1219
- baseCommit: repo.baseCommit,
1220
- };
1396
+ export async function createWorktrees(cwd: string, runId: string, count: number, options: CreateWorktreesOptions = {}): Promise<WorktreeSetup> {
1397
+ const tx = new SetupTransaction(cwd, options);
1398
+ try {
1399
+ return await withWorktreeTransaction(async () => {
1400
+ setupActive = true;
1401
+ try { return await allocateWorktrees(tx, cwd, runId, count); }
1402
+ finally { setupActive = false; }
1403
+ });
1404
+ } catch (error) {
1405
+ if (setupPoison) tx.progress.unknown ??= setupPoison;
1406
+ tx.progress.cleanup ??= { state: tx.progress.unknown ? "partial" : "complete", tasks: [], pruned: false, errors: tx.progress.unknown ? [tx.progress.unknown] : [] };
1407
+ try { tx.publish(); } catch (publicationError) {
1408
+ tx.progress.cleanup?.errors?.push(`Recovery publication failed: ${String(publicationError)}`);
1409
+ }
1410
+ throw new WorktreeSetupError(error, tx.snapshot());
1411
+ }
1221
1412
  }
1222
1413
 
1223
1414
  export function diffWorktrees(setup: WorktreeSetup, agents: string[], diffsDir: string): WorktreeDiff[] {
1415
+ assertWorktreeMutationAllowed();
1224
1416
  try {
1225
1417
  fs.mkdirSync(diffsDir, { recursive: true });
1226
1418
  } catch {
@@ -1250,6 +1442,7 @@ export function cleanupWorktrees(
1250
1442
  setup: WorktreeSetup,
1251
1443
  intent: WorktreeCleanupIntent = { kind: "preserve", ...(setup.capturedDiffs ? { capturedDiffs: setup.capturedDiffs } : {}) },
1252
1444
  ): WorktreeCleanupReport {
1445
+ assertWorktreeMutationAllowed();
1253
1446
  const tasks: WorktreeCleanupTask[] = [];
1254
1447
  for (let index = setup.worktrees.length - 1; index >= 0; index--) {
1255
1448
  tasks.push(cleanupSingleWorktree(setup, setup.worktrees[index]!, intent));