cyber-agentkit 0.0.0-stage → 1.0.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.
@@ -0,0 +1,1456 @@
1
+ import { basename, dirname, join, resolve, sep } from "node:path";
2
+ import { copyFile, lstat, mkdir, readFile, readdir, readlink, realpath, unlink, writeFile } from "node:fs/promises";
3
+ import { execFile } from "node:child_process";
4
+ import { randomUUID } from "node:crypto";
5
+ //#region src/worktrees/dirty.ts
6
+ /**
7
+ * Whether a checkout has uncommitted changes, tracked or untracked, or `undefined` when git could not
8
+ * say. `undefined` is not `false`: a caller deciding whether a worktree is safe to reuse must not read
9
+ * an unanswered question as a clean tree.
10
+ *
11
+ * `--untracked-files=all` is explicit because a repo's `status.showUntrackedFiles=no` would otherwise
12
+ * hide untracked work.
13
+ */
14
+ async function readDirty(worktreeRoot, options) {
15
+ const args = [
16
+ "-C",
17
+ worktreeRoot,
18
+ "status",
19
+ "--porcelain",
20
+ "--untracked-files=all"
21
+ ];
22
+ if (options.ignore?.length) args.push("--", ".", ...options.ignore.map((path) => `:(exclude)${path}`));
23
+ const out = await options.exec("git", args);
24
+ return out === null ? void 0 : out.length > 0;
25
+ }
26
+ //#endregion
27
+ //#region src/worktrees/exec.ts
28
+ /** The default `Exec`: `execFile` with stderr captured, never inherited, because routine failures
29
+ * (no `origin/HEAD`, a branch with no upstream) would otherwise spam the caller's terminal. */
30
+ const nodeExec = (cmd, args) => new Promise((resolve) => {
31
+ execFile(cmd, [...args], {
32
+ encoding: "utf8",
33
+ maxBuffer: 67108864
34
+ }, (error, stdout) => {
35
+ resolve(error ? null : stdout.trim());
36
+ });
37
+ });
38
+ //#endregion
39
+ //#region src/worktrees/landed.ts
40
+ /**
41
+ * The ref "landed" is measured against: `origin/HEAD` first, because landed means landed upstream,
42
+ * then the branch checked out in the primary checkout. Undefined when neither answers.
43
+ */
44
+ async function defaultBranchRef(exec, primaryRoot, primaryBranch) {
45
+ return await exec("git", [
46
+ "-C",
47
+ primaryRoot,
48
+ "symbolic-ref",
49
+ "--short",
50
+ "refs/remotes/origin/HEAD"
51
+ ]) || primaryBranch;
52
+ }
53
+ async function readLandedContext(exec, primaryRoot, target, forge) {
54
+ const [merged, goneUpstream] = await Promise.all([readMergedBranches(exec, primaryRoot, target), readGoneUpstreamBranches(exec, primaryRoot)]);
55
+ return {
56
+ exec,
57
+ primaryRoot,
58
+ target,
59
+ merged,
60
+ goneUpstream,
61
+ forge
62
+ };
63
+ }
64
+ /**
65
+ * Runs the signals for one branch in cost order and stops at the first positive. Only `ancestor` may
66
+ * seed a `false`; no later signal can turn a `true` into a `false`.
67
+ */
68
+ async function resolveLanded(branch, context) {
69
+ if (!branch) return {};
70
+ if (context.merged?.has(branch)) return {
71
+ merged: true,
72
+ signal: "ancestor"
73
+ };
74
+ const seed = context.merged ? { merged: false } : {};
75
+ if (context.goneUpstream?.has(branch)) return {
76
+ merged: true,
77
+ signal: "upstream-gone"
78
+ };
79
+ if (context.target && await isSquashApplied(context.exec, context.primaryRoot, context.target, branch)) return {
80
+ merged: true,
81
+ signal: "squash-patch"
82
+ };
83
+ if (await context.forge?.(branch) === true) return {
84
+ merged: true,
85
+ signal: "forge"
86
+ };
87
+ return seed;
88
+ }
89
+ async function readMergedBranches(exec, primaryRoot, target) {
90
+ if (!target) return void 0;
91
+ const out = await exec("git", [
92
+ "-C",
93
+ primaryRoot,
94
+ "branch",
95
+ "--format=%(refname:short)",
96
+ "--merged",
97
+ target
98
+ ]);
99
+ if (out === null) return void 0;
100
+ return new Set(out.split("\n").map((line) => line.trim()).filter(Boolean));
101
+ }
102
+ async function readGoneUpstreamBranches(exec, primaryRoot) {
103
+ const out = await exec("git", [
104
+ "-C",
105
+ primaryRoot,
106
+ "for-each-ref",
107
+ "--format=%(refname:short) %(upstream:track)",
108
+ "refs/heads/"
109
+ ]);
110
+ if (out === null) return void 0;
111
+ const gone = /* @__PURE__ */ new Set();
112
+ for (const line of out.split("\n")) {
113
+ const tab = line.indexOf(" ");
114
+ if (tab !== -1 && line.slice(tab + 1).trim() === "[gone]") gone.add(line.slice(0, tab));
115
+ }
116
+ return gone;
117
+ }
118
+ /**
119
+ * Whether the branch, collapsed to one synthetic commit (`commit-tree <branch>^{tree} -p <merge-base>`),
120
+ * is already applied on `target` by patch id (`git cherry`). Writes one unreferenced commit object that
121
+ * ordinary gc collects. The identity is inline because `commit-tree` refuses on a machine with no git
122
+ * identity configured, such as a CI runner.
123
+ */
124
+ async function isSquashApplied(exec, primaryRoot, target, branch) {
125
+ const base = await exec("git", [
126
+ "-C",
127
+ primaryRoot,
128
+ "merge-base",
129
+ target,
130
+ branch
131
+ ]);
132
+ if (!base) return false;
133
+ const synthetic = await exec("git", [
134
+ "-c",
135
+ "user.name=agent-harness",
136
+ "-c",
137
+ "user.email=probe@agent-harness.invalid",
138
+ "-C",
139
+ primaryRoot,
140
+ "commit-tree",
141
+ `${branch}^{tree}`,
142
+ "-p",
143
+ base,
144
+ "-m",
145
+ "agent-harness squash probe"
146
+ ]);
147
+ if (!synthetic) return false;
148
+ return (await exec("git", [
149
+ "-C",
150
+ primaryRoot,
151
+ "cherry",
152
+ target,
153
+ synthetic
154
+ ]))?.split("\n").some((line) => line.startsWith("-")) ?? false;
155
+ }
156
+ /**
157
+ * A `ForgeMergedProbe` backed by the GitHub CLI: `gh pr list --head <branch> --state merged`. Every
158
+ * way it can fail (no `gh`, no auth, no network, a non-GitHub origin) resolves `undefined`.
159
+ */
160
+ function ghForgeMergedProbe(exec, primaryRoot) {
161
+ return async (branch) => {
162
+ const url = await exec("git", [
163
+ "-C",
164
+ primaryRoot,
165
+ "remote",
166
+ "get-url",
167
+ "origin"
168
+ ]);
169
+ const slug = url ? /[:/]([^/:]+\/[^/]+?)(?:\.git)?$/.exec(url.trim())?.[1] : void 0;
170
+ if (!slug) return void 0;
171
+ const out = await exec("gh", [
172
+ "pr",
173
+ "list",
174
+ "--repo",
175
+ slug,
176
+ "--head",
177
+ branch,
178
+ "--state",
179
+ "merged",
180
+ "--json",
181
+ "number",
182
+ "--limit",
183
+ "1"
184
+ ]);
185
+ if (out === null) return void 0;
186
+ try {
187
+ const prs = JSON.parse(out);
188
+ return Array.isArray(prs) ? prs.length > 0 : void 0;
189
+ } catch {
190
+ return;
191
+ }
192
+ };
193
+ }
194
+ //#endregion
195
+ //#region src/worktrees/list-worktrees.ts
196
+ /**
197
+ * The primary checkout's root, from the primary checkout or any linked worktree:
198
+ * `--git-common-dir` always points at the primary's `.git`.
199
+ */
200
+ async function primaryRoot(options = {}) {
201
+ const commonDir = await (options.exec ?? nodeExec)("git", [
202
+ "-C",
203
+ options.from ?? ".",
204
+ "rev-parse",
205
+ "--path-format=absolute",
206
+ "--git-common-dir"
207
+ ]);
208
+ if (!commonDir) throw new Error(`not inside a git repository: ${resolve(options.from ?? ".")}`);
209
+ return dirname(commonDir);
210
+ }
211
+ /**
212
+ * The single normalization for every path matched against another, so a symlinked repo or macOS's
213
+ * `/tmp` → `/private/tmp` still matches. Falls back to `resolve` for a path not on disk.
214
+ */
215
+ async function normalizeWorktreePath(path) {
216
+ try {
217
+ return await realpath(path);
218
+ } catch {
219
+ return resolve(path);
220
+ }
221
+ }
222
+ /** Every worktree of the repo, from `git worktree list --porcelain`, with landed and dirty facts. */
223
+ async function listWorktrees(options) {
224
+ const exec = options.exec ?? nodeExec;
225
+ const out = await exec("git", [
226
+ "-C",
227
+ options.primaryRoot,
228
+ "worktree",
229
+ "list",
230
+ "--porcelain"
231
+ ]);
232
+ if (!out) return [];
233
+ const primary = await normalizeWorktreePath(options.primaryRoot);
234
+ const entries = await Promise.all(out.split("\n\n").map((record) => record.trim()).filter((record) => record.startsWith("worktree ")).map((record) => parseRecord(record, primary)));
235
+ const target = await defaultBranchRef(exec, options.primaryRoot, entries.find((entry) => !entry.linked)?.branch);
236
+ const context = await readLandedContext(exec, options.primaryRoot, target, options.forge);
237
+ for (const entry of entries) {
238
+ const landed = await resolveLanded(entry.branch, context);
239
+ if (landed.merged !== void 0) entry.merged = landed.merged;
240
+ if (landed.signal !== void 0) entry.mergedSignal = landed.signal;
241
+ if (!entry.prunable) {
242
+ const dirty = await readDirty(entry.root, {
243
+ exec,
244
+ ignore: options.ignore
245
+ });
246
+ if (dirty !== void 0) entry.dirty = dirty;
247
+ }
248
+ }
249
+ return entries;
250
+ }
251
+ async function parseRecord(record, primary) {
252
+ const lines = record.split("\n");
253
+ const root = await normalizeWorktreePath(unquote(lines[0].slice(9)));
254
+ const value = (key) => {
255
+ const line = lines.find((line) => line === key || line.startsWith(`${key} `));
256
+ return line === void 0 ? void 0 : unquote(line.slice(key.length + 1));
257
+ };
258
+ const entry = {
259
+ root,
260
+ detached: lines.includes("detached"),
261
+ linked: root !== primary,
262
+ prunable: value("prunable") !== void 0
263
+ };
264
+ const head = value("HEAD");
265
+ if (head) entry.head = head;
266
+ const branch = value("branch")?.replace(/^refs\/heads\//, "");
267
+ if (branch) entry.branch = branch;
268
+ const locked = value("locked");
269
+ if (locked !== void 0) entry.locked = locked;
270
+ return entry;
271
+ }
272
+ const escapes = {
273
+ a: "\x07",
274
+ b: "\b",
275
+ f: "\f",
276
+ n: "\n",
277
+ r: "\r",
278
+ t: " ",
279
+ v: "\v"
280
+ };
281
+ /**
282
+ * Undo git's C-style quoting, which porcelain applies to a path or lock reason holding a quote,
283
+ * backslash, or control character: `"a\"b\n"` → `a"b` plus a newline. Octal escapes are bytes, so a
284
+ * quoted UTF-8 name decodes back to its characters.
285
+ */
286
+ function unquote(text) {
287
+ if (text.length < 2 || !text.startsWith("\"") || !text.endsWith("\"")) return text;
288
+ const bytes = [];
289
+ const body = text.slice(1, -1);
290
+ for (let i = 0; i < body.length; i++) {
291
+ const char = body[i];
292
+ if (char !== "\\") {
293
+ bytes.push(...Buffer.from(char));
294
+ continue;
295
+ }
296
+ const next = body[++i] ?? "";
297
+ const octal = /^[0-7]{3}/.exec(body.slice(i));
298
+ if (octal) {
299
+ bytes.push(Number.parseInt(octal[0], 8));
300
+ i += 2;
301
+ } else bytes.push(...Buffer.from(escapes[next] ?? next));
302
+ }
303
+ return Buffer.from(bytes).toString("utf8");
304
+ }
305
+ //#endregion
306
+ //#region src/worktrees/naming.ts
307
+ /**
308
+ * Where the library puts a repo's worktrees: `<parent>/<repo>.worktrees`, a sibling of the primary
309
+ * checkout (E-MUX-2, E-LEG-1). Every worktree the library creates is a slot in this folder.
310
+ */
311
+ function worktreesDir(primaryRoot) {
312
+ return join(dirname(primaryRoot), `${basename(primaryRoot)}.worktrees`);
313
+ }
314
+ /**
315
+ * The path of slot `n`: `<parent>/<repo>.worktrees/<repo>-<n>`. The library assigns `n` and the
316
+ * caller cannot, so the name stays meaningless and true for the worktree's whole life; what it is
317
+ * for lives in its branch and lease holder, which change on reuse (decision 2026-10-05, E-TH-7).
318
+ */
319
+ function slotPath(primaryRoot, n) {
320
+ return join(worktreesDir(primaryRoot), `${basename(primaryRoot)}-${n}`);
321
+ }
322
+ /**
323
+ * The slot number of a worktree root, or `undefined` when the root is not one of the library's slots.
324
+ * Both paths are normalized (`normalizeWorktreePath`). `n` is a positive integer with no leading
325
+ * zero, so `repo-01` is not slot 1.
326
+ */
327
+ function slotNumber(primaryRoot, worktreeRoot) {
328
+ if (dirname(worktreeRoot) !== worktreesDir(primaryRoot)) return void 0;
329
+ const prefix = `${basename(primaryRoot)}-`;
330
+ const name = basename(worktreeRoot);
331
+ if (!name.startsWith(prefix)) return void 0;
332
+ const digits = name.slice(prefix.length);
333
+ return /^[1-9]\d*$/.test(digits) ? Number(digits) : void 0;
334
+ }
335
+ //#endregion
336
+ //#region src/worktrees/owner.ts
337
+ /** The `library` value a lease reason carries. */
338
+ const LEASE_LIBRARY = "cyber-agentkit";
339
+ /** The lease a lock reason records, or `undefined` when the lock is not this library's. */
340
+ function parseLeaseReason(reason) {
341
+ if (!reason) return void 0;
342
+ let value;
343
+ try {
344
+ value = JSON.parse(reason);
345
+ } catch {
346
+ return;
347
+ }
348
+ if (typeof value !== "object" || value === null) return void 0;
349
+ const { library, leaseId, holder } = value;
350
+ if (library !== "cyber-agentkit" || typeof leaseId !== "string" || typeof holder !== "string") return void 0;
351
+ return {
352
+ library,
353
+ leaseId,
354
+ holder
355
+ };
356
+ }
357
+ /** Classify a worktree's owner from its lock reason and path. Pure: reads nothing from disk. */
358
+ function classifyOwner(entry, options) {
359
+ if (parseLeaseReason(entry.locked)) return {
360
+ owner: "self",
361
+ research: ["E-GIT-L1", "E-GIT-L2"]
362
+ };
363
+ if (entry.locked?.startsWith("claude session ")) return {
364
+ owner: "claude-code",
365
+ research: ["E-CC-W4", "E-PROC-CC5"]
366
+ };
367
+ if (!entry.linked) return {
368
+ owner: "user",
369
+ research: []
370
+ };
371
+ if (entry.locked === void 0 && slotNumber(options.primaryRoot, entry.root) !== void 0) return {
372
+ owner: "self",
373
+ research: []
374
+ };
375
+ if (isInside(entry.root, `${options.primaryRoot}${sep}.claude${sep}worktrees`)) return {
376
+ owner: "claude-code",
377
+ research: ["E-CC-W1"]
378
+ };
379
+ if (options.codexHome && isInside(entry.root, `${options.codexHome}${sep}worktrees`)) return {
380
+ owner: "codex",
381
+ research: ["E-CODEX-W1"]
382
+ };
383
+ return {
384
+ owner: "unknown",
385
+ research: []
386
+ };
387
+ }
388
+ function isInside(path, dir) {
389
+ return path.startsWith(`${dir}${sep}`);
390
+ }
391
+ //#endregion
392
+ //#region src/worktrees/lease.ts
393
+ /** The default `LeaseFs`, on `node:fs/promises`. */
394
+ const nodeLeaseFs = {
395
+ async createExclusive(path, text) {
396
+ try {
397
+ await writeFile(path, text, { flag: "wx" });
398
+ return true;
399
+ } catch (error) {
400
+ if (error.code === "EEXIST") return false;
401
+ throw error;
402
+ }
403
+ },
404
+ async readText(path) {
405
+ try {
406
+ return await readFile(path, "utf8");
407
+ } catch (error) {
408
+ if (error.code === "ENOENT") return void 0;
409
+ throw error;
410
+ }
411
+ },
412
+ async remove(path) {
413
+ try {
414
+ await unlink(path);
415
+ } catch (error) {
416
+ if (error.code !== "ENOENT") throw error;
417
+ }
418
+ }
419
+ };
420
+ /**
421
+ * `$GIT_COMMON_DIR/worktrees/<id>/locked` for a linked worktree, or `undefined` when git cannot say.
422
+ * The `<id>` is git's admin folder name, which need not match the basename, so it is read from
423
+ * `--git-dir` rather than built. When the checkout is gone, `commonDir` finds the admin folder by the
424
+ * `gitdir` pointer it keeps, so a lease on a vanished worktree can still be cleared.
425
+ */
426
+ async function leaseFile(worktreeRoot, exec = nodeExec, commonDir) {
427
+ const gitDir = await exec("git", [
428
+ "-C",
429
+ worktreeRoot,
430
+ "rev-parse",
431
+ "--path-format=absolute",
432
+ "--git-dir"
433
+ ]);
434
+ if (gitDir) return join(gitDir, "locked");
435
+ const admin = commonDir && await adminDirOf(worktreeRoot, commonDir);
436
+ return admin ? join(admin, "locked") : void 0;
437
+ }
438
+ /** The `worktrees/<id>` folder whose `gitdir` file points at `<worktreeRoot>/.git`. */
439
+ async function adminDirOf(worktreeRoot, commonDir) {
440
+ const dir = join(commonDir, "worktrees");
441
+ const target = await dotGitOf(worktreeRoot);
442
+ let ids;
443
+ try {
444
+ ids = await readdir(dir);
445
+ } catch {
446
+ return;
447
+ }
448
+ for (const id of ids) {
449
+ let pointer;
450
+ try {
451
+ pointer = (await readFile(join(dir, id, "gitdir"), "utf8")).trim();
452
+ } catch {
453
+ continue;
454
+ }
455
+ if (await dotGitOf(dirname(pointer)) === target) return join(dir, id);
456
+ }
457
+ }
458
+ /** `<root>/.git` with the root's existing ancestors resolved, so a vanished root still compares. */
459
+ async function dotGitOf(root) {
460
+ return join(await normalizeWorktreePath(dirname(root)), basename(root), ".git");
461
+ }
462
+ /**
463
+ * Claim a worktree: exclusive-create its `locked` file with the lease as a one-line JSON reason, which
464
+ * git then honours as a lock (E-GIT-L1, E-GIT-L2, E-GIT-L3), and read it back. `git worktree lock`
465
+ * checks then truncates, so it can overwrite a lease created inside its window (E-GIT-L5); the read
466
+ * back catches that. `undefined` when the worktree is already locked or the claim was lost.
467
+ */
468
+ async function claimLease(worktreeRoot, holder, options = {}) {
469
+ const exec = options.exec ?? nodeExec;
470
+ const fs = options.fs ?? nodeLeaseFs;
471
+ const path = await leaseFile(worktreeRoot, exec);
472
+ if (!path) return void 0;
473
+ const commonDir = await exec("git", [
474
+ "-C",
475
+ worktreeRoot,
476
+ "rev-parse",
477
+ "--path-format=absolute",
478
+ "--git-common-dir"
479
+ ]);
480
+ const lease = {
481
+ worktree: worktreeRoot,
482
+ leaseId: options.leaseId ?? randomUUID(),
483
+ holder,
484
+ commonDir: commonDir ?? void 0
485
+ };
486
+ const reason = {
487
+ library: LEASE_LIBRARY,
488
+ leaseId: lease.leaseId,
489
+ holder
490
+ };
491
+ if (!await fs.createExclusive(path, JSON.stringify(reason))) return void 0;
492
+ return await holdsLease(lease, {
493
+ exec,
494
+ fs
495
+ }) ? lease : void 0;
496
+ }
497
+ /** Whether the worktree's `locked` file still records this lease. Checked again before a destructive
498
+ * step, since a manual `git worktree unlock` or a racing `lock` can take it away (E-GIT-L4, E-GIT-L5). */
499
+ async function holdsLease(lease, options = {}) {
500
+ const path = await leaseFile(lease.worktree, options.exec ?? nodeExec, lease.commonDir);
501
+ if (!path) return false;
502
+ return parseLeaseReason(await (options.fs ?? nodeLeaseFs).readText(path))?.leaseId === lease.leaseId;
503
+ }
504
+ /**
505
+ * Give a lease back by deleting the `locked` file, only when it still records this `leaseId`. It does
506
+ * not recycle: an occupant such as a judge may still be working there, so the next `acquire` recycles
507
+ * after probing.
508
+ */
509
+ async function release(lease, options = {}) {
510
+ const exec = options.exec ?? nodeExec;
511
+ const fs = options.fs ?? nodeLeaseFs;
512
+ const path = await leaseFile(lease.worktree, exec, lease.commonDir);
513
+ if (!path || parseLeaseReason(await fs.readText(path))?.leaseId !== lease.leaseId) return {
514
+ released: false,
515
+ reason: "lost"
516
+ };
517
+ await fs.remove(path);
518
+ return { released: true };
519
+ }
520
+ //#endregion
521
+ //#region src/worktrees/process-source.ts
522
+ /**
523
+ * Reads processes from a Linux `/proc` (or a mounted copy at `procRoot`). On any other platform the
524
+ * default root returns `null`: macOS would need `proc_pidinfo` (E-PROC-OS2), and Windows has no
525
+ * documented way to read another process's cwd (E-PROC-OS3).
526
+ */
527
+ function procfsProcessSource(procRoot = "/proc") {
528
+ return async () => {
529
+ if (procRoot === "/proc" && process.platform !== "linux") return null;
530
+ let names;
531
+ try {
532
+ names = await readdir(procRoot);
533
+ } catch {
534
+ return null;
535
+ }
536
+ return (await Promise.all(names.filter((name) => /^\d+$/.test(name)).map((name) => readProcess(join(procRoot, name), Number(name))))).filter((info) => info !== void 0);
537
+ };
538
+ }
539
+ async function readProcess(dir, pid) {
540
+ const [stat, cmdline, exe, cwd, environ] = await Promise.all([
541
+ readText(join(dir, "stat")),
542
+ readText(join(dir, "cmdline")),
543
+ readLink(join(dir, "exe")),
544
+ readLink(join(dir, "cwd")),
545
+ readText(join(dir, "environ"))
546
+ ]);
547
+ if (stat === void 0) return void 0;
548
+ const info = {
549
+ pid,
550
+ argv: cmdline ? splitNul(cmdline) : []
551
+ };
552
+ const ppid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
553
+ if (Number.isInteger(ppid)) info.ppid = ppid;
554
+ if (exe !== void 0) info.exe = exe;
555
+ if (cwd !== void 0) info.cwd = cwd;
556
+ if (environ !== void 0) {
557
+ const env = {};
558
+ for (const entry of splitNul(environ)) {
559
+ const eq = entry.indexOf("=");
560
+ if (eq > 0) env[entry.slice(0, eq)] = entry.slice(eq + 1);
561
+ }
562
+ info.env = env;
563
+ }
564
+ return info;
565
+ }
566
+ const splitNul = (text) => text.split("\0").filter((part) => part.length > 0);
567
+ async function readText(path) {
568
+ try {
569
+ return await readFile(path, "utf8");
570
+ } catch {
571
+ return;
572
+ }
573
+ }
574
+ /** The kernel appends ` (deleted)` to a link whose target is gone, as after an in-place
575
+ * auto-update of the Copilot CLI (E-PROC-COP1). */
576
+ async function readLink(path) {
577
+ try {
578
+ return (await readlink(path)).replace(/ \(deleted\)$/, "");
579
+ } catch {
580
+ return;
581
+ }
582
+ }
583
+ //#endregion
584
+ //#region src/worktrees/session-signatures.ts
585
+ const base = (path) => path?.slice(path.lastIndexOf("/") + 1);
586
+ const exeIs = (info, name) => base(info.exe) === name;
587
+ const exeMatches = (info, pattern) => info.exe !== void 0 && pattern.test(info.exe);
588
+ const isNode = (info) => exeIs(info, "node") || base(info.argv[0]) === "node";
589
+ const argMatches = (info, pattern) => info.argv.some((arg) => pattern.test(arg));
590
+ const sessionSignatures = [
591
+ {
592
+ harness: "claude-code",
593
+ research: [
594
+ "E-PROC-CC1",
595
+ "E-PROC-CC2",
596
+ "E-PROC-CC3"
597
+ ],
598
+ isSession: (info) => exeMatches(info, /\/claude\/versions\/[^/]+$/),
599
+ link: {
600
+ pid: "CLAUDE_PID",
601
+ id: "CLAUDE_CODE_SESSION_ID"
602
+ }
603
+ },
604
+ {
605
+ harness: "codex",
606
+ research: ["E-PROC-CX1", "E-PROC-CX2"],
607
+ isSession: (info) => exeIs(info, "codex") && !isCodexHelper(info),
608
+ helper: (info) => isCodexHelper(info) ? "session" : void 0,
609
+ link: { id: "CODEX_SESSION_ID" }
610
+ },
611
+ {
612
+ harness: "copilot-cli",
613
+ research: ["E-PROC-COP1", "E-PROC-COP2"],
614
+ isSession: (info) => exeIs(info, "copilot"),
615
+ link: { id: "COPILOT_AGENT_SESSION_ID" }
616
+ },
617
+ {
618
+ harness: "cursor",
619
+ research: [
620
+ "E-PROC-CUR1",
621
+ "E-PROC-CUR3",
622
+ "E-PROC-CUR4"
623
+ ],
624
+ isSession: (info) => isCursorAgent(info) && info.env?.AGENT_CLI_SOCKET_PATH === void 0,
625
+ helper: (info) => isCursorAgent(info) && info.env?.AGENT_CLI_SOCKET_PATH !== void 0 ? "session" : void 0,
626
+ link: { id: "CURSOR_CONVERSATION_ID" }
627
+ },
628
+ {
629
+ harness: "opencode",
630
+ research: ["E-PROC-OC1"],
631
+ isSession: (info) => exeIs(info, "opencode"),
632
+ link: { pid: "OPENCODE_PID" }
633
+ },
634
+ {
635
+ harness: "kilo",
636
+ research: ["E-PROC-KILO1"],
637
+ isSession: (info) => exeMatches(info, /\/cli-linux-x64\/bin\/kilo$/) || isNode(info) && argMatches(info, /\/@kilocode\/cli\/bin\/kilo$/),
638
+ link: {
639
+ pid: "KILO_PID",
640
+ id: "KILO_RUN_ID"
641
+ }
642
+ },
643
+ {
644
+ harness: "qwen-code",
645
+ research: ["E-PROC-QWEN1"],
646
+ isSession: (info) => isNode(info) && argMatches(info, /\/qwen-code\/cli-entry\.js$/),
647
+ link: { id: "QWEN_CODE_SESSION_ID" }
648
+ },
649
+ {
650
+ harness: "crush",
651
+ research: ["E-PROC-CRUSH1"],
652
+ isSession: (info) => exeMatches(info, /\/crush\/bin\/crush$/) || isNode(info) && argMatches(info, /\/@charmland\/crush(\/|$)/)
653
+ },
654
+ {
655
+ harness: "gemini-cli",
656
+ research: ["E-PROC-GEM1"],
657
+ isSession: (info) => isNode(info) && argMatches(info, /\/gemini-cli\/bundle\/gemini\.js$/)
658
+ },
659
+ {
660
+ harness: "goose",
661
+ research: ["E-PROC-GOOSE1"],
662
+ isSession: (info) => exeIs(info, "goose") && (info.argv[1] === "run" || info.argv[1] === "session"),
663
+ link: { id: "AGENT_SESSION_ID" }
664
+ },
665
+ {
666
+ harness: "openhands",
667
+ research: ["E-PROC-OH1"],
668
+ isSession: (info) => /^python[\d.]*$/.test(base(info.exe) ?? "") && argMatches(info, /\/openhands$/),
669
+ helper: (info) => exeIs(info, "tmux") && (info.argv.includes("-Lopenhands") || /-L openhands( |$)/.test(info.argv.join(" "))) ? "shared" : void 0
670
+ },
671
+ {
672
+ harness: "cline",
673
+ research: ["E-PROC-CLINE1"],
674
+ isSession: (info) => isClineProcess(info) && !info.argv.includes("--cline-hub-daemon"),
675
+ helper: (info) => isClineProcess(info) && info.argv.includes("--cline-hub-daemon") ? "shared" : void 0
676
+ },
677
+ {
678
+ harness: "augment",
679
+ research: ["E-PROC-AUG1"],
680
+ isSession: (info) => isNode(info) && argMatches(info, /\/@augmentcode\/auggie\/augment\.mjs$/)
681
+ },
682
+ {
683
+ harness: "antigravity-cli",
684
+ research: ["E-PROC-AGY1"],
685
+ isSession: (info) => exeIs(info, "agy"),
686
+ link: { id: "ANTIGRAVITY_CONVERSATION_ID" }
687
+ }
688
+ ];
689
+ function isCodexHelper(info) {
690
+ return base(info.exe) === "codex-code-mode-host" || info.argv.some((arg) => base(arg) === "codex-code-mode-host");
691
+ }
692
+ function isCursorAgent(info) {
693
+ return isNode(info) && argMatches(info, /(^|\/)cursor-agent$/);
694
+ }
695
+ function isClineProcess(info) {
696
+ return exeMatches(info, /\/cli-linux-x64\/bin\/cline$/) || isNode(info) && argMatches(info, /\/cline\/bin\/cline$/);
697
+ }
698
+ //#endregion
699
+ //#region src/worktrees/occupancy.ts
700
+ /**
701
+ * Snapshots processes once and classifies each against the session signature table. Read-only: the
702
+ * probe never signals or kills a process. A harness missing from the table is invisible to it.
703
+ */
704
+ async function probeProcesses(options = {}) {
705
+ const processes = await (options.source ?? procfsProcessSource())();
706
+ if (processes === null) return {
707
+ verified: false,
708
+ occupancy: (_root, occupancyOptions) => ({
709
+ busy: occupancyOptions?.strict === true,
710
+ verified: false,
711
+ occupants: [],
712
+ lingering: [],
713
+ unlinked: []
714
+ })
715
+ };
716
+ return classify(processes);
717
+ }
718
+ /**
719
+ * The live agent sessions inside a worktree, or `undefined` when the probe could not read this
720
+ * platform's processes.
721
+ */
722
+ async function occupants(worktreeRoot, options = {}) {
723
+ const probe = await probeProcesses(options);
724
+ if (!probe.verified) return void 0;
725
+ const nested = options.nested && await Promise.all(options.nested.map(normalizeWorktreePath));
726
+ return probe.occupancy(await normalizeWorktreePath(worktreeRoot), { nested }).occupants;
727
+ }
728
+ function classify(processes) {
729
+ const byPid = new Map(processes.map((info) => [info.pid, info]));
730
+ const sessionOf = /* @__PURE__ */ new Map();
731
+ const helperOf = /* @__PURE__ */ new Map();
732
+ for (const info of processes) for (const signature of sessionSignatures) {
733
+ const scope = signature.helper?.(info);
734
+ if (scope) {
735
+ helperOf.set(info.pid, {
736
+ signature,
737
+ scope
738
+ });
739
+ break;
740
+ }
741
+ if (signature.isSession(info)) {
742
+ sessionOf.set(info.pid, signature);
743
+ break;
744
+ }
745
+ }
746
+ const parentOf = (info) => info.ppid === void 0 || info.ppid === info.pid ? void 0 : byPid.get(info.ppid);
747
+ const groupOf = /* @__PURE__ */ new Map();
748
+ const groupRoot = (info) => {
749
+ const parent = parentOf(info);
750
+ return parent && sessionOf.get(parent.pid) === sessionOf.get(info.pid) ? groupRoot(parent) : info;
751
+ };
752
+ for (const [pid, signature] of sessionOf) {
753
+ const root = groupRoot(byPid.get(pid));
754
+ let group = groupOf.get(root.pid);
755
+ if (!group) {
756
+ group = {
757
+ signature,
758
+ members: [],
759
+ ids: /* @__PURE__ */ new Set()
760
+ };
761
+ groupOf.set(root.pid, group);
762
+ }
763
+ group.members.push(byPid.get(pid));
764
+ groupOf.set(pid, group);
765
+ }
766
+ const groups = [...new Set(groupOf.values())];
767
+ const nearestSession = (info) => {
768
+ const seen = /* @__PURE__ */ new Set();
769
+ for (let parent = parentOf(info); parent && !seen.has(parent.pid); parent = parentOf(parent)) {
770
+ seen.add(parent.pid);
771
+ const group = groupOf.get(parent.pid);
772
+ if (group) return group;
773
+ }
774
+ };
775
+ for (const info of processes) {
776
+ if (sessionOf.has(info.pid)) continue;
777
+ const group = nearestSession(info);
778
+ const idVar = group?.signature.link?.id;
779
+ const id = idVar && info.env?.[idVar];
780
+ if (group && id) group.ids.add(id);
781
+ }
782
+ const liveGroups = (harness) => groups.filter((group) => group.signature.harness === harness);
783
+ const resolveLink = (signature, env) => {
784
+ const live = liveGroups(signature.harness);
785
+ const pidVar = signature.link?.pid;
786
+ if (pidVar && env[pidVar]) {
787
+ const pid = Number(env[pidVar]);
788
+ return live.some((group) => group.members.some((member) => member.pid === pid)) ? "live" : "dead";
789
+ }
790
+ const idVar = signature.link?.id;
791
+ const id = idVar && env[idVar];
792
+ if (!id) return void 0;
793
+ if (live.some((group) => group.ids.has(id))) return "live";
794
+ return live.every((group) => group.ids.size > 0) ? "dead" : "unknown";
795
+ };
796
+ const leftover = (info) => {
797
+ const helper = helperOf.get(info.pid);
798
+ if (helper?.scope === "shared") {
799
+ if (liveGroups(helper.signature.harness).length > 0) return void 0;
800
+ return {
801
+ pid: info.pid,
802
+ argv: info.argv,
803
+ harness: helper.signature.harness,
804
+ reason: "harness-idle"
805
+ };
806
+ }
807
+ if (nearestSession(info)) return void 0;
808
+ if (helper) return {
809
+ pid: info.pid,
810
+ argv: info.argv,
811
+ harness: helper.signature.harness,
812
+ reason: "helper-orphaned"
813
+ };
814
+ if (!info.env) return {
815
+ pid: info.pid,
816
+ argv: info.argv,
817
+ reason: "env-unreadable"
818
+ };
819
+ const links = sessionSignatures.map((signature) => ({
820
+ signature,
821
+ link: resolveLink(signature, info.env)
822
+ })).filter((entry) => entry.link !== void 0);
823
+ if (links.some((entry) => entry.link === "live")) return void 0;
824
+ if (links.length === 0) return {
825
+ pid: info.pid,
826
+ argv: info.argv,
827
+ reason: "no-link"
828
+ };
829
+ const reason = links.every((entry) => entry.link === "dead") ? "session-gone" : "unresolved-link";
830
+ return {
831
+ pid: info.pid,
832
+ argv: info.argv,
833
+ harness: links[0].signature.harness,
834
+ reason
835
+ };
836
+ };
837
+ return {
838
+ verified: true,
839
+ occupancy(worktreeRoot, options = {}) {
840
+ const within = (cwd, root) => cwd !== void 0 && (cwd === root || cwd.startsWith(root.endsWith(sep) ? root : root + sep));
841
+ const inside = (cwd) => within(cwd, worktreeRoot) && !(options.nested ?? []).some((root) => within(cwd, root));
842
+ const found = [];
843
+ for (const group of groups) {
844
+ if (!group.members.some((member) => inside(member.cwd))) continue;
845
+ const session = {
846
+ harness: group.signature.harness,
847
+ pid: (group.members.find((member) => groupRoot(member) === member) ?? group.members[0]).pid,
848
+ pids: group.members.map((member) => member.pid).sort((a, b) => a - b),
849
+ research: group.signature.research
850
+ };
851
+ if (group.ids.size === 1) session.sessionId = [...group.ids][0];
852
+ found.push(session);
853
+ }
854
+ const lingering = [];
855
+ const unlinked = [];
856
+ for (const info of processes) {
857
+ if (sessionOf.has(info.pid) || !inside(info.cwd)) continue;
858
+ const entry = leftover(info);
859
+ if (!entry) continue;
860
+ const report = {
861
+ ...entry,
862
+ cwd: info.cwd
863
+ };
864
+ if (info.ppid !== void 0) report.ppid = info.ppid;
865
+ (entry.reason === "session-gone" || entry.reason === "helper-orphaned" || entry.reason === "harness-idle" ? lingering : unlinked).push(report);
866
+ }
867
+ return {
868
+ busy: found.length > 0,
869
+ verified: true,
870
+ occupants: found,
871
+ lingering,
872
+ unlinked
873
+ };
874
+ }
875
+ };
876
+ }
877
+ //#endregion
878
+ //#region src/worktrees/acquire.ts
879
+ /** Every worktree of the repo with the reason it cannot be reused, or none when it can. */
880
+ async function explain(options = {}) {
881
+ return (await survey(options)).verdicts;
882
+ }
883
+ async function survey(options) {
884
+ const exec = options.exec ?? nodeExec;
885
+ const primary = await normalizeWorktreePath(options.primaryRoot ?? await primaryRoot({
886
+ from: options.from,
887
+ exec
888
+ }));
889
+ const entries = await listWorktrees({
890
+ primaryRoot: primary,
891
+ exec,
892
+ ignore: options.ignore,
893
+ forge: options.forge
894
+ });
895
+ const base = options.base ?? await defaultBranchRef(exec, primary, entries.find((entry) => !entry.linked)?.branch);
896
+ const codexHome = options.codexHome && await normalizeWorktreePath(options.codexHome);
897
+ let probe = options.probe;
898
+ const verdicts = [];
899
+ for (const entry of entries) {
900
+ const owner = classifyOwner(entry, {
901
+ primaryRoot: primary,
902
+ codexHome
903
+ });
904
+ const verdict = {
905
+ worktree: entry,
906
+ owner,
907
+ verified: true
908
+ };
909
+ verdicts.push(verdict);
910
+ if (!entry.linked) {
911
+ verdict.skip = "primary";
912
+ continue;
913
+ }
914
+ if (owner.owner !== "self") {
915
+ verdict.skip = "foreign";
916
+ continue;
917
+ }
918
+ const lease = parseLeaseReason(entry.locked);
919
+ if (lease) {
920
+ verdict.skip = "leased";
921
+ verdict.holder = lease.holder;
922
+ continue;
923
+ }
924
+ if (entry.prunable) {
925
+ verdict.skip = "prunable";
926
+ continue;
927
+ }
928
+ probe ??= await probeProcesses();
929
+ const nested = entries.map((other) => other.root).filter((root) => root !== entry.root && root.startsWith(entry.root + sep));
930
+ const occupancy = probe.occupancy(entry.root, {
931
+ strict: options.strict,
932
+ nested
933
+ });
934
+ verdict.occupancy = occupancy;
935
+ verdict.verified = occupancy.verified;
936
+ if (occupancy.busy) {
937
+ verdict.skip = occupancy.verified ? "busy" : "unverified";
938
+ continue;
939
+ }
940
+ if (entry.dirty !== false) {
941
+ verdict.skip = "dirty";
942
+ continue;
943
+ }
944
+ if (!await hasLanded(exec, primary, entry, base)) {
945
+ verdict.skip = "unmerged";
946
+ continue;
947
+ }
948
+ if (options.available && !await options.available(entry)) verdict.skip = "rejected";
949
+ }
950
+ return {
951
+ primary,
952
+ exec,
953
+ base,
954
+ entries,
955
+ verdicts
956
+ };
957
+ }
958
+ /** The branch has landed on the default branch, or HEAD (detached after a recycle) is in `base`. */
959
+ async function hasLanded(exec, primary, entry, base) {
960
+ if (entry.merged === true) return true;
961
+ if (!entry.head || !base) return false;
962
+ return await exec("git", [
963
+ "-C",
964
+ primary,
965
+ "merge-base",
966
+ "--is-ancestor",
967
+ entry.head,
968
+ base
969
+ ]) !== null;
970
+ }
971
+ /** The default `WorktreeCreator`: `git worktree add`. */
972
+ function gitWorktreeCreator(exec = nodeExec) {
973
+ return async ({ primaryRoot, path, base, branch }) => {
974
+ const args = [
975
+ "-C",
976
+ primaryRoot,
977
+ "worktree",
978
+ "add",
979
+ "--quiet"
980
+ ];
981
+ if (!branch) args.push("--detach", path, base);
982
+ else if (await branchExists(exec, primaryRoot, branch)) args.push(path, branch);
983
+ else args.push("-b", branch, path, base);
984
+ if (await exec("git", args) === null) throw new Error(`git worktree add failed: ${path}`);
985
+ };
986
+ }
987
+ var AcquireError = class extends Error {
988
+ code;
989
+ verdicts;
990
+ constructor(code, message, verdicts) {
991
+ super(message);
992
+ this.code = code;
993
+ this.verdicts = verdicts;
994
+ this.name = "AcquireError";
995
+ }
996
+ };
997
+ const ATTEMPTS = 3;
998
+ /**
999
+ * Lease a worktree: recycle an idle one of ours when there is one, otherwise create one at the next
1000
+ * free slot if under `max`. Never fetches and never kills a process.
1001
+ */
1002
+ async function acquire(options) {
1003
+ let verdicts = [];
1004
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
1005
+ const found = await survey(options);
1006
+ verdicts = found.verdicts;
1007
+ const { primary, exec, entries } = found;
1008
+ if (!found.base) throw new AcquireError("no-base", "no base ref: pass `base` or set origin/HEAD", verdicts);
1009
+ const base = await exec("git", [
1010
+ "-C",
1011
+ primary,
1012
+ "rev-parse",
1013
+ "--verify",
1014
+ "--quiet",
1015
+ `${found.base}^{commit}`
1016
+ ]);
1017
+ if (!base) throw new AcquireError("no-base", `base does not resolve to a commit: ${found.base}`, verdicts);
1018
+ const idle = verdicts.filter((verdict) => verdict.skip === void 0).sort((a, b) => slotOf(primary, a) - slotOf(primary, b));
1019
+ for (const verdict of idle) {
1020
+ const result = await recycle(verdict, base, options, exec, primary);
1021
+ if (result) return result;
1022
+ }
1023
+ const slots = entries.filter((entry) => entry.linked && slotNumber(primary, entry.root) !== void 0);
1024
+ if (options.max !== void 0 && slots.length >= options.max) throw new AcquireError("pool-full", `all ${options.max} worktrees are in use`, verdicts);
1025
+ const lease = await createSlot(primary, base, options, exec, slots);
1026
+ if (lease) return {
1027
+ ...lease,
1028
+ reused: false,
1029
+ branch: options.branch,
1030
+ verified: true,
1031
+ lingering: []
1032
+ };
1033
+ }
1034
+ throw new AcquireError("contended", "another caller took every worktree this one tried", verdicts);
1035
+ }
1036
+ function slotOf(primary, verdict) {
1037
+ return slotNumber(primary, verdict.worktree.root) ?? Number.POSITIVE_INFINITY;
1038
+ }
1039
+ async function recycle(verdict, base, options, exec, primary) {
1040
+ const entry = verdict.worktree;
1041
+ const store = {
1042
+ exec,
1043
+ fs: options.fs
1044
+ };
1045
+ const lease = await claimLease(entry.root, options.holder, {
1046
+ ...store,
1047
+ leaseId: options.newLeaseId?.()
1048
+ });
1049
+ if (!lease) return void 0;
1050
+ const head = await exec("git", [
1051
+ "-C",
1052
+ entry.root,
1053
+ "rev-parse",
1054
+ "HEAD"
1055
+ ]);
1056
+ const dirty = await readDirty(entry.root, {
1057
+ exec,
1058
+ ignore: options.ignore
1059
+ });
1060
+ if (head !== entry.head || dirty !== false || !await holdsLease(lease, store)) {
1061
+ await release(lease, store);
1062
+ return;
1063
+ }
1064
+ const git = (...args) => exec("git", [
1065
+ "-C",
1066
+ entry.root,
1067
+ ...args
1068
+ ]);
1069
+ const fail = async (message) => {
1070
+ await release(lease, store);
1071
+ throw new Error(`${message}: ${entry.root}`);
1072
+ };
1073
+ if (await git("read-tree", "--reset", "-u", base) === null || await git("clean", "-fd") === null) return fail("recycle failed");
1074
+ if ((options.branch ? await branchExists(exec, primary, options.branch) ? await git("switch", "--quiet", "--discard-changes", options.branch) : await git("switch", "--quiet", "--discard-changes", "-c", options.branch, base) : await git("switch", "--quiet", "--discard-changes", "--detach", base)) === null) return fail(`recycle could not switch to ${options.branch ?? base}`);
1075
+ const result = {
1076
+ ...lease,
1077
+ reused: true,
1078
+ branch: options.branch,
1079
+ verified: verdict.verified,
1080
+ lingering: verdict.occupancy?.lingering ?? []
1081
+ };
1082
+ const previous = entry.branch;
1083
+ if (previous) {
1084
+ result.previousBranch = previous;
1085
+ if (entry.merged === true && previous !== options.branch) result.previousBranchDeleted = await exec("git", [
1086
+ "-C",
1087
+ primary,
1088
+ "branch",
1089
+ "-D",
1090
+ previous
1091
+ ]) !== null;
1092
+ }
1093
+ return result;
1094
+ }
1095
+ async function createSlot(primary, base, options, exec, slots) {
1096
+ const used = new Set(slots.map((entry) => slotNumber(primary, entry.root)));
1097
+ let n = 1;
1098
+ while (used.has(n) || await exists(slotPath(primary, n))) n++;
1099
+ const path = slotPath(primary, n);
1100
+ const create = options.create ?? gitWorktreeCreator(exec);
1101
+ const request = {
1102
+ primaryRoot: primary,
1103
+ path,
1104
+ base,
1105
+ branch: options.branch
1106
+ };
1107
+ try {
1108
+ await create(request);
1109
+ } catch (error) {
1110
+ if (await exists(path)) return void 0;
1111
+ throw error;
1112
+ }
1113
+ let lease;
1114
+ try {
1115
+ lease = await claimLease(await normalizeWorktreePath(path), options.holder, {
1116
+ exec,
1117
+ fs: options.fs,
1118
+ leaseId: options.newLeaseId?.()
1119
+ });
1120
+ } catch (error) {
1121
+ await undo(options.undoCreate, request);
1122
+ throw error;
1123
+ }
1124
+ if (!lease) await undo(options.undoCreate, request);
1125
+ return lease;
1126
+ }
1127
+ async function undo(undoCreate, request) {
1128
+ try {
1129
+ await undoCreate?.(request);
1130
+ } catch {}
1131
+ }
1132
+ async function branchExists(exec, primary, branch) {
1133
+ return await exec("git", [
1134
+ "-C",
1135
+ primary,
1136
+ "rev-parse",
1137
+ "--verify",
1138
+ "--quiet",
1139
+ `refs/heads/${branch}`
1140
+ ]) !== null;
1141
+ }
1142
+ async function exists(path) {
1143
+ try {
1144
+ await lstat(path);
1145
+ return true;
1146
+ } catch {
1147
+ return false;
1148
+ }
1149
+ }
1150
+ //#endregion
1151
+ //#region src/worktrees/prune-worktrees.ts
1152
+ /**
1153
+ * Removes the worktrees this library owns that nothing needs any more: unleased, idle, clean, and
1154
+ * landed. A dry run unless `apply` is set. Never touches the primary checkout, a foreign worktree, a
1155
+ * leased one, or a busy one, and never kills a process: lingering services are reported so the caller
1156
+ * can reclaim them (E-TH-5 is the counterexample).
1157
+ *
1158
+ * An unleased worktree carries no record of its owner, since the lease is the only one this library
1159
+ * writes; `classifyOwner` recognises it as this library's by its slot path (`slotPath`).
1160
+ */
1161
+ async function pruneWorktrees(options) {
1162
+ const exec = options.exec ?? nodeExec;
1163
+ const primary = await normalizeWorktreePath(options.primaryRoot);
1164
+ const entries = await listWorktrees({
1165
+ primaryRoot: options.primaryRoot,
1166
+ exec,
1167
+ ignore: options.ignore,
1168
+ forge: options.forge
1169
+ });
1170
+ const wanted = options.roots && new Set(await Promise.all(options.roots.map(normalizeWorktreePath)));
1171
+ const probe = await probeProcesses({ source: options.source });
1172
+ const strict = options.strict ?? true;
1173
+ const apply = options.apply === true;
1174
+ const worktrees = [];
1175
+ for (const entry of entries) {
1176
+ if (wanted && !wanted.has(entry.root)) continue;
1177
+ const owner = classifyOwner(entry, {
1178
+ primaryRoot: primary,
1179
+ codexHome: options.codexHome
1180
+ }).owner;
1181
+ const nested = entries.map((other) => other.root).filter((root) => root.startsWith(`${entry.root}${sep}`));
1182
+ const occupancy = probe.occupancy(entry.root, {
1183
+ strict,
1184
+ nested
1185
+ });
1186
+ const outcome = {
1187
+ root: entry.root,
1188
+ owner,
1189
+ status: "skipped",
1190
+ occupants: occupancy.occupants,
1191
+ lingering: occupancy.lingering,
1192
+ unlinked: occupancy.unlinked
1193
+ };
1194
+ if (entry.branch) outcome.branch = entry.branch;
1195
+ if (!occupancy.verified && !strict) outcome.unverified = true;
1196
+ const reason = skipReason(entry, owner, occupancy);
1197
+ if (reason) outcome.reason = reason;
1198
+ else outcome.status = apply ? await removeEntry(exec, primary, entry, options.ignore) : "candidate";
1199
+ worktrees.push(outcome);
1200
+ }
1201
+ if (!apply) return {
1202
+ applied: false,
1203
+ worktrees
1204
+ };
1205
+ return {
1206
+ applied: true,
1207
+ worktrees,
1208
+ pruned: await exec("git", [
1209
+ "-C",
1210
+ primary,
1211
+ "worktree",
1212
+ "prune"
1213
+ ]) !== null
1214
+ };
1215
+ }
1216
+ /**
1217
+ * The first check a worktree fails, or `undefined` when it may go. `force` is `removeWorktree`'s
1218
+ * explicit override. `true` waives every check except `primary`, `leased`, and `busy` or `unverified`;
1219
+ * `'in-use'` waives those two as well. Nothing waives `primary` and `leased`.
1220
+ */
1221
+ function skipReason(entry, owner, occupancy, force = false) {
1222
+ if (!entry.linked) return "primary";
1223
+ if (parseLeaseReason(entry.locked)) return "leased";
1224
+ if (force) {
1225
+ if (force !== "in-use" && occupancy.busy) return occupancy.verified ? "busy" : "unverified";
1226
+ return;
1227
+ }
1228
+ if (owner !== "self") return "foreign";
1229
+ if (entry.prunable) return "prunable";
1230
+ if (occupancy.busy) return occupancy.verified ? "busy" : "unverified";
1231
+ if (entry.dirty === void 0) return "dirty-unknown";
1232
+ if (entry.dirty) return "dirty";
1233
+ if (entry.merged === void 0) return "merge-unknown";
1234
+ if (!entry.merged) return "unmerged";
1235
+ }
1236
+ async function removeEntry(exec, primary, entry, ignore, forced = false) {
1237
+ if (forced) return await exec("git", [
1238
+ "-C",
1239
+ primary,
1240
+ "worktree",
1241
+ "remove",
1242
+ "--force",
1243
+ entry.root
1244
+ ]) === null ? "failed" : "removed";
1245
+ if (await readDirty(entry.root, {
1246
+ exec,
1247
+ ignore
1248
+ }) !== false) return "failed";
1249
+ return await exec("git", [
1250
+ "-C",
1251
+ primary,
1252
+ "worktree",
1253
+ "remove",
1254
+ ...ignore?.length ? ["--force"] : [],
1255
+ entry.root
1256
+ ]) === null ? "failed" : "removed";
1257
+ }
1258
+ //#endregion
1259
+ //#region src/worktrees/remove-worktree.ts
1260
+ /**
1261
+ * Removes one worktree the caller names, the single-target counterpart of `pruneWorktrees`, and
1262
+ * reports the same `PruneOutcome` with a skip `reason`. The caller naming it is what a prune lacks, so
1263
+ * the landed check can be waived with `force`, and the idle check with `force: 'in-use'`; ownership cannot:
1264
+ *
1265
+ * | worktree | without `force` | `force: true` | `force: 'in-use'` |
1266
+ * | --- | --- | --- | --- |
1267
+ * | the primary checkout | `primary` | `primary` | `primary` |
1268
+ * | held by this library's lease | `leased`; `release` it first | `leased` | `leased`; the lease is never broken |
1269
+ * | foreign (another tool, person, or lock) | `foreign` | removed, unless git refuses a locked one: `failed` | same |
1270
+ * | a live agent session inside | `busy` (or `unverified`) | `busy` (or `unverified`) | removed; the sessions are reported, never killed |
1271
+ * | checkout already gone | `prunable` | `git worktree remove --force` is tried | same |
1272
+ * | dirty or unmerged | the matching reason | removed | removed |
1273
+ * | a path that is no worktree of this repository | `unlisted` | `unlisted` | `unlisted` |
1274
+ *
1275
+ * `--force` is passed once and `-f -f` never: one refuses a locked worktree, so a lease taken after the
1276
+ * listing holds (E-GIT-L3, E-GIT-L4). Never kills a process; lingering services are reported so the
1277
+ * caller can reclaim them. Always applies: there is no dry run, since the caller named the target.
1278
+ */
1279
+ async function removeWorktree(options) {
1280
+ const exec = options.exec ?? nodeExec;
1281
+ const primary = await normalizeWorktreePath(options.primaryRoot);
1282
+ const root = await normalizeWorktreePath(options.root);
1283
+ const entries = await listWorktrees({
1284
+ primaryRoot: options.primaryRoot,
1285
+ exec,
1286
+ ignore: options.ignore,
1287
+ forge: options.forge
1288
+ });
1289
+ const entry = entries.find((candidate) => candidate.root === root);
1290
+ if (!entry) return {
1291
+ root,
1292
+ owner: "unknown",
1293
+ status: "skipped",
1294
+ reason: "unlisted",
1295
+ occupants: [],
1296
+ lingering: [],
1297
+ unlinked: []
1298
+ };
1299
+ const force = options.force ?? false;
1300
+ const strict = options.strict ?? true;
1301
+ const owner = classifyOwner(entry, {
1302
+ primaryRoot: primary,
1303
+ codexHome: options.codexHome
1304
+ }).owner;
1305
+ const probe = await probeProcesses({ source: options.source });
1306
+ const nested = entries.map((other) => other.root).filter((path) => path.startsWith(`${entry.root}${sep}`));
1307
+ const occupancy = probe.occupancy(entry.root, {
1308
+ strict,
1309
+ nested
1310
+ });
1311
+ const outcome = {
1312
+ root: entry.root,
1313
+ owner,
1314
+ status: "skipped",
1315
+ occupants: occupancy.occupants,
1316
+ lingering: occupancy.lingering,
1317
+ unlinked: occupancy.unlinked
1318
+ };
1319
+ if (entry.branch) outcome.branch = entry.branch;
1320
+ if (!occupancy.verified && !strict && force !== "in-use") outcome.unverified = true;
1321
+ const reason = skipReason(entry, owner, occupancy, force);
1322
+ if (reason) outcome.reason = reason;
1323
+ else outcome.status = await removeEntry(exec, primary, entry, options.ignore, force !== false);
1324
+ return outcome;
1325
+ }
1326
+ //#endregion
1327
+ //#region src/worktrees/seed-worktree.ts
1328
+ /** The default `SeedFs`, on `node:fs/promises`. */
1329
+ const nodeSeedFs = {
1330
+ async readText(path) {
1331
+ try {
1332
+ return await readFile(path, "utf8");
1333
+ } catch (error) {
1334
+ if (error.code === "ENOENT") return void 0;
1335
+ throw error;
1336
+ }
1337
+ },
1338
+ async exists(path) {
1339
+ try {
1340
+ await lstat(path);
1341
+ return true;
1342
+ } catch {
1343
+ return false;
1344
+ }
1345
+ },
1346
+ async mkdirp(path) {
1347
+ await mkdir(path, { recursive: true });
1348
+ },
1349
+ async copyFile(from, to) {
1350
+ await copyFile(from, to);
1351
+ }
1352
+ };
1353
+ /** The include file's name and place: the project root, `.gitignore` syntax (research: 'E-CC-W2'). */
1354
+ const WORKTREE_INCLUDE = ".worktreeinclude";
1355
+ /** Pathspecs per `git ls-files` call, to stay under the platform's argument-length limit. */
1356
+ const CHUNK = 500;
1357
+ /**
1358
+ * Copies the gitignored files a `.worktreeinclude` names from a source checkout (normally the primary)
1359
+ * into a new or recycled worktree, and reports what it copied and what it skipped.
1360
+ *
1361
+ * Claude Code's semantics (research: 'E-CC-W2'): the file sits at the project root and uses
1362
+ * `.gitignore` syntax, and only a file that matches a pattern *and* is gitignored is copied, so
1363
+ * tracked files are never duplicated. Git does the matching here, so negation, anchoring, and
1364
+ * directory patterns behave exactly as they do in `.gitignore`. Inside a wholly ignored directory, a
1365
+ * pattern that starts with `**` and a slash matches every file below it here, while Claude Code reaches
1366
+ * in only when the directory's path holds the pattern's first name (research: 'E-CC-W6'), so this can
1367
+ * copy a superset of what Claude Code would.
1368
+ *
1369
+ * The include file is read from the source's working tree, as Claude Code does, not from its
1370
+ * committed HEAD as Treehouse does (research: 'E-TH-4'), so an uncommitted edit takes effect.
1371
+ */
1372
+ async function seedWorktree(sourceRoot, worktreeRoot, options) {
1373
+ const fs = options.fs ?? nodeSeedFs;
1374
+ const inventory = {
1375
+ include: "absent",
1376
+ copied: [],
1377
+ skipped: []
1378
+ };
1379
+ const text = await fs.readText(join(sourceRoot, WORKTREE_INCLUDE));
1380
+ if (text === void 0) return inventory;
1381
+ const patterns = parsePatterns(text);
1382
+ if (patterns.length === 0) return {
1383
+ ...inventory,
1384
+ include: "present"
1385
+ };
1386
+ const candidates = await lsFiles(options.exec, sourceRoot, patterns.flatMap((pattern) => ["-x", pattern]));
1387
+ if (candidates === void 0) return {
1388
+ ...inventory,
1389
+ include: "unknown"
1390
+ };
1391
+ const ignored = /* @__PURE__ */ new Set();
1392
+ for (let i = 0; i < candidates.length; i += CHUNK) {
1393
+ const chunk = candidates.slice(i, i + CHUNK).map((path) => `:(literal)${path}`);
1394
+ const found = await lsFiles(options.exec, sourceRoot, [
1395
+ "--exclude-standard",
1396
+ "--",
1397
+ ...chunk
1398
+ ]);
1399
+ if (found === void 0) return {
1400
+ ...inventory,
1401
+ include: "unknown"
1402
+ };
1403
+ for (const path of found) ignored.add(path);
1404
+ }
1405
+ inventory.include = "present";
1406
+ for (const path of candidates) {
1407
+ if (!ignored.has(path)) {
1408
+ inventory.skipped.push({
1409
+ path,
1410
+ reason: "not-ignored"
1411
+ });
1412
+ continue;
1413
+ }
1414
+ const to = join(worktreeRoot, path);
1415
+ if (!options.overwrite && await fs.exists(to)) {
1416
+ inventory.skipped.push({
1417
+ path,
1418
+ reason: "exists"
1419
+ });
1420
+ continue;
1421
+ }
1422
+ try {
1423
+ await fs.mkdirp(dirname(to));
1424
+ await fs.copyFile(join(sourceRoot, path), to);
1425
+ inventory.copied.push(path);
1426
+ } catch (error) {
1427
+ inventory.skipped.push({
1428
+ path,
1429
+ reason: "failed",
1430
+ error: error instanceof Error ? error.message : String(error)
1431
+ });
1432
+ }
1433
+ }
1434
+ return inventory;
1435
+ }
1436
+ /** Lines of a `.gitignore`-syntax file that carry a pattern; git itself ignores blanks and comments. */
1437
+ function parsePatterns(text) {
1438
+ return text.split(/\r?\n/).filter((line) => line.trim() !== "" && !line.startsWith("#"));
1439
+ }
1440
+ /** `git ls-files --others --ignored` with the given exclude source, NUL-separated so no path is
1441
+ * C-quoted; `undefined` when git failed. */
1442
+ async function lsFiles(exec, root, args) {
1443
+ const out = await exec("git", [
1444
+ "-C",
1445
+ root,
1446
+ "ls-files",
1447
+ "-z",
1448
+ "--others",
1449
+ "--ignored",
1450
+ ...args
1451
+ ]);
1452
+ if (out === null) return void 0;
1453
+ return out.split("\0").filter((path) => path !== "");
1454
+ }
1455
+ //#endregion
1456
+ export { AcquireError, LEASE_LIBRARY, WORKTREE_INCLUDE, acquire, claimLease, classifyOwner, explain, ghForgeMergedProbe, gitWorktreeCreator, holdsLease, leaseFile, listWorktrees, nodeExec, nodeLeaseFs, nodeSeedFs, normalizeWorktreePath, occupants, parseLeaseReason, primaryRoot, probeProcesses, procfsProcessSource, pruneWorktrees, readDirty, release, removeWorktree, seedWorktree, slotNumber, slotPath, worktreesDir };