@agentproto/worktree 0.12.0 → 0.14.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.
@@ -1,13 +1,14 @@
1
- import { provisionWorktreeTool, cleanupWorktreeTool, runGateTool, runScriptTool, startServiceTool, stopServiceTool, listServicesTool } from './chunk-EK5BH2YL.mjs';
1
+ import { provisionWorktreeTool, cleanupWorktreeTool, runGateTool, runScriptTool, startServiceTool, stopServiceTool, listServicesTool } from './chunk-3FZWPMLF.mjs';
2
2
  import { implementTool, defineDriver } from '@agentproto/driver';
3
3
  import { randomUUID } from 'crypto';
4
- import { lstat, mkdir, symlink, appendFile, writeFile, copyFile, rename, readdir, stat, readFile } from 'fs/promises';
4
+ import { lstat, mkdir, symlink, appendFile, writeFile, copyFile, readFile, rename, readdir, stat } from 'fs/promises';
5
5
  import { resolve, join, dirname, basename, posix, sep } from 'path';
6
6
  import { ToolError } from '@agentproto/tool';
7
7
  import { spawn } from 'child_process';
8
8
  import { z } from 'zod';
9
- import { homedir } from 'os';
10
- import { existsSync, mkdirSync, realpathSync, readdirSync } from 'fs';
9
+ import { AsyncLocalStorage } from 'async_hooks';
10
+ import { loadavg, cpus, homedir } from 'os';
11
+ import { existsSync, mkdirSync, realpathSync, readFileSync, writeFileSync, rmSync, readdirSync } from 'fs';
11
12
  import { createServer } from 'net';
12
13
 
13
14
  /**
@@ -17,26 +18,81 @@ import { createServer } from 'net';
17
18
  function mergedEnv(extra) {
18
19
  return extra ? { ...process.env, ...extra } : process.env;
19
20
  }
21
+ function abortReason(signal) {
22
+ const reason = signal.reason;
23
+ return reason instanceof Error ? reason : new Error("aborted");
24
+ }
25
+ function signalTree(child, sig) {
26
+ if (child.pid === void 0) return;
27
+ try {
28
+ if (process.platform === "win32") child.kill(sig);
29
+ else process.kill(-child.pid, sig);
30
+ } catch {
31
+ }
32
+ }
33
+ function killOnAbort(child, opts) {
34
+ const { signal } = opts;
35
+ if (!signal) return () => {
36
+ };
37
+ let escalation;
38
+ const onAbort = () => {
39
+ signalTree(child, "SIGTERM");
40
+ escalation = setTimeout(() => signalTree(child, "SIGKILL"), opts.killGraceMs ?? 5e3);
41
+ escalation.unref();
42
+ };
43
+ if (signal.aborted) onAbort();
44
+ else signal.addEventListener("abort", onAbort, { once: true });
45
+ return () => {
46
+ signal.removeEventListener("abort", onAbort);
47
+ if (escalation && !signal.aborted) clearTimeout(escalation);
48
+ };
49
+ }
20
50
  function execArgv(command, args, cwd, opts = {}) {
21
51
  return new Promise((resolvePromise, reject) => {
22
- const child = spawn(command, args, { cwd, shell: false, env: mergedEnv(opts.env) });
52
+ if (opts.signal?.aborted) return reject(abortReason(opts.signal));
53
+ const child = spawn(command, args, {
54
+ cwd,
55
+ shell: false,
56
+ env: mergedEnv(opts.env),
57
+ detached: opts.signal !== void 0 && process.platform !== "win32"
58
+ });
59
+ const detach = killOnAbort(child, opts);
23
60
  let stdout = "";
24
61
  let stderr = "";
25
62
  child.stdout?.on("data", (d) => stdout += d.toString("utf8"));
26
63
  child.stderr?.on("data", (d) => stderr += d.toString("utf8"));
27
- child.on("error", reject);
28
- child.on("close", (code) => resolvePromise({ exitCode: code ?? -1, stdout, stderr }));
64
+ child.on("error", (err) => {
65
+ detach();
66
+ reject(err);
67
+ });
68
+ child.on("close", (code) => {
69
+ detach();
70
+ resolvePromise({ exitCode: code ?? -1, stdout, stderr });
71
+ });
29
72
  });
30
73
  }
31
74
  function execShell(cmd, cwd, opts = {}) {
32
75
  return new Promise((resolvePromise, reject) => {
33
- const child = spawn(cmd, { cwd, shell: true, env: mergedEnv(opts.env) });
76
+ if (opts.signal?.aborted) return reject(abortReason(opts.signal));
77
+ const child = spawn(cmd, {
78
+ cwd,
79
+ shell: true,
80
+ env: mergedEnv(opts.env),
81
+ detached: opts.signal !== void 0 && process.platform !== "win32"
82
+ });
83
+ const detach = killOnAbort(child, opts);
34
84
  let stdout = "";
35
85
  let stderr = "";
36
86
  child.stdout?.on("data", (d) => stdout += d.toString("utf8"));
37
87
  child.stderr?.on("data", (d) => stderr += d.toString("utf8"));
38
- child.on("error", reject);
39
- child.on("close", (code) => resolvePromise({ exitCode: code ?? -1, stdout, stderr }));
88
+ child.on("error", (err) => {
89
+ detach();
90
+ reject(err);
91
+ });
92
+ child.on("close", (code) => {
93
+ detach();
94
+ resolvePromise({ exitCode: code ?? -1, stdout, stderr });
95
+ });
40
96
  });
41
97
  }
42
98
  async function execGit(repoRoot, args) {
@@ -106,6 +162,53 @@ async function expandGlob(root, pattern) {
106
162
  const candidates = await walk(root, base);
107
163
  return candidates.filter((rel) => regex.test(rel));
108
164
  }
165
+ var GlobTraversalError = class extends Error {
166
+ constructor(pattern) {
167
+ super(`glob pattern '${pattern}' escapes the repo root (path traversal)`);
168
+ this.name = "GlobTraversalError";
169
+ }
170
+ };
171
+ function assertNoTraversal(pattern) {
172
+ if (pattern.startsWith("/")) throw new GlobTraversalError(pattern);
173
+ if (pattern.split("/").some((seg) => seg === "..")) throw new GlobTraversalError(pattern);
174
+ }
175
+ async function expandCloneGlob(root, pattern) {
176
+ assertNoTraversal(pattern);
177
+ const segments = pattern.split("/").filter((seg) => seg.length > 0);
178
+ if (segments.length === 0) return [];
179
+ if (segments.includes("**")) {
180
+ throw new Error(
181
+ `cloneGlobs pattern '${pattern}' uses '**', which is not supported \u2014 name each path segment explicitly so a directory match clones as one unit.`
182
+ );
183
+ }
184
+ async function walkMatch(dirRel, idx) {
185
+ const seg = segments[idx];
186
+ const isLast = idx === segments.length - 1;
187
+ const abs = dirRel ? join(root, dirRel) : root;
188
+ let entries;
189
+ try {
190
+ entries = await readdir(abs, { withFileTypes: true });
191
+ } catch {
192
+ return [];
193
+ }
194
+ const regex = new RegExp(`^${escapeSegment(seg)}$`);
195
+ const out = [];
196
+ for (const entry of entries) {
197
+ if (entry.name === ".git") continue;
198
+ if (!regex.test(entry.name)) continue;
199
+ const rel = dirRel ? posix.join(dirRel, entry.name) : entry.name;
200
+ if (isLast) {
201
+ out.push(rel);
202
+ continue;
203
+ }
204
+ if (entry.isDirectory()) {
205
+ out.push(...await walkMatch(rel, idx + 1));
206
+ }
207
+ }
208
+ return out;
209
+ }
210
+ return walkMatch("", 0);
211
+ }
109
212
  var CONFIG_FILENAME = "agentproto.json";
110
213
  var hookSchema = z.union([z.string(), z.array(z.string())]);
111
214
  var scriptSchema = z.object({
@@ -182,6 +285,280 @@ async function loadConfigFromBase(repoRoot, base = "origin/main") {
182
285
  }
183
286
  return parseConfig(result.stdout);
184
287
  }
288
+ var LOCAL_WORKTREE_CONFIG_REL = ".agentproto/worktree.json";
289
+ var localWriteFileEntrySchema = z.object({
290
+ path: z.string().min(1, "writeFiles[].path must be non-empty"),
291
+ content: z.string(),
292
+ mode: z.enum(["create", "append"]).optional()
293
+ });
294
+ var localWorktreeConfigSchema = z.object({
295
+ copyGlobs: z.array(z.string()).optional(),
296
+ /** Gitignored dirs/files cloned (copy-on-write where the filesystem
297
+ * supports it) from the source checkout into the worktree before
298
+ * `depsCmd` runs — see `clone.ts`. */
299
+ cloneGlobs: z.array(z.string()).optional(),
300
+ writeFiles: z.array(localWriteFileEntrySchema).optional(),
301
+ depsCmd: z.string().optional(),
302
+ linkPaths: z.array(z.string()).optional()
303
+ });
304
+ function parseLocalWorktreeConfig(raw) {
305
+ let json;
306
+ try {
307
+ json = JSON.parse(raw);
308
+ } catch (err) {
309
+ throw new ConfigError(
310
+ `${LOCAL_WORKTREE_CONFIG_REL} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
311
+ );
312
+ }
313
+ const result = localWorktreeConfigSchema.safeParse(json);
314
+ if (!result.success) {
315
+ const issues = result.error.issues.map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`).join("; ");
316
+ throw new ConfigError(`${LOCAL_WORKTREE_CONFIG_REL} failed validation: ${issues}`);
317
+ }
318
+ return result.data;
319
+ }
320
+ async function loadLocalWorktreeConfig(workspace) {
321
+ const path = resolve(workspace, LOCAL_WORKTREE_CONFIG_REL);
322
+ let raw;
323
+ try {
324
+ raw = await readFile(path, "utf8");
325
+ } catch (err) {
326
+ if (err.code === "ENOENT") return null;
327
+ throw err;
328
+ }
329
+ return parseLocalWorktreeConfig(raw);
330
+ }
331
+ var SLUG_PLACEHOLDER = /\{slug\}/g;
332
+ function applySlugPlaceholder(text, slug) {
333
+ return text.replace(SLUG_PLACEHOLDER, slug);
334
+ }
335
+ function resolveLocalWriteFiles(files, slug) {
336
+ return (files ?? []).map((file) => ({
337
+ ...file,
338
+ path: applySlugPlaceholder(file.path, slug),
339
+ content: applySlugPlaceholder(file.content, slug)
340
+ }));
341
+ }
342
+ var DEFAULT_PROVISION_CONCURRENCY = 2;
343
+ var PROVISION_CONCURRENCY_ENV = "AGENTPROTO_WORKTREES_PROVISION_CONCURRENCY";
344
+ var DEFAULT_PROVISION_LIMITS = Object.freeze({
345
+ concurrency: DEFAULT_PROVISION_CONCURRENCY,
346
+ perRepo: Object.freeze({}),
347
+ loadFactor: 0
348
+ });
349
+ function parseCap(raw) {
350
+ if (typeof raw === "string" && raw.trim() !== "") raw = Number(raw);
351
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) return void 0;
352
+ return raw === 0 ? Infinity : raw;
353
+ }
354
+ function normalizeRepoKey(key) {
355
+ const trimmed = key.trim();
356
+ if (trimmed.startsWith("~/")) return resolve(homedir(), trimmed.slice(2));
357
+ if (trimmed.includes("/") || trimmed.includes("\\")) return resolve(trimmed);
358
+ return trimmed;
359
+ }
360
+ function parseProvisionLimits(raw, env = process.env) {
361
+ const section = raw !== null && typeof raw === "object" ? raw : {};
362
+ const concurrency = parseCap(env[PROVISION_CONCURRENCY_ENV]) ?? parseCap(section["provisionConcurrency"]) ?? DEFAULT_PROVISION_CONCURRENCY;
363
+ const perRepo = {};
364
+ const byRepo = section["provisionConcurrencyByRepo"];
365
+ if (byRepo !== null && typeof byRepo === "object" && !Array.isArray(byRepo)) {
366
+ for (const [key, value] of Object.entries(byRepo)) {
367
+ const cap = parseCap(value);
368
+ if (cap !== void 0 && key.trim() !== "") perRepo[normalizeRepoKey(key)] = cap;
369
+ }
370
+ }
371
+ const factor = section["provisionLoadFactor"];
372
+ const loadFactor = typeof factor === "number" && Number.isFinite(factor) && factor > 0 ? factor : 0;
373
+ return { concurrency, perRepo, loadFactor };
374
+ }
375
+ var ProvisionCancelledError = class extends Error {
376
+ constructor(message = "worktree provisioning cancelled") {
377
+ super(message);
378
+ this.name = "ProvisionCancelledError";
379
+ }
380
+ };
381
+ var contextStore = new AsyncLocalStorage();
382
+ function runWithProvisionContext(ctx, fn) {
383
+ return contextStore.run(ctx, fn);
384
+ }
385
+ function currentProvisionContext() {
386
+ return contextStore.getStore();
387
+ }
388
+ var ProvisionScheduler = class {
389
+ limits;
390
+ queue = [];
391
+ running = /* @__PURE__ */ new Set();
392
+ seq = 0;
393
+ loadTimer;
394
+ loadavg1;
395
+ cpuCount;
396
+ loadPollMs;
397
+ constructor(opts = {}) {
398
+ this.limits = opts.limits ?? DEFAULT_PROVISION_LIMITS;
399
+ this.loadavg1 = opts.loadavg1 ?? (() => loadavg()[0] ?? 0);
400
+ this.cpuCount = opts.cpuCount ?? (() => cpus().length || 1);
401
+ this.loadPollMs = opts.loadPollMs ?? 5e3;
402
+ }
403
+ /** Replace the limits (config is re-read per provision). Raising a cap
404
+ * admits waiters immediately; lowering one never interrupts a running
405
+ * segment, it just delays the next admission. */
406
+ configure(limits) {
407
+ this.limits = limits;
408
+ this.pump();
409
+ }
410
+ snapshot() {
411
+ return { running: this.running.size, queued: this.queue.length, limit: this.limits.concurrency };
412
+ }
413
+ /**
414
+ * Request a heavy-phase slot. Resolves with a lease once admitted; rejects
415
+ * with {@link ProvisionCancelledError} when `signal` aborts first (the
416
+ * queue entry is dropped, so a cancelled spawn never occupies a slot).
417
+ */
418
+ acquire(req) {
419
+ if (req.signal?.aborted) return Promise.reject(new ProvisionCancelledError());
420
+ return new Promise((resolvePromise, rejectPromise) => {
421
+ const waiter = {
422
+ seq: this.seq++,
423
+ repoKey: req.repoKey,
424
+ callerKey: req.callerKey ?? "anonymous",
425
+ signal: req.signal,
426
+ onQueued: req.onQueued,
427
+ lastPosition: 0,
428
+ grant: resolvePromise,
429
+ reject: rejectPromise,
430
+ detach: () => {
431
+ }
432
+ };
433
+ if (req.signal) {
434
+ const onAbort = () => {
435
+ const idx = this.queue.indexOf(waiter);
436
+ if (idx === -1) return;
437
+ this.queue.splice(idx, 1);
438
+ waiter.reject(new ProvisionCancelledError("worktree provisioning cancelled while queued"));
439
+ this.pump();
440
+ };
441
+ req.signal.addEventListener("abort", onAbort, { once: true });
442
+ waiter.detach = () => req.signal.removeEventListener("abort", onAbort);
443
+ }
444
+ this.queue.push(waiter);
445
+ this.pump();
446
+ });
447
+ }
448
+ repoCap(repoKey) {
449
+ const { perRepo } = this.limits;
450
+ const own = (key) => Object.hasOwn(perRepo, key) ? perRepo[key] : void 0;
451
+ return Math.min(own(repoKey) ?? own(basename(repoKey)) ?? Infinity, this.limits.concurrency);
452
+ }
453
+ runningFor(match) {
454
+ let n = 0;
455
+ for (const slot of this.running) if (match(slot)) n++;
456
+ return n;
457
+ }
458
+ /** Waiters in the order they would be admitted absent per-repo caps. */
459
+ projectedOrder() {
460
+ const held = /* @__PURE__ */ new Map();
461
+ for (const slot of this.running) held.set(slot.callerKey, (held.get(slot.callerKey) ?? 0) + 1);
462
+ const remaining = [...this.queue];
463
+ const ordered = [];
464
+ while (remaining.length > 0) {
465
+ let best = 0;
466
+ for (let i = 1; i < remaining.length; i++) {
467
+ if ((held.get(remaining[i].callerKey) ?? 0) < (held.get(remaining[best].callerKey) ?? 0)) best = i;
468
+ }
469
+ const [next] = remaining.splice(best, 1);
470
+ ordered.push(next);
471
+ held.set(next.callerKey, (held.get(next.callerKey) ?? 0) + 1);
472
+ }
473
+ return ordered;
474
+ }
475
+ loadGuardBlocks() {
476
+ const { loadFactor } = this.limits;
477
+ if (loadFactor <= 0) return false;
478
+ if (this.running.size === 0) return false;
479
+ return this.loadavg1() > this.cpuCount() * loadFactor;
480
+ }
481
+ pump() {
482
+ for (; ; ) {
483
+ if (this.queue.length === 0) break;
484
+ if (this.running.size >= this.limits.concurrency) break;
485
+ if (this.loadGuardBlocks()) {
486
+ this.armLoadTimer();
487
+ break;
488
+ }
489
+ const next = this.projectedOrder().find(
490
+ (w) => this.runningFor((s) => s.repoKey === w.repoKey) < this.repoCap(w.repoKey)
491
+ );
492
+ if (!next) break;
493
+ this.queue.splice(this.queue.indexOf(next), 1);
494
+ next.detach();
495
+ const slot = { repoKey: next.repoKey, callerKey: next.callerKey };
496
+ this.running.add(slot);
497
+ let released = false;
498
+ next.grant({
499
+ release: () => {
500
+ if (released) return;
501
+ released = true;
502
+ this.running.delete(slot);
503
+ this.pump();
504
+ }
505
+ });
506
+ }
507
+ this.notifyPositions();
508
+ }
509
+ notifyPositions() {
510
+ this.projectedOrder().forEach((waiter, idx) => {
511
+ const position = idx + 1;
512
+ if (position === waiter.lastPosition) return;
513
+ waiter.lastPosition = position;
514
+ try {
515
+ waiter.onQueued?.(position);
516
+ } catch {
517
+ }
518
+ });
519
+ }
520
+ armLoadTimer() {
521
+ if (this.loadTimer) return;
522
+ this.loadTimer = setTimeout(() => {
523
+ this.loadTimer = void 0;
524
+ this.pump();
525
+ }, this.loadPollMs);
526
+ this.loadTimer.unref();
527
+ }
528
+ };
529
+ var provisionScheduler = new ProvisionScheduler();
530
+
531
+ // src/clone.ts
532
+ async function cloneEntry(src, dest, signal) {
533
+ const execOpts = signal ? { signal } : {};
534
+ await mkdir(dirname(dest), { recursive: true });
535
+ const platform = process.platform;
536
+ const cloneArgs = platform === "darwin" ? ["-Rc", src, dest] : platform === "linux" ? ["-r", "--reflink=auto", src, dest] : null;
537
+ if (cloneArgs) {
538
+ const result = await execArgv("cp", cloneArgs, dirname(dest), execOpts);
539
+ if (result.exitCode === 0) return;
540
+ if (signal?.aborted) throw new ProvisionCancelledError();
541
+ }
542
+ const fallback = await execArgv("cp", ["-R", src, dest], dirname(dest), execOpts);
543
+ if (signal?.aborted) throw new ProvisionCancelledError();
544
+ if (fallback.exitCode !== 0) {
545
+ throw new Error(
546
+ `clone of '${src}' into '${dest}' failed: ${fallback.stderr || fallback.stdout}`
547
+ );
548
+ }
549
+ }
550
+ async function cloneEntries(repoRoot, cwd, patterns, signal) {
551
+ for (const pattern of patterns) {
552
+ const matches = await expandCloneGlob(repoRoot, pattern);
553
+ for (const rel of matches) {
554
+ if (signal?.aborted) throw new ProvisionCancelledError();
555
+ const dest = join(cwd, rel);
556
+ const existing = await lstat(dest).catch(() => null);
557
+ if (existing) continue;
558
+ await cloneEntry(join(repoRoot, rel), dest, signal);
559
+ }
560
+ }
561
+ }
185
562
  var ENV_VARS = {
186
563
  /** Absolute path to the original repo checkout the worktree was cut from. */
187
564
  sourceCheckoutPath: "AGENTPROTO_SOURCE_CHECKOUT_PATH",
@@ -282,16 +659,20 @@ async function runSetup(config, ctx, opts = {}) {
282
659
  const commands = normalizeHook(config.worktree?.setup);
283
660
  const runs = [];
284
661
  const env = { ...hookEnv(ctx), TURBO_CACHE_DIR: resolveWorktreesTurboCacheDir() };
662
+ const execOpts = { env, ...opts.signal ? { signal: opts.signal } : {} };
285
663
  for (const command of commands) {
286
- let result = await execShell(command, ctx.worktreePath, { env });
664
+ if (opts.signal?.aborted) throw new ProvisionCancelledError();
665
+ let result = await execShell(command, ctx.worktreePath, execOpts);
287
666
  let run = { command, result };
288
667
  runs.push(run);
289
668
  await appendHookLog(opts.logPath, command, result);
669
+ if (opts.signal?.aborted) throw new ProvisionCancelledError();
290
670
  if (result.exitCode !== 0 && opts.retryOnFailure) {
291
- result = await execShell(command, ctx.worktreePath, { env });
671
+ result = await execShell(command, ctx.worktreePath, execOpts);
292
672
  run = { command, result };
293
673
  runs.push(run);
294
674
  await appendHookLog(opts.logPath, `${command} (retry 1/1)`, result);
675
+ if (opts.signal?.aborted) throw new ProvisionCancelledError();
295
676
  }
296
677
  if (result.exitCode !== 0) throw new HookError("setup", run, opts.logPath);
297
678
  }
@@ -308,6 +689,127 @@ async function runTeardown(config, ctx, opts = {}) {
308
689
  }
309
690
  return runs;
310
691
  }
692
+ var WORKTREE_TRASH_DIRNAME = ".trash";
693
+ var WORKTREE_TRASH_PIDFILE = ".deleting";
694
+ var DELETER_SCRIPT = `trap 'rm -f "$1/.deleting"' EXIT; [ ! -e "$1/.deleting" ] || echo $$ > "$1/.deleting"; n=1; while [ "$n" -gt 0 ]; do n=0; for d in "$1"/*; do [ -e "$d" ] || continue; rm -rf "$d"; n=1; done; done`;
695
+ function isPidAlive(pid) {
696
+ try {
697
+ process.kill(pid, 0);
698
+ return true;
699
+ } catch (err) {
700
+ return err.code === "EPERM";
701
+ }
702
+ }
703
+ function defaultSpawnDeleter(trashParent) {
704
+ const child = spawn("sh", ["-c", DELETER_SCRIPT, "_", trashParent], { detached: true, stdio: "ignore" });
705
+ child.unref();
706
+ return child.pid;
707
+ }
708
+ function ensureTrashDeleter(trashParent, spawnDeleter = defaultSpawnDeleter) {
709
+ const pidFile = join(trashParent, WORKTREE_TRASH_PIDFILE);
710
+ try {
711
+ const pid2 = Number.parseInt(readFileSync(pidFile, "utf8").trim(), 10);
712
+ if (Number.isInteger(pid2) && pid2 > 0 && isPidAlive(pid2)) return;
713
+ } catch {
714
+ }
715
+ let placeholder = false;
716
+ try {
717
+ writeFileSync(pidFile, String(process.pid));
718
+ placeholder = true;
719
+ } catch {
720
+ }
721
+ let pid;
722
+ try {
723
+ pid = spawnDeleter(trashParent);
724
+ } catch (err) {
725
+ if (placeholder) rmSync(pidFile, { force: true });
726
+ throw err;
727
+ }
728
+ if (pid === void 0 && placeholder) rmSync(pidFile, { force: true });
729
+ }
730
+ function realOrResolved(path) {
731
+ try {
732
+ return realpathSync(path);
733
+ } catch {
734
+ return path;
735
+ }
736
+ }
737
+ async function removeWorktreeFast(repoRoot, path, options = {}) {
738
+ const force = options.force === true;
739
+ if (!force) {
740
+ const status = await execArgv(
741
+ "git",
742
+ ["-C", path, "status", "--porcelain", "--untracked-files=normal", "--ignore-submodules=none"],
743
+ repoRoot
744
+ );
745
+ if (status.exitCode !== 0) {
746
+ throw new Error(
747
+ `git status failed in ${path} (exit ${status.exitCode}): ${status.stderr.trim() || status.stdout.trim()}`
748
+ );
749
+ }
750
+ if (status.stdout.trim().length > 0) {
751
+ throw new Error(
752
+ `git worktree remove failed (exit 128): '${path}' contains modified or untracked files, use --force to delete it`
753
+ );
754
+ }
755
+ }
756
+ const list = await execArgv("git", ["-C", repoRoot, "worktree", "list", "--porcelain"], repoRoot);
757
+ if (list.exitCode !== 0) {
758
+ throw new Error(
759
+ `git worktree list failed (exit ${list.exitCode}): ${list.stderr.trim() || list.stdout.trim()}`
760
+ );
761
+ }
762
+ const target = realOrResolved(path);
763
+ let found = false;
764
+ let locked = false;
765
+ for (const block of list.stdout.split(/\n\s*\n/)) {
766
+ const lines = block.split("\n");
767
+ const head = lines.find((line) => line.startsWith("worktree "));
768
+ if (!head || realOrResolved(head.slice("worktree ".length).trim()) !== target) continue;
769
+ found = true;
770
+ locked = lines.some((line) => line === "locked" || line.startsWith("locked "));
771
+ break;
772
+ }
773
+ if (!found) {
774
+ throw new Error(`not a linked worktree of ${repoRoot}: ${path}`);
775
+ }
776
+ if (locked) {
777
+ throw new Error(`git worktree remove failed (exit 128): '${path}' is locked; unlock it first`);
778
+ }
779
+ const poolDir = join(path, "..");
780
+ const stamp = (options.now ?? Date.now)();
781
+ const trashDir = join(poolDir, WORKTREE_TRASH_DIRNAME, `${basename(path)}-${stamp}`);
782
+ try {
783
+ if (!existsSync(join(poolDir, WORKTREE_TRASH_DIRNAME))) {
784
+ mkdirSync(join(poolDir, WORKTREE_TRASH_DIRNAME));
785
+ }
786
+ await rename(path, trashDir);
787
+ } catch {
788
+ await execGit(repoRoot, force ? ["worktree", "remove", "--force", path] : ["worktree", "remove", path]);
789
+ return;
790
+ }
791
+ await execGit(repoRoot, ["worktree", "prune"]);
792
+ if (options.spawnRemoval) {
793
+ options.spawnRemoval(trashDir);
794
+ } else {
795
+ ensureTrashDeleter(dirname(trashDir), options.spawnDeleter);
796
+ }
797
+ }
798
+ function sweepWorktreeTrash(root, options = {}) {
799
+ let names;
800
+ try {
801
+ names = readdirSync(join(root, WORKTREE_TRASH_DIRNAME));
802
+ } catch {
803
+ return;
804
+ }
805
+ names = names.filter((name) => name !== WORKTREE_TRASH_PIDFILE);
806
+ if (names.length === 0) return;
807
+ if (options.spawnRemoval) {
808
+ for (const name of names) options.spawnRemoval(join(root, WORKTREE_TRASH_DIRNAME, name));
809
+ } else {
810
+ ensureTrashDeleter(join(root, WORKTREE_TRASH_DIRNAME), options.spawnDeleter);
811
+ }
812
+ }
311
813
  var SESSIONS_FILE_PATH = () => resolve(homedir(), ".agentproto", "sessions.json");
312
814
  var SESSIONS_BUCKETS_ROOT = () => resolve(homedir(), ".agentproto", "workspaces");
313
815
  var sessionRefSchema = z.object({
@@ -443,6 +945,12 @@ async function computeProvenance(repoRoot, worktreePath, options = {}) {
443
945
  }
444
946
 
445
947
  // src/provider/bodies/provision-worktree.body.ts
948
+ async function discardCancelledWorktree(repoRoot, cwd, branch) {
949
+ await removeWorktreeFast(repoRoot, cwd, { force: true }).catch(() => {
950
+ });
951
+ await execGit(repoRoot, ["branch", "-D", branch]).catch(() => {
952
+ });
953
+ }
446
954
  var provisionWorktreeBuiltin = implementTool(
447
955
  provisionWorktreeTool,
448
956
  async ({ input }) => {
@@ -455,8 +963,12 @@ var provisionWorktreeBuiltin = implementTool(
455
963
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
456
964
  });
457
965
  const config = input.runSetup !== false ? await loadConfigFromBase(input.repoRoot, base) : null;
458
- const linkPaths = input.linkPaths ?? config?.worktree?.linkPaths ?? [];
459
- const depsCmd = input.depsCmd ?? config?.worktree?.depsCmd;
966
+ const localConfig = input.runSetup !== false ? await loadLocalWorktreeConfig(input.repoRoot) : null;
967
+ const linkPaths = input.linkPaths ?? localConfig?.linkPaths ?? config?.worktree?.linkPaths ?? [];
968
+ const depsCmd = input.depsCmd ?? localConfig?.depsCmd ?? config?.worktree?.depsCmd;
969
+ const copyGlobs = input.copyGlobs ?? localConfig?.copyGlobs ?? [];
970
+ const cloneGlobs = input.cloneGlobs ?? localConfig?.cloneGlobs ?? [];
971
+ const writeFiles = input.writeFiles ?? resolveLocalWriteFiles(localConfig?.writeFiles, input.slug);
460
972
  for (const rel of linkPaths) {
461
973
  const target = resolve(input.repoRoot, rel);
462
974
  const dest = join(cwd, rel);
@@ -465,7 +977,7 @@ var provisionWorktreeBuiltin = implementTool(
465
977
  await mkdir(dirname(dest), { recursive: true });
466
978
  await symlink(target, dest, "dir");
467
979
  }
468
- for (const file of input.writeFiles ?? []) {
980
+ for (const file of writeFiles) {
469
981
  const dest = join(cwd, file.path);
470
982
  await mkdir(dirname(dest), { recursive: true });
471
983
  if (file.mode === "append") {
@@ -478,146 +990,124 @@ var provisionWorktreeBuiltin = implementTool(
478
990
  await writeFile(dest, file.content);
479
991
  }
480
992
  }
481
- if (depsCmd) {
482
- const result = await execShell(depsCmd, cwd);
483
- if (result.exitCode !== 0) {
484
- throw new ToolError({
485
- code: "execution_failed",
486
- message: `depsCmd '${depsCmd}' failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`
487
- });
993
+ const ctx = currentProvisionContext();
994
+ const signal = ctx?.signal;
995
+ const report = (progress) => {
996
+ try {
997
+ ctx?.onProgress?.(progress);
998
+ } catch {
488
999
  }
1000
+ };
1001
+ const hasSetup = config ? normalizeHook(config.worktree?.setup).length > 0 : false;
1002
+ const firstHeavy = cloneGlobs.length > 0 ? "clone" : depsCmd ? "deps" : copyGlobs.length > 0 ? "copy" : hasSetup ? "setup" : null;
1003
+ if (firstHeavy === null) {
1004
+ report({ kind: "done", outcome: "ok" });
1005
+ return { cwd, branch };
489
1006
  }
490
- for (const pattern of input.copyGlobs ?? []) {
491
- const matches = await expandGlob(input.repoRoot, pattern);
492
- for (const rel of matches) {
493
- const dest = join(cwd, rel);
494
- await mkdir(dirname(dest), { recursive: true });
495
- await copyFile(join(input.repoRoot, rel), dest);
1007
+ let phase = firstHeavy;
1008
+ let lease;
1009
+ try {
1010
+ lease = await provisionScheduler.acquire({
1011
+ repoKey: resolve(input.repoRoot),
1012
+ ...ctx?.callerKey ? { callerKey: ctx.callerKey } : {},
1013
+ ...signal ? { signal } : {},
1014
+ onQueued: (position) => report({ kind: "queued", position, phase: firstHeavy })
1015
+ });
1016
+ if (signal?.aborted) throw new ProvisionCancelledError();
1017
+ report({ kind: "started", phase });
1018
+ if (cloneGlobs.length > 0) {
1019
+ phase = "clone";
1020
+ report({ kind: "phase", phase });
1021
+ try {
1022
+ await cloneEntries(input.repoRoot, cwd, cloneGlobs, signal);
1023
+ } catch (err) {
1024
+ if (err instanceof ProvisionCancelledError) throw err;
1025
+ throw new ToolError({
1026
+ code: "execution_failed",
1027
+ message: `cloneGlobs failed: ${err instanceof Error ? err.message : String(err)}`
1028
+ });
1029
+ }
496
1030
  }
497
- }
498
- if (config) {
499
- try {
500
- await runSetup(
501
- config,
502
- {
503
- sourceCheckoutPath: input.repoRoot,
504
- worktreePath: cwd,
505
- branchName: branch
506
- },
507
- {
508
- ...input.setupLogPath ? { logPath: input.setupLogPath } : {},
509
- ...input.retrySetupOnFailure ? { retryOnFailure: true } : {}
1031
+ if (depsCmd) {
1032
+ phase = "deps";
1033
+ report({ kind: "phase", phase });
1034
+ const result = await execShell(depsCmd, cwd, signal ? { signal } : {});
1035
+ if (signal?.aborted) throw new ProvisionCancelledError();
1036
+ if (result.exitCode !== 0) {
1037
+ throw new ToolError({
1038
+ code: "execution_failed",
1039
+ message: `depsCmd '${depsCmd}' failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`
1040
+ });
1041
+ }
1042
+ }
1043
+ if (copyGlobs.length > 0) {
1044
+ phase = "copy";
1045
+ report({ kind: "phase", phase });
1046
+ for (const pattern of copyGlobs) {
1047
+ const matches = await expandGlob(input.repoRoot, pattern);
1048
+ for (const rel of matches) {
1049
+ if (signal?.aborted) throw new ProvisionCancelledError();
1050
+ const dest = join(cwd, rel);
1051
+ await mkdir(dirname(dest), { recursive: true });
1052
+ await copyFile(join(input.repoRoot, rel), dest);
510
1053
  }
511
- );
512
- } catch (err) {
513
- if (err instanceof HookError) {
514
- throw new ToolError({ code: "execution_failed", message: err.message });
515
1054
  }
516
- throw err;
517
1055
  }
1056
+ if (config && hasSetup) {
1057
+ phase = "setup";
1058
+ report({ kind: "phase", phase });
1059
+ try {
1060
+ await runSetup(
1061
+ config,
1062
+ {
1063
+ sourceCheckoutPath: input.repoRoot,
1064
+ worktreePath: cwd,
1065
+ branchName: branch
1066
+ },
1067
+ {
1068
+ ...input.setupLogPath ? { logPath: input.setupLogPath } : {},
1069
+ ...input.retrySetupOnFailure ? { retryOnFailure: true } : {},
1070
+ ...signal ? { signal } : {}
1071
+ }
1072
+ );
1073
+ } catch (err) {
1074
+ if (err instanceof HookError) {
1075
+ throw new ToolError({ code: "execution_failed", message: err.message });
1076
+ }
1077
+ throw err;
1078
+ }
1079
+ }
1080
+ } catch (err) {
1081
+ const cancelled = err instanceof ProvisionCancelledError;
1082
+ report({ kind: "done", outcome: cancelled ? "cancelled" : "failed" });
1083
+ if (cancelled) await discardCancelledWorktree(input.repoRoot, cwd, branch);
1084
+ throw err;
1085
+ } finally {
1086
+ lease?.release();
518
1087
  }
1088
+ report({ kind: "done", outcome: "ok" });
519
1089
  return { cwd, branch };
520
1090
  }
521
1091
  );
522
- var WORKTREE_TRASH_DIRNAME = ".trash";
523
- function realOrResolved(path) {
524
- try {
525
- return realpathSync(path);
526
- } catch {
527
- return path;
528
- }
529
- }
530
- async function removeWorktreeFast(repoRoot, path, options = {}) {
531
- const force = options.force === true;
532
- if (!force) {
533
- const status = await execArgv(
534
- "git",
535
- ["-C", path, "status", "--porcelain=v2", "--ignored=matching"],
536
- repoRoot
537
- );
538
- if (status.exitCode !== 0) {
539
- throw new Error(
540
- `git status failed in ${path} (exit ${status.exitCode}): ${status.stderr.trim() || status.stdout.trim()}`
541
- );
542
- }
543
- if (status.stdout.trim().length > 0) {
544
- const probe = await execArgv("git", ["-C", repoRoot, "worktree", "remove", path], repoRoot);
545
- if (probe.exitCode !== 0) {
546
- throw new Error(
547
- `git worktree remove failed (exit ${probe.exitCode}): ${probe.stderr.trim() || probe.stdout.trim()}`
548
- );
549
- }
550
- await execGit(repoRoot, ["worktree", "prune"]);
551
- return;
552
- }
553
- }
554
- const list = await execArgv("git", ["-C", repoRoot, "worktree", "list", "--porcelain"], repoRoot);
555
- if (list.exitCode !== 0) {
556
- throw new Error(
557
- `git worktree list failed (exit ${list.exitCode}): ${list.stderr.trim() || list.stdout.trim()}`
558
- );
559
- }
560
- const registered = new Set(
561
- list.stdout.split("\n").filter((line) => line.startsWith("worktree ")).map((line) => line.slice("worktree ".length).trim())
562
- );
563
- const registeredReal = new Set([...registered].map(realOrResolved));
564
- if (!registeredReal.has(realOrResolved(path))) {
565
- throw new Error(`not a linked worktree of ${repoRoot}: ${path}`);
566
- }
567
- const poolDir = join(path, "..");
568
- const stamp = (options.now ?? Date.now)();
569
- const trashDir = join(poolDir, WORKTREE_TRASH_DIRNAME, `${basename(path)}-${stamp}`);
570
- try {
571
- if (!existsSync(join(poolDir, WORKTREE_TRASH_DIRNAME))) {
572
- mkdirSync(join(poolDir, WORKTREE_TRASH_DIRNAME));
573
- }
574
- await rename(path, trashDir);
575
- } catch {
576
- await execGit(repoRoot, force ? ["worktree", "remove", "--force", path] : ["worktree", "remove", path]);
577
- return;
578
- }
579
- await execGit(repoRoot, ["worktree", "prune"]);
580
- if (options.spawnRemoval) {
581
- options.spawnRemoval(trashDir);
582
- } else {
583
- spawn("rm", ["-rf", trashDir], { detached: true, stdio: "ignore" }).unref();
584
- }
585
- }
586
- function sweepWorktreeTrash(root, options = {}) {
587
- let names;
588
- try {
589
- names = readdirSync(join(root, WORKTREE_TRASH_DIRNAME));
590
- } catch {
591
- return;
592
- }
593
- for (const name of names) {
594
- const trashDir = join(root, WORKTREE_TRASH_DIRNAME, name);
595
- if (options.spawnRemoval) {
596
- options.spawnRemoval(trashDir);
597
- } else {
598
- spawn("rm", ["-rf", trashDir], { detached: true, stdio: "ignore" }).unref();
599
- }
600
- }
601
- }
602
1092
  function isPortFree(port) {
603
- return new Promise((resolve5) => {
1093
+ return new Promise((resolve7) => {
604
1094
  const server = createServer();
605
- server.once("error", () => resolve5(false));
1095
+ server.once("error", () => resolve7(false));
606
1096
  server.once("listening", () => {
607
- server.close(() => resolve5(true));
1097
+ server.close(() => resolve7(true));
608
1098
  });
609
1099
  server.listen(port, "127.0.0.1");
610
1100
  });
611
1101
  }
612
1102
  function ephemeralPort() {
613
- return new Promise((resolve5, reject) => {
1103
+ return new Promise((resolve7, reject) => {
614
1104
  const server = createServer();
615
1105
  server.once("error", reject);
616
1106
  server.listen(0, "127.0.0.1", () => {
617
1107
  const address = server.address();
618
1108
  if (address && typeof address === "object") {
619
1109
  const port = address.port;
620
- server.close(() => resolve5(port));
1110
+ server.close(() => resolve7(port));
621
1111
  } else {
622
1112
  server.close(() => reject(new Error("could not determine an ephemeral port")));
623
1113
  }
@@ -752,10 +1242,10 @@ var ServiceSupervisor = class _ServiceSupervisor {
752
1242
  this.opts.proxyTable?.delete(t.runtime.hostname);
753
1243
  const child = t.child;
754
1244
  if (!child || t.status !== "running") return false;
755
- return new Promise((resolve5) => {
1245
+ return new Promise((resolve7) => {
756
1246
  const done = () => {
757
1247
  t.status = "exited";
758
- resolve5(true);
1248
+ resolve7(true);
759
1249
  };
760
1250
  child.once("exit", done);
761
1251
  child.kill("SIGTERM");
@@ -1420,6 +1910,6 @@ var worktreeProvider = defineDriver({
1420
1910
  ]
1421
1911
  });
1422
1912
 
1423
- export { CONFIG_FILENAME, ConfigError, DEFAULT_PROXY_PORT, ENV_VARS, FileVerdictMemoStore, HookError, InMemoryVerdictMemoStore, ProxyTable, SESSIONS_BUCKETS_ROOT, SESSIONS_FILE_PATH, ServiceSupervisor, VERDICT_MEMO_PATH, WORKTREES_TURBO_CACHE_DIR_ENV, WORKTREE_TRASH_DIRNAME, WorktreeNotRemovableError, allocatePort, classify, computeBaseDivergence, computeLiveness, computeProvenance, computeTreeState, computeWorktreeStatus, detectDefaultBranch, disposeSupervisor, ephemeralPort, execArgv, execGit, execShell, expandGlob, getScript, getSupervisor, globToRegExp, hookEnv, isPortFree, listGitWorktrees, listScripts, listServices, listWorktreeStatuses, loadConfigFromBase, normalizeHook, parseConfig, peerEnv, peerEnvKey, readSessionsRegistry, readWorktreeMarker, reconcileIntegration, removeWorktreeFast, repoLabel, resolveSupervisor, resolveWorktreesTurboCacheDir, runSetup, runTeardown, serviceEnv, serviceEnvToken, serviceHostname, serviceUrl, sessionInWorktree, sharedProxyTable, slugify, stripPort, sweepWorktreeTrash, worktreeProvider, writeWorktreeMarker };
1424
- //# sourceMappingURL=chunk-EVHLUKC7.mjs.map
1425
- //# sourceMappingURL=chunk-EVHLUKC7.mjs.map
1913
+ export { CONFIG_FILENAME, ConfigError, DEFAULT_PROVISION_CONCURRENCY, DEFAULT_PROVISION_LIMITS, DEFAULT_PROXY_PORT, ENV_VARS, FileVerdictMemoStore, GlobTraversalError, HookError, InMemoryVerdictMemoStore, LOCAL_WORKTREE_CONFIG_REL, PROVISION_CONCURRENCY_ENV, ProvisionCancelledError, ProvisionScheduler, ProxyTable, SESSIONS_BUCKETS_ROOT, SESSIONS_FILE_PATH, ServiceSupervisor, VERDICT_MEMO_PATH, WORKTREES_TURBO_CACHE_DIR_ENV, WORKTREE_TRASH_DIRNAME, WorktreeNotRemovableError, allocatePort, applySlugPlaceholder, classify, cloneEntries, computeBaseDivergence, computeLiveness, computeProvenance, computeTreeState, computeWorktreeStatus, currentProvisionContext, detectDefaultBranch, disposeSupervisor, ephemeralPort, execArgv, execGit, execShell, expandCloneGlob, expandGlob, getScript, getSupervisor, globToRegExp, hookEnv, isPortFree, listGitWorktrees, listScripts, listServices, listWorktreeStatuses, loadConfigFromBase, loadLocalWorktreeConfig, normalizeHook, parseConfig, parseLocalWorktreeConfig, parseProvisionLimits, peerEnv, peerEnvKey, provisionScheduler, readSessionsRegistry, readWorktreeMarker, reconcileIntegration, removeWorktreeFast, repoLabel, resolveLocalWriteFiles, resolveSupervisor, resolveWorktreesTurboCacheDir, runSetup, runTeardown, runWithProvisionContext, serviceEnv, serviceEnvToken, serviceHostname, serviceUrl, sessionInWorktree, sharedProxyTable, slugify, stripPort, sweepWorktreeTrash, worktreeProvider, writeWorktreeMarker };
1914
+ //# sourceMappingURL=chunk-IEW2O3PF.mjs.map
1915
+ //# sourceMappingURL=chunk-IEW2O3PF.mjs.map