viewable-one 0.0.0-stage → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +1483 -0
  2. package/LICENSE +21 -0
  3. package/README.md +738 -2
  4. package/SPAWN.md +2108 -0
  5. package/glytch.mjs +517 -0
  6. package/lib/acp.mjs +1018 -0
  7. package/lib/admission.mjs +105 -0
  8. package/lib/agent-selection.mjs +124 -0
  9. package/lib/agents.mjs +914 -0
  10. package/lib/atomic.mjs +96 -0
  11. package/lib/auth.mjs +1491 -0
  12. package/lib/authenticatedHttp.mjs +75 -0
  13. package/lib/boardside.mjs +225 -0
  14. package/lib/child.mjs +1292 -0
  15. package/lib/cli-output.mjs +176 -0
  16. package/lib/cloudflared-release.json +25 -0
  17. package/lib/cloudflared.mjs +81 -0
  18. package/lib/codex-rpc.mjs +92 -0
  19. package/lib/codex.mjs +767 -0
  20. package/lib/codexApps.mjs +344 -0
  21. package/lib/codexPlanWindow.mjs +84 -0
  22. package/lib/codexToolInputs.mjs +139 -0
  23. package/lib/codingChecks.mjs +55 -0
  24. package/lib/compose.mjs +76 -0
  25. package/lib/computerApps.mjs +110 -0
  26. package/lib/conversationRules.mjs +45 -0
  27. package/lib/detect.mjs +629 -0
  28. package/lib/devserver.mjs +670 -0
  29. package/lib/digest.mjs +290 -0
  30. package/lib/doctor.mjs +464 -0
  31. package/lib/exitClass.mjs +64 -0
  32. package/lib/hosted.mjs +112 -0
  33. package/lib/imageAttachments.mjs +31 -0
  34. package/lib/init-connect.mjs +418 -0
  35. package/lib/kill.mjs +104 -0
  36. package/lib/logindaemon.mjs +976 -0
  37. package/lib/logs.mjs +120 -0
  38. package/lib/mcpjson.mjs +242 -0
  39. package/lib/memberPreview.mjs +173 -0
  40. package/lib/multiwatch.mjs +428 -0
  41. package/lib/pagePictures.mjs +396 -0
  42. package/lib/pageRoutes.mjs +70 -0
  43. package/lib/paths.mjs +10 -0
  44. package/lib/permissionAsk.mjs +418 -0
  45. package/lib/picker.mjs +300 -0
  46. package/lib/platform.mjs +60 -0
  47. package/lib/previewGateway.mjs +130 -0
  48. package/lib/previewTunnel.mjs +85 -0
  49. package/lib/procs.mjs +428 -0
  50. package/lib/project-mcp.mjs +288 -0
  51. package/lib/projectPaths.mjs +31 -0
  52. package/lib/refusal.mjs +70 -0
  53. package/lib/registry.mjs +204 -0
  54. package/lib/repomap.mjs +323 -0
  55. package/lib/restartPass.mjs +151 -0
  56. package/lib/retired-address.mjs +78 -0
  57. package/lib/rowsAnswers.mjs +388 -0
  58. package/lib/rowsEvents.mjs +86 -0
  59. package/lib/run-engine.mjs +16 -0
  60. package/lib/runner-health.mjs +57 -0
  61. package/lib/runner.mjs +1640 -0
  62. package/lib/runnerAdmission.mjs +35 -0
  63. package/lib/runnerApi.mjs +312 -0
  64. package/lib/runnerCompletion.mjs +406 -0
  65. package/lib/runnerLease.mjs +37 -0
  66. package/lib/runnerMaintenance.mjs +27 -0
  67. package/lib/runnerPace.mjs +283 -0
  68. package/lib/runnerPerformance.mjs +26 -0
  69. package/lib/runnerRealtime.mjs +340 -0
  70. package/lib/runnerRegister.mjs +96 -0
  71. package/lib/runnerSettings.mjs +40 -0
  72. package/lib/runnerSettlement.mjs +145 -0
  73. package/lib/runnerWorkspace.mjs +177 -0
  74. package/lib/serverSweep.mjs +90 -0
  75. package/lib/sharedPreview.mjs +292 -0
  76. package/lib/signIn.mjs +47 -0
  77. package/lib/spawn.mjs +330 -0
  78. package/lib/sweep.mjs +161 -0
  79. package/lib/sync.mjs +484 -0
  80. package/lib/uninstall-project.mjs +589 -0
  81. package/lib/uninstall.mjs +464 -0
  82. package/lib/update.mjs +302 -0
  83. package/lib/usageLimit.mjs +108 -0
  84. package/lib/viewableFolder.mjs +105 -0
  85. package/lib/windows-command.mjs +85 -0
  86. package/lib/worktree.mjs +1142 -0
  87. package/lib/writePolicy.mjs +45 -0
  88. package/package.json +58 -4
  89. package/spawn-runner.mjs +167 -0
@@ -0,0 +1,1142 @@
1
+ // A spawned session gets its own git worktree, so a bad run cannot touch the
2
+ // checkout the user is looking at.
3
+ //
4
+ // The ruling, 2026-08-26: "Worktree yes!". The gap it closes was found while
5
+ // the first real board-spawned session was still running: the runner passes
6
+ // `cwd: request.cwd || root`, and a board request deliberately carries no cwd
7
+ // (a browser directory handle has a name, not a path), so every spawned
8
+ // session was landing in the MAIN checkout, on whatever branch was checked
9
+ // out - which was `main`. Every session dispatched by hand that night ran in
10
+ // an isolated worktree and got that safety for free; the ones spawned from
11
+ // the canvas, the whole point of the feature, did not. The brief asked them
12
+ // nicely to work on a branch. Nothing enforced it.
13
+ //
14
+ // TWO THINGS THIS MUST NOT BREAK, both learned the hard way:
15
+ //
16
+ // `canvas/` and `inbox/` are GITIGNORED, so a worktree has neither. A session
17
+ // whose board root resolved to its own worktree would write ops files into a
18
+ // folder the app has never heard of - the call succeeds, an op id comes back,
19
+ // and nothing ever reaches the canvas (bridge/README.md, "Sessions in a git
20
+ // worktree"). The child's PM_BOARD_ROOT already points at the real linked
21
+ // folder and MUST keep doing so; that is why this returns a cwd and touches
22
+ // nothing else about the environment.
23
+ //
24
+ // `node_modules` is not in a fresh worktree either, and a session that cannot
25
+ // run `npx vitest` cannot verify what it built. It is symlinked rather than
26
+ // installed: an install per spawn costs minutes and disk for a tree that is
27
+ // usually thrown away.
28
+
29
+ import { execFile } from "node:child_process";
30
+ import { symlink, mkdir, rm, access, readFile, writeFile, stat, lstat } from "node:fs/promises";
31
+ import { hostname } from "node:os";
32
+ import { canonicalPath, staysInProject } from "./projectPaths.mjs";
33
+ import { basename, isAbsolute, join, resolve, sep } from "node:path";
34
+ // THE PROCESS KILLING LIVES IN procs.mjs since 2026-09-02: it is about
35
+ // processes, not about git, and both of its functions had already learned the
36
+ // same "whose process is this?" lesson the hard way. Re-exported below so no
37
+ // importer and no test changed.
38
+ // GLYTCH lives in procs.mjs and is re-exported here. It is the prefix every
39
+ // board-spawned tree and branch carries, and procs.mjs is where it is
40
+ // MATCHED - the reaper's whole first guard is "is this directory one
41
+ // makeWorktree could have made?". Putting it there rather than importing it
42
+ // from here is what keeps these two modules a straight line instead of a
43
+ // cycle, which is the thing check-portable --strict now looks for.
44
+ import { CHAT_PREFIX, GLYTCH, killProcessesIn } from "./procs.mjs";
45
+ export { CHAT_PREFIX, GLYTCH };
46
+ export { isOursToKill, parseProcessTable, reapOrphanedWorktreeProcesses, reapableWorktreeName } from "./procs.mjs";
47
+
48
+ /** How `node_modules` is linked into a fresh worktree.
49
+ *
50
+ * A DIRECTORY SYMLINK ON WINDOWS NEEDS DEVELOPER MODE OR ADMIN. Without one it
51
+ * throws EPERM, which the caller catches and logs, and the session then cannot
52
+ * run the project's own tests - which is most of what a brief asks it to do.
53
+ * A junction needs no privilege, works for a local absolute directory (which
54
+ * this always is: it is `<root>/node_modules`), and is what npm itself uses on
55
+ * Windows. Brief 21, session 3. Exported so the choice can be pinned from a
56
+ * Mac rather than only observed on Windows. */
57
+ export const nodeModulesLinkType = (platform = process.platform) => (platform === "win32" ? "junction" : "dir");
58
+
59
+ // Exported (2026-09-11, brief 30 Move 2) so `runner.mjs`'s rows-based
60
+ // admission check can run the same merge-base check
61
+ // this file already does, without a second copy of the `execFile` wrapper.
62
+ export const git = (args, cwd) =>
63
+ new Promise((resolve) => {
64
+ execFile("git", args, { cwd, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
65
+ resolve({
66
+ ok: !error,
67
+ // The exit status, for the few commands whose non-zero exit is an
68
+ // answer rather than a failure (`merge-tree` exits 1 on a conflict).
69
+ code: error ? (typeof error.code === "number" ? error.code : -1) : 0,
70
+ stdout: String(stdout ?? ""),
71
+ stderr: String(stderr ?? ""),
72
+ detail: error ? String(stderr || error.message).trim() : "",
73
+ });
74
+ });
75
+ });
76
+
77
+ /** Is `dir` inside a git checkout? False when git says no or cannot run. */
78
+ export async function isGitCheckout(dir) {
79
+ const inRepo = await git(["rev-parse", "--is-inside-work-tree"], dir);
80
+ return inRepo.ok && inRepo.stdout.trim() === "true";
81
+ }
82
+
83
+ const exists = async (path) => {
84
+ try {
85
+ await access(path);
86
+ return true;
87
+ } catch {
88
+ return false;
89
+ }
90
+ };
91
+
92
+ // PER-REPO LOCK for git mutations. makeWorktree, cleanWorktree, reuseWorktree,
93
+ // mergeWorktree and deleteWorktree on the same root run one at a time. Reads
94
+ // (branchOfWorktree, worktreeSummary) stay unlocked.
95
+ //
96
+ // It is TWO locks, because there are two ways to collide.
97
+ //
98
+ // IN-PROCESS, a promise chain keyed by repo root: two sessions finishing
99
+ // inside one runner can no longer both `git checkout main` and merge at once.
100
+ // This is the lock that has been here since 2026-09-01.
101
+ //
102
+ // CROSS-PROCESS, a lockdir under the repo's own `.git/`. The comment above
103
+ // used to say "one runner process owns a root (the claim files keep two
104
+ // runners apart), so an in-process lock is enough", and that was not true of
105
+ // the code standing beside it: `spawn-runner.mjs merge <path>` and
106
+ // `spawn-runner.mjs delete-worktree <path>` are a SECOND process, typed by a
107
+ // person, calling straight into mergeWorktree while a watcher is running. The
108
+ // claim files keep two runners from taking the same REQUEST; they say nothing
109
+ // about two processes running `git checkout main` in the same second.
110
+ //
111
+ // `mkdir` is the primitive because it is the one atomic exclusive create every
112
+ // filesystem agrees on - the same reasoning the claim file uses `flag: "wx"`.
113
+ // The owner file inside it is who holds it, and it is how a lock survives its
114
+ // holder being killed: a pid that is gone on this host frees it immediately, a
115
+ // lock from another host frees on age alone. Waiting is bounded and losing the
116
+ // wait is not fatal - it logs loudly and proceeds, because refusing to clean up
117
+ // a worktree is worse than the small race that was there all along.
118
+ const lockChains = new Map(); // root -> Promise that resolves when the lock is free
119
+
120
+ /** How long a lockdir is believed before it is treated as abandoned. Git
121
+ * mutations here take seconds; ten minutes is a crashed holder, not a busy
122
+ * one. */
123
+ export const LOCK_STALE_MS = 10 * 60_000;
124
+ /** How long to wait for another process to finish before going ahead anyway. */
125
+ export const LOCK_WAIT_MS = 60_000;
126
+ const LOCK_POLL_MS = 150;
127
+
128
+ // INSIDE `.git/`, and the reason is not tidiness. mergeWorktreeImpl refuses to
129
+ // merge into a dirty checkout, and rightly - so a lock that git can SEE makes
130
+ // every merge refuse itself. `.git/` is the one directory `git status` never
131
+ // reports, it is exactly per-repo, and it is already where git keeps its own
132
+ // locks. When `.git` is a file rather than a directory (this root is itself a
133
+ // linked worktree) the mkdir fails, and acquireFileLock says so and proceeds.
134
+ const lockDirFor = (root) => join(resolve(root), ".git", "pm-repo.lock");
135
+
136
+ const stillAlive = (pid) => {
137
+ try {
138
+ process.kill(pid, 0);
139
+ return true;
140
+ } catch (error) {
141
+ // EPERM means it exists and belongs to somebody else.
142
+ return error?.code === "EPERM";
143
+ }
144
+ };
145
+
146
+ /** Take the cross-process lock. Returns a release function, or null when the
147
+ * wait ran out - the caller proceeds either way, and says so. */
148
+ async function acquireFileLock(root, log) {
149
+ const dir = lockDirFor(root);
150
+ const startedAt = Date.now();
151
+ const deadline = startedAt + LOCK_WAIT_MS;
152
+ const mine = { pid: process.pid, host: hostname(), at: Date.now() };
153
+ // WAITED, NAMED (2026-09-21, the claim-to-spawn gap). `busy` is only true
154
+ // once the FIRST `mkdir` has already lost to EEXIST - so a lock taken on
155
+ // the first try logs nothing, same as before, and only a caller that
156
+ // actually queued behind another holder pays for a line. `lastOwner` is
157
+ // whichever `owner.json` was last read while waiting, so the eventual
158
+ // success can say who it waited on even though that holder may have
159
+ // released and vanished by the time this loop wins.
160
+ let busy = false;
161
+ let lastOwner = null;
162
+ for (;;) {
163
+ try {
164
+ await mkdir(dir); // EEXIST when somebody holds it: the whole of the lock
165
+ await writeFile(join(dir, "owner.json"), `${JSON.stringify(mine)}\n`, "utf8").catch(() => {});
166
+ if (busy) {
167
+ const waitedSecs = ((Date.now() - startedAt) / 1000).toFixed(1);
168
+ log(
169
+ `took the repo lock at ${dir} after waiting ${waitedSecs}s ` +
170
+ `(previously held by pid ${lastOwner?.pid ?? "?"} on ${lastOwner?.host ?? "?"})`,
171
+ );
172
+ }
173
+ return async () => { await rm(dir, { recursive: true, force: true }).catch(() => {}); };
174
+ } catch (error) {
175
+ if (error?.code !== "EEXIST") {
176
+ // No lock is better than no worktree operation: a `.git` that is a
177
+ // file (this root is a linked worktree), a read-only checkout, or a
178
+ // filesystem that will not mkdir must not stop a merge.
179
+ log(`could not take the repo lock (${error?.code ?? error}); proceeding without it`);
180
+ return null;
181
+ }
182
+ busy = true;
183
+ let owner = null;
184
+ try { owner = JSON.parse(await readFile(join(dir, "owner.json"), "utf8")); } catch { /* mid-write, or crashed between mkdir and write */ }
185
+ if (owner) lastOwner = owner;
186
+ // A holder that just won the `mkdir` has not written `owner.json` yet,
187
+ // so a reader landing in that gap sees the same ENOENT a crashed holder
188
+ // would leave forever. Treating "unreadable" as "infinitely old" (age
189
+ // measured from `at: 0`) cleared a LIVE lock out from under its holder
190
+ // on every such read - a real cross-process race, not just a test one.
191
+ // Falling back to the lock DIRECTORY's own age keeps a lock that was
192
+ // just taken safe (its mtime is now) while still reclaiming one whose
193
+ // holder crashed before ever writing owner.json (the dir ages past
194
+ // LOCK_STALE_MS same as always).
195
+ let age;
196
+ if (owner) {
197
+ age = Date.now() - (Number(owner.at) || 0);
198
+ } else {
199
+ const dirStat = await stat(dir).catch(() => null);
200
+ age = dirStat ? Date.now() - dirStat.mtimeMs : 0;
201
+ }
202
+ const deadHolder = owner?.host === mine.host && Number.isFinite(owner?.pid) && !stillAlive(owner.pid);
203
+ if (deadHolder || age > LOCK_STALE_MS) {
204
+ log(`clearing a ${deadHolder ? "dead" : "stale"} repo lock at ${dir} (pid ${owner?.pid ?? "?"})`);
205
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
206
+ continue;
207
+ }
208
+ if (Date.now() >= deadline) {
209
+ log(
210
+ `waited ${Math.round(LOCK_WAIT_MS / 1000)}s for the repo lock held by pid ${owner?.pid ?? "?"} ` +
211
+ `on ${owner?.host ?? "?"}; going ahead anyway`,
212
+ );
213
+ return null;
214
+ }
215
+ await new Promise((r) => setTimeout(r, LOCK_POLL_MS));
216
+ }
217
+ }
218
+ }
219
+
220
+ /** Acquire the in-process lock for the given repo root. Returns a release
221
+ * function. */
222
+ async function acquireRepoLock(root) {
223
+ const normalized = resolve(root); // normalize paths so /foo and /foo/ are the same key
224
+ const existingChain = lockChains.get(normalized) ?? Promise.resolve();
225
+ let release;
226
+ const newChain = new Promise((res) => { release = res; });
227
+ lockChains.set(normalized, newChain);
228
+ await existingChain;
229
+ return release;
230
+ }
231
+
232
+ /** Wrap a worktree operation with the repo lock, both halves. In-process
233
+ * first, so a queue of callers inside this process waits on a promise rather
234
+ * than on a directory. Preparation may supply check/own: check cancellation
235
+ * after each lock wait, then own the started mutation through its result.
236
+ * Callers must not treat the queued lock wait itself as an owned mutation. */
237
+ export async function withRepoLock(root, fn, log = () => {}, lifecycle = {}) {
238
+ const release = await acquireRepoLock(root);
239
+ let releaseFile = null;
240
+ try {
241
+ lifecycle.check?.();
242
+ releaseFile = await acquireFileLock(root, log);
243
+ lifecycle.check?.();
244
+ return await (lifecycle.own ? lifecycle.own(fn) : fn());
245
+ } finally {
246
+ if (releaseFile) await releaseFile();
247
+ release();
248
+ }
249
+ }
250
+
251
+ /** The repo's default branch, LOCAL only: `main` if `refs/heads/main` exists,
252
+ * else `master` if that exists, else null.
253
+ *
254
+ * PROVEN 2026-09-21: `makeWorktreeImpl` branched off `git rev-parse HEAD` -
255
+ * whatever the main checkout happened to have checked out, not the repo's
256
+ * actual default branch. A run based on a side branch someone left checked
257
+ * out, and `mergeWorktreeImpl` (which checks out literal `main` and merges
258
+ * into it) would have dragged that whole side branch into main. A run's
259
+ * worktree must instead base on the SAME ref the merge op merges into.
260
+ *
261
+ * LOCAL over `origin/*`: the merge op merges into the local branch of that
262
+ * name, so the base must be the same ref - basing off `origin/main` while
263
+ * local `main` has diverged would silently drop or duplicate commits at
264
+ * merge time. `--verify --quiet` against `refs/heads/<name>` so a detached
265
+ * HEAD or a missing branch is a clean `null`, not a thrown error. */
266
+ export async function defaultBranchOf(root) {
267
+ for (const name of ["main", "master"]) {
268
+ const check = await git(["rev-parse", "--verify", "--quiet", `refs/heads/${name}`], root);
269
+ if (check.ok) return name;
270
+ }
271
+ return null;
272
+ }
273
+
274
+ /** The prompt's own name, made safe for a git ref and a directory.
275
+ *
276
+ * Only [a-z0-9-] survives, which is what keeps the result a legal ref: every
277
+ * shape git refuses (a leading dash, `..`, `@{`, a `.lock` ending, a space)
278
+ * is spelled with a character this drops. Capped so a 60-character title does
279
+ * not become a 60-character path segment; the tail below is what makes it
280
+ * unique, so cutting here loses traceability, never correctness. */
281
+ export const slugForTitle = (title) =>
282
+ String(title ?? "")
283
+ .toLowerCase()
284
+ .replace(/[^a-z0-9]+/g, "-")
285
+ .replace(/^-+|-+$/g, "")
286
+ .slice(0, 40)
287
+ .replace(/-+$/g, "");
288
+
289
+ /** The name both the tree and the branch are built from: the prompt, then the
290
+ * request id.
291
+ *
292
+ * THE ID STAYS, and it is not decoration. Two runs of the same prompt would
293
+ * otherwise ask for the same branch, `git worktree add -b` would fail, and
294
+ * makeWorktree's failure path is "run in the linked checkout instead" - so a
295
+ * name collision would silently put the second session on his own working
296
+ * tree. It also keeps the tree traceable back to the file that asked for it,
297
+ * which is what the id was for before the prompt name joined it. Its `spawn-`
298
+ * prefix is dropped: `glytch` already says what this is. */
299
+ export const worktreeStem = (id, title) => {
300
+ const slug = slugForTitle(title);
301
+ const tail = String(id ?? "").replace(/^spawn-/, "");
302
+ return [slug, tail].filter((part) => part !== "").join("-") || "session";
303
+ };
304
+
305
+ /** Where a request's worktree and branch live. A directory cannot hold the
306
+ * branch's `/`, so the two differ by that one character and nothing else. */
307
+ export const worktreeName = (id, title) => `${GLYTCH}-${worktreeStem(id, title)}`;
308
+ export const worktreePath = (root, id, title) =>
309
+ join(root, ".claude", "worktrees", worktreeName(id, title));
310
+ export const worktreeBranch = (id, title) => `${GLYTCH}/${worktreeStem(id, title)}`;
311
+
312
+ /** True only when `target` resolves to a path strictly inside
313
+ * `<root>/.claude/worktrees/` — the one directory makeWorktree ever creates.
314
+ *
315
+ * The merge/delete SIGNAL FILES carry the worktree path to act on, and until
316
+ * this nothing validated it: `killProcessesIn` SIGTERMs every process with a
317
+ * file open under the path, then `rm -rf` takes the directory, both twice.
318
+ * A `.delete` file containing {"worktreePath":"/Users/<me>"} would kill every
319
+ * process holding a file under home and recursively delete it. Board content
320
+ * is an injection surface (a spawned session can write into inbox/), so the
321
+ * path a request can name is fenced to where spawns actually live. Resolving
322
+ * an absolute `target` ignores `root`, which is exactly the escape we block;
323
+ * the trailing `sep` stops `.claude/worktrees-evil` passing as a sibling. */
324
+ export function isContainedWorktree(root, target) {
325
+ if (typeof target !== "string" || target.trim() === "") return false;
326
+ const base = resolve(root, ".claude", "worktrees");
327
+ const abs = resolve(root, target);
328
+ return abs !== base && (abs + sep).startsWith(base + sep) && staysInProject(root, abs);
329
+ }
330
+
331
+ /** A directory inside the tree folder is not necessarily this repository's
332
+ * worktree. Destructive operations require Git's exact registration too. */
333
+ async function registeredWorktree(root, target) {
334
+ if (!isContainedWorktree(root, target)) return false;
335
+ const listed = await git(["worktree", "list", "--porcelain", "-z"], root);
336
+ if (!listed.ok) return false;
337
+ try {
338
+ const canonical = canonicalPath(target);
339
+ return listed.stdout.split("\0").some((field) => {
340
+ if (!field.startsWith("worktree ")) return false;
341
+ try { return canonicalPath(field.slice(9)) === canonical; } catch { return false; }
342
+ });
343
+ } catch { return false; }
344
+ }
345
+
346
+ // ---------- THE CHAT WORKTREE (brief 25, ruling 1, 2026-09-07) ----------
347
+ //
348
+ // "One long-lived chat worktree per agent per folder,
349
+ // `<root>/.claude/worktrees/chat-<agentId>`, made once and kept." A chat must
350
+ // not mint a tree per MESSAGE - thirty messages would be thirty trees and
351
+ // thirty branches, and resume would break on every one of them because Claude
352
+ // Code keys its conversations to a working directory.
353
+ //
354
+ // So this half of the file is the opposite of the half above it. A session's
355
+ // tree is made per run and thrown away by cleanWorktree when the run left
356
+ // nothing in it; a chat tree is made ONCE, on the first message that finds it
357
+ // missing, and is removed only when the AGENT is deleted. Nothing here
358
+ // removes one, cleanWorktreeImpl refuses to (below), and the orphan reaper
359
+ // steps over the name (procs.mjs, reapableWorktreeName).
360
+
361
+ /** True when `target` is a chat worktree of this folder: inside the one
362
+ * directory makeWorktree ever creates AND named `chat-<agentId>`.
363
+ *
364
+ * Both halves matter. The containment fence is isContainedWorktree's, for its
365
+ * reason (a request file is an injection surface and the path it names must
366
+ * not be able to say "up"); the name is what separates an agent's kept tree
367
+ * from a session's disposable one. */
368
+ export function isChatWorktreePath(root, target) {
369
+ if (!isContainedWorktree(root, target)) return false;
370
+ return basename(resolve(root, target)).startsWith(CHAT_PREFIX);
371
+ }
372
+
373
+ /** The branch a chat worktree sits on: `glytch/chat-<agentId>`.
374
+ *
375
+ * Read OFF THE PATH rather than off a new request field, because the path
376
+ * already carries the agent id - the app builds `.claude/worktrees/chat-
377
+ * <agentId>` and that name is the only thing on either side of the contract
378
+ * that has to agree. `glytch/` is the prefix his 2026-08-28 ask put on every
379
+ * board-made branch so `git branch` correlates with the canvas, and a chat
380
+ * tree is board-made like any other. */
381
+ export const chatWorktreeBranch = (path) => `${GLYTCH}/${basename(path)}`;
382
+
383
+ /** WHERE A CHAT TURN RUNS, as an absolute path, or a refusal in words.
384
+ *
385
+ * The app writes `cwd` RELATIVE to the folder root and cannot do otherwise: a
386
+ * browser directory handle has a name, not a path (see the `cwd` note in
387
+ * src/folder/spawnRequest.ts), so it must not invent an absolute one. A cwd
388
+ * written by the CLI is absolute and is used as it stands. From the second
389
+ * turn on the app hands back the runner's own absolute `worktreePath` as the
390
+ * cwd, so only a first turn is ever relative.
391
+ *
392
+ * ANYTHING THAT ESCAPES THE FOLDER IS REFUSED, `..` and an absolute path
393
+ * outside it alike. A chat turn holds Write, Edit and Bash, and the folder is
394
+ * the boundary the person linked; a path outside it is either a mistake or
395
+ * somebody else's idea, and neither is worth guessing at. The root ITSELF is
396
+ * allowed, because a caller naming it is a caller who said so - the thing
397
+ * ruling 1 refuses is a silent FALLBACK to his checkout, which nothing here
398
+ * does. */
399
+ export function resolveChatCwd(root, cwd) {
400
+ const named = typeof cwd === "string" ? cwd.trim() : "";
401
+ if (named === "") {
402
+ return { ok: false, reason: "A chat turn must name cwd: the directory the conversation lives in." };
403
+ }
404
+ return resolveContainedCwd(root, named, "a chat conversation");
405
+ }
406
+
407
+ /** The same fence for any request that names a `cwd` (#324): an ordinary
408
+ * run holds Write, Edit and Bash too, so a cwd that resolves outside the
409
+ * linked folder, or through a symbolic link, is refused rather than run in.
410
+ * `what` names the thing in the refusal. */
411
+ export function resolveContainedCwd(root, cwd, what = "a run") {
412
+ const named = typeof cwd === "string" ? cwd.trim() : "";
413
+ const base = resolve(root);
414
+ const path = isAbsolute(named) ? resolve(named) : resolve(base, named);
415
+ if (named === "" || (path !== base && !(path + sep).startsWith(base + sep)) || !staysInProject(root, path)) {
416
+ return {
417
+ ok: false,
418
+ path,
419
+ reason:
420
+ `it resolves to ${path}, which is outside the folder this runner watches (${base}) or traverses a symbolic link. ` +
421
+ `A relative cwd is resolved against that folder and an absolute one is used as it stands; ` +
422
+ `either way ${what} runs inside the linked folder or it does not run.`,
423
+ };
424
+ }
425
+ return { ok: true, path };
426
+ }
427
+
428
+ /** Mint the chat worktree, once, for a turn that found it missing.
429
+ *
430
+ * `mintIfMissing: true` on the request is what asks for this, and it is asked
431
+ * for because THE APP CANNOT MAKE A WORKTREE. Ruling 1's directory has to
432
+ * exist before the first message can run in it, and the browser can only
433
+ * write a file. Session 1's refusal stands untouched for a request WITHOUT
434
+ * the flag: a missing directory with nothing asking for one is the
435
+ * conversation being gone, and falling back to the linked checkout would put
436
+ * a chat agent in the tree he is working in.
437
+ *
438
+ * Idempotent by construction: the caller only reaches this when the directory
439
+ * is not there, so a tree that already exists is used as it stands and a
440
+ * swept one comes back on the next message instead of failing it. The BRANCH
441
+ * may outlive the directory (someone `rm -rf`'d the tree, or `git worktree
442
+ * remove` left the branch), so an existing branch is checked out into the new
443
+ * tree rather than re-created - a `-b` onto a name git already knows fails,
444
+ * and failing here means a message he typed does not run.
445
+ *
446
+ * Returns `{ ok: true, path, branch, base, detail }`, or `{ ok: false,
447
+ * reason }` in words a person reads in the transcript. It never falls back
448
+ * the way makeWorktreeImpl does, and that is the difference between the two:
449
+ * a session that could not get a tree still has somewhere to run, and a chat
450
+ * turn's tree IS which conversation this is. */
451
+ async function makeChatWorktreeImpl(root, path, log = () => {}) {
452
+ // A CHAT NEEDS A GIT REPO FOR ITS OWN WORKTREE. There is no unisolated
453
+ // fallback to offer: ruling 1 gives the agent a tree so it can edit and
454
+ // commit without touching his checkout, and a folder that is not a
455
+ // repository cannot give it one.
456
+ const inRepo = await git(["rev-parse", "--is-inside-work-tree"], root);
457
+ if (!inRepo.ok || inRepo.stdout.trim() !== "true") {
458
+ return {
459
+ ok: false,
460
+ reason:
461
+ `${root} is not a git repository, and a chat with a coding agent needs one: the agent ` +
462
+ `gets its OWN long-lived worktree (${path}) so it can read, edit and commit without ` +
463
+ `touching the checkout you are working in, and a worktree can only be cut from a repo. ` +
464
+ `Link a folder that is a git checkout, or run \`git init\` in this one, and send the ` +
465
+ `message again.`,
466
+ };
467
+ }
468
+ // The one place a chat tree may be made, so an injected request file cannot
469
+ // turn "mint if missing" into "create a directory anywhere in my folder".
470
+ if (!isChatWorktreePath(root, path)) {
471
+ return {
472
+ ok: false,
473
+ reason:
474
+ `${path} is not a chat worktree path. The runner mints one only at ` +
475
+ `<folder>/.claude/worktrees/chat-<agentId> (his ruling 1: one long-lived chat worktree ` +
476
+ `per agent per folder), so nothing was created and nothing was spawned.`,
477
+ };
478
+ }
479
+
480
+ await ensureIgnored(root, log);
481
+
482
+ const branch = chatWorktreeBranch(path);
483
+ // THE SAME BASE makeWorktreeImpl PICKS, and for its reason: the repo's
484
+ // default branch (main, else master), not whatever the main checkout
485
+ // happens to have checked out - a chat tree cut from a side branch someone
486
+ // left checked out would carry that branch's commits forever. Falls back to
487
+ // HEAD only when neither local branch exists.
488
+ const defaultBranch = await defaultBranchOf(root);
489
+ const baseRev = await git(["rev-parse", defaultBranch ?? "HEAD"], root);
490
+ const base = baseRev.ok ? baseRev.stdout.trim() : (defaultBranch ?? "HEAD");
491
+ const baseLabel = defaultBranch
492
+ ? `${defaultBranch} at ${base.slice(0, 7)}`
493
+ : `HEAD at ${base.slice(0, 7)}, no main or master branch`;
494
+ const known = await git(["rev-parse", "--verify", branch], root);
495
+ const add = known.ok
496
+ ? await git(["worktree", "add", path, branch], root)
497
+ : await git(["worktree", "add", "-b", branch, path, base], root);
498
+ if (!add.ok) {
499
+ return {
500
+ ok: false,
501
+ reason:
502
+ `The chat worktree ${path} could not be created, so this message did not run and ` +
503
+ `nothing was billed.\ngit said: ${add.detail}`,
504
+ };
505
+ }
506
+
507
+ // Same link a session's tree gets, for the same reason: an agent that
508
+ // cannot run the project's own tests cannot check what it just wrote.
509
+ const modules = join(root, "node_modules");
510
+ if (await exists(modules)) {
511
+ try {
512
+ await symlink(modules, join(path, "node_modules"), nodeModulesLinkType());
513
+ } catch (error) {
514
+ log(`chat worktree ${branch}: node_modules could not be linked: ${error?.message ?? error}`);
515
+ }
516
+ }
517
+
518
+ return {
519
+ ok: true,
520
+ path,
521
+ branch,
522
+ base,
523
+ detail: known.ok
524
+ ? `Minted the chat worktree ${path} on the existing branch ${branch}.`
525
+ : `Minted the chat worktree ${path} on branch ${branch} off ${baseLabel}.`,
526
+ };
527
+ }
528
+
529
+ /** Make the worktree a session will run in.
530
+ *
531
+ * Returns `{ cwd, isolated, branch?, path?, detail }`. Never throws and never
532
+ * refuses to run: if this is not a git repo, or git fails for any reason, the
533
+ * session still runs in `root` and `detail` says why it was not isolated. A
534
+ * runner that would rather spawn nothing than spawn unisolated is a runner
535
+ * that stops working the day someone tries it outside a checkout, and the
536
+ * money gate - not this - is what decides whether anything runs at all. */
537
+ async function makeWorktreeImpl(root, id, log = () => {}, title = "") {
538
+ const inRepo = await git(["rev-parse", "--is-inside-work-tree"], root);
539
+ if (!inRepo.ok || inRepo.stdout.trim() !== "true") {
540
+ return {
541
+ cwd: root,
542
+ isolated: false,
543
+ detail:
544
+ `Not a git checkout, so this session ran in the linked folder itself ` +
545
+ `rather than an isolated worktree. Anything it changed, it changed there.`,
546
+ };
547
+ }
548
+
549
+ await ensureIgnored(root, log);
550
+
551
+ const path = worktreePath(root, id, title);
552
+ if (!isContainedWorktree(root, path)) throw new Error("Worktree creation refused: the path leaves this project or traverses a symbolic link.");
553
+ const branch = worktreeBranch(id, title);
554
+ // Branch off the repo's DEFAULT BRANCH (main, else master), not whatever
555
+ // the main checkout happens to have checked out. PROVEN 2026-09-21: basing
556
+ // on HEAD meant a run cut while the checkout sat on a side branch inherited
557
+ // that branch's commits, and "Keep changes" would have dragged the whole
558
+ // thing into main. Falls back to HEAD, and says so, only when neither local
559
+ // branch exists.
560
+ const defaultBranch = await defaultBranchOf(root);
561
+ const baseRev = await git(["rev-parse", defaultBranch ?? "HEAD"], root);
562
+ const base = baseRev.ok ? baseRev.stdout.trim() : (defaultBranch ?? "HEAD");
563
+ const baseLabel = defaultBranch
564
+ ? `${defaultBranch} at ${base.slice(0, 7)}`
565
+ : `HEAD at ${base.slice(0, 7)}, no main or master branch`;
566
+
567
+ const add = await git(["worktree", "add", "-b", branch, path, base], root);
568
+ if (!add.ok) {
569
+ return {
570
+ cwd: root,
571
+ isolated: false,
572
+ detail:
573
+ `Could not create a worktree, so this session ran in the linked folder ` +
574
+ `itself and anything it changed, it changed there.\ngit said: ${add.detail}`,
575
+ };
576
+ }
577
+
578
+ // Without this the session cannot run the project's own tests, which is
579
+ // most of what a brief asks it to do before reporting.
580
+ const modules = join(root, "node_modules");
581
+ if (await exists(modules)) {
582
+ try {
583
+ await symlink(modules, join(path, "node_modules"), nodeModulesLinkType());
584
+ } catch (error) {
585
+ log(`[${id}] node_modules could not be linked: ${error?.message ?? error}`);
586
+ }
587
+ }
588
+
589
+ return {
590
+ cwd: path,
591
+ isolated: true,
592
+ branch,
593
+ path,
594
+ detail: `Ran in its own worktree, ${path}, on branch ${branch} off ${baseLabel}.`,
595
+ };
596
+ }
597
+
598
+ /** Take the worktree away IF the session left nothing in it.
599
+ *
600
+ * An untouched tree is litter and goes. A tree with commits or uncommitted
601
+ * changes STAYS, because that is the session's work and the only copy of it -
602
+ * removing it would throw away the thing the run was for. The receipt says
603
+ * which happened, so a kept tree is never a surprise found weeks later. */
604
+ async function cleanWorktreeImpl(root, made, log = () => {}, wroteFiles = []) {
605
+ // A CHAT WORKTREE IS NEVER CLEANED (brief 25, ruling 1, 2026-09-07:
606
+ // one long-lived chat worktree per agent per folder, kept). It is not this
607
+ // run's tree - it was there before the message and is what the NEXT message
608
+ // resumes in, and Claude Code keeps its conversations per working directory,
609
+ // so removing it because one turn happened to leave it tidy would throw the
610
+ // whole conversation away. runner.mjs does not call this for a chat turn at
611
+ // all; this is the guard that says so where the removal is, so a future
612
+ // caller cannot reach it by accident.
613
+ if (isChatWorktreePath(root, made?.path ?? "")) {
614
+ return {
615
+ removed: false,
616
+ detail: `${made?.detail ?? ""}\nKEPT: a chat worktree is the agent's own and outlives every turn that runs in it.`,
617
+ };
618
+ }
619
+ if (!made?.isolated || !made.path) return { removed: false, detail: made?.detail ?? "" };
620
+ if (!(await registeredWorktree(root, made.path))) return { removed: false, detail: "KEPT: worktree ownership could not be verified." };
621
+
622
+ const dirty = await git(["status", "--porcelain"], made.path);
623
+ const base = await git(["merge-base", made.branch, "HEAD"], root);
624
+ const commits = base.ok && base.stdout.trim()
625
+ ? await git(["log", "--oneline", `${base.stdout.trim()}..HEAD`], made.path)
626
+ : { ok: false, detail: base.detail || "Could not establish the worktree base." };
627
+ // Failed inspection is not evidence that this tree contains no work.
628
+ if (!dirty.ok || !commits.ok) {
629
+ const detail = `KEPT: could not verify worktree changes: ${!dirty.ok ? dirty.detail : commits.detail}`;
630
+ log(`[${made.branch}] ${detail}`);
631
+ return { removed: false, detail: `${made.detail}\n${detail}` };
632
+ }
633
+ // WHAT THE RUNNER WROTE IS NOT THE SESSION'S WORK. A config dropped in so
634
+ // the engine can reach the board (opencode.json) must not make the tree look
635
+ // dirty, or no opencode worktree would ever be cleaned up.
636
+ //
637
+ // Filtered here rather than excluded in git, and that distinction cost an
638
+ // hour: worktrees SHARE .git/info/exclude with the main checkout, so a
639
+ // "per-worktree" exclude is nothing of the sort - it silently made
640
+ // opencode.json un-trackable across the whole repository, which only
641
+ // surfaced when checking the file in failed. Deciding it at the one place
642
+ // that asks the question touches no shared git config and cannot leak.
643
+ const theirs = dirty.stdout
644
+ .split("\n")
645
+ .map((l) => l.trim())
646
+ .filter((l) => l !== "")
647
+ .filter((l) => !wroteFiles.some((f) => l.endsWith(` ${f}`) || l.endsWith(`/${f}`)));
648
+ const changed = (theirs.length > 0 || commits.stdout.trim() !== "");
649
+
650
+ if (changed) {
651
+ const n = commits.stdout.trim() === "" ? 0 : commits.stdout.trim().split("\n").length;
652
+ return {
653
+ removed: false,
654
+ detail:
655
+ `${made.detail}\nKEPT, because the session left work in it: ` +
656
+ `${n} commit${n === 1 ? "" : "s"}` +
657
+ `${theirs.length > 0 ? " and uncommitted changes" : ""}. ` +
658
+ `Review it there; nothing was merged anywhere.`,
659
+ };
660
+ }
661
+
662
+ const removed = await git(["worktree", "remove", "--force", made.path], root);
663
+ if (!removed.ok) {
664
+ log(`[${made.branch}] worktree could not be removed: ${removed.detail}`);
665
+ return { removed: false, detail: `${made.detail}\nIt is still on disk: ${removed.detail}` };
666
+ }
667
+ await git(["branch", "-D", made.branch], root);
668
+ return { removed: true, detail: `${made.detail}\nIt changed nothing, so the worktree was removed.` };
669
+ }
670
+
671
+ /** Keep spawn worktrees and preview records out of `git status`, without touching a tracked file.
672
+ *
673
+ * They live inside the repo on purpose - every worktree in this project is
674
+ * under `.claude/worktrees/`, and a session's tree should be where he already
675
+ * looks for one rather than in a temp directory he has to be told about. The
676
+ * cost is that git sees them.
677
+ *
678
+ * This clone happens to ignore them already, but through `.git/info/exclude`,
679
+ * which is LOCAL and not checked in - so on a fresh clone, or on anyone
680
+ * else's machine, the first spawn would start dirtying `git status` with its
681
+ * own worktrees. Depending on a file that only exists here is the kind of
682
+ * thing that works right up until it is someone else's problem.
683
+ *
684
+ * `.git/info/exclude` and not `.gitignore`: this is the runner's housekeeping,
685
+ * not a decision about the project, and a detachable sidecar has no business
686
+ * editing a tracked file to make itself comfortable. */
687
+ async function ensureIgnored(root, log = () => {}) {
688
+ const missing = [];
689
+ for (const [file, pattern] of [[".claude/worktrees", ".claude/worktrees/"], [".viewable-server.json", "/.viewable-server.json"], [".viewable-page.json", "/.viewable-page.json"], [".glytch-server.json", "/.glytch-server.json"], [".glytch-page.json", "/.glytch-page.json"]]) {
690
+ const ignored = await git(["check-ignore", "-q", file], root);
691
+ if (!ignored.ok) missing.push(pattern);
692
+ }
693
+ if (missing.length === 0) return;
694
+ const dir = await git(["rev-parse", "--git-common-dir"], root);
695
+ if (!dir.ok) return;
696
+ const base = dir.stdout.trim();
697
+ // `git rev-parse --git-common-dir` prints an absolute path or a path
698
+ // relative to `root`. `base.startsWith("/")` only recognized the POSIX
699
+ // shape; a Windows absolute path ("C:\Users\..." or "C:/Users/...") never
700
+ // starts with "/", so it was joined onto `root` and wrote the exclude file
701
+ // to a garbage path. `isAbsolute` is platform-bound and gets both shapes
702
+ // right, including on the machine this actually runs on.
703
+ const path = join(isAbsolute(base) ? base : join(root, base), "info", "exclude");
704
+ try {
705
+ let current = "";
706
+ try {
707
+ current = await readFile(path, "utf8");
708
+ } catch {
709
+ /* no exclude file yet, which is normal */
710
+ }
711
+ const additions = missing.filter((pattern) => !current.split(/\r?\n/).includes(pattern));
712
+ if (additions.length === 0) return;
713
+ const sep = current === "" || current.endsWith("\n") ? "" : "\n";
714
+ await writeFile(
715
+ path,
716
+ `${current}${sep}# spawn runner files (bridge/lib/worktree.mjs), local only\n${additions.join("\n")}\n`,
717
+ "utf8",
718
+ );
719
+ } catch (error) {
720
+ // Not fatal: an unignored worktree is untidy, never unsafe.
721
+ log(`could not add runner files to the local exclude: ${error?.message ?? error}`);
722
+ }
723
+ }
724
+
725
+ /** Reuse an existing worktree for a resumed session.
726
+ *
727
+ * Returns the same shape as makeWorktree, but checks the tree still exists on
728
+ * disk and that git still knows about it. A worktree that was manually removed
729
+ * or pruned falls back to null, which tells the caller to make a fresh one. */
730
+ async function reuseWorktreeImpl(root, existingPath, log = () => {}) {
731
+ // Same fence as merge/delete, and for a sharper reason: this path becomes the
732
+ // session's cwd and is reported as `isolated: true`. A resume request naming
733
+ // the linked checkout itself (a real git work tree, so every check below
734
+ // passes) would put an acceptEdits session on his own branch while the
735
+ // receipt swore it was isolated. Refusing here falls back to a fresh tree.
736
+ if (!isContainedWorktree(root, existingPath)) {
737
+ log(`reuse: refusing ${existingPath}, outside .claude/worktrees/`);
738
+ return null;
739
+ }
740
+ if (!existingPath || !(await exists(existingPath))) {
741
+ log(`reuse: path ${existingPath} does not exist on disk`);
742
+ return null;
743
+ }
744
+ if (!(await registeredWorktree(root, existingPath))) return null;
745
+ // Check git still knows this worktree.
746
+ const check = await git(["rev-parse", "--is-inside-work-tree"], existingPath);
747
+ if (!check.ok || check.stdout.trim() !== "true") {
748
+ log(`reuse: ${existingPath} is no longer a git worktree`);
749
+ return null;
750
+ }
751
+ // Find the branch name.
752
+ const branch = await git(["rev-parse", "--abbrev-ref", "HEAD"], existingPath);
753
+ const branchName = branch.ok ? branch.stdout.trim() : "unknown";
754
+ return {
755
+ cwd: existingPath,
756
+ isolated: true,
757
+ branch: branchName,
758
+ path: existingPath,
759
+ reused: true,
760
+ detail: `Reused existing worktree at ${existingPath} on branch ${branchName}.`,
761
+ };
762
+ }
763
+
764
+ /** The commit the worktree started from, for counting what the session added. */
765
+ async function baseOf(made, root) {
766
+ const merge = await git(["merge-base", made.branch, "HEAD"], root);
767
+ return merge.ok && merge.stdout.trim() ? merge.stdout.trim() : "HEAD";
768
+ }
769
+
770
+ // ---------- post-session worktree operations ----------
771
+ //
772
+ // Called by the runner when the app writes a merge or delete signal file.
773
+ // Git operations stay here, in the sidecar process, never in the browser.
774
+
775
+ /** Derive the branch name from a worktree path, if git knows it. */
776
+ export async function branchOfWorktree(worktreePath) {
777
+ if (!worktreePath) return null;
778
+ const branch = await git(["rev-parse", "--abbrev-ref", "HEAD"], worktreePath);
779
+ return branch.ok ? branch.stdout.trim() : null;
780
+ }
781
+
782
+ /** The identity a commit falls back to when the folder has none configured:
783
+ * `git commit` and `commit-tree` refuse outright without one, and a Keep
784
+ * that fails on "Please tell me who you are" is a Keep nobody can press. */
785
+ async function identityArgs(cwd) {
786
+ const email = await git(["config", "user.email"], cwd);
787
+ const name = await git(["config", "user.name"], cwd);
788
+ return [
789
+ ...(name.ok && name.stdout.trim() ? [] : ["-c", "user.name=Viewable"]),
790
+ ...(email.ok && email.stdout.trim() ? [] : ["-c", "user.email=viewable@localhost"]),
791
+ ];
792
+ }
793
+
794
+ /** NUL-separated git output as a list, empties dropped. */
795
+ const zList = (stdout) => stdout.split("\0").filter((p) => p !== "");
796
+
797
+ /** What `git merge-tree --write-tree --name-only` says: the tree it would
798
+ * write, or the files that conflict. Exit 0 is clean and exit 1 is a
799
+ * conflict; anything else means this git cannot answer (merge-tree's
800
+ * --write-tree is git 2.38), which is a refusal, never a guess. */
801
+ async function trialMerge(cwd, ours, theirs) {
802
+ const out = await git(["merge-tree", "--write-tree", "--name-only", ours, theirs], cwd);
803
+ const lines = out.stdout.split("\n");
804
+ if (out.code === 0) return { clean: true, tree: lines[0].trim() };
805
+ if (out.code === 1) {
806
+ const end = lines.indexOf("", 1);
807
+ return { clean: false, files: lines.slice(1, end === -1 ? undefined : end).filter((l) => l !== "") };
808
+ }
809
+ return { error: out.detail || "git merge-tree failed" };
810
+ }
811
+
812
+ /** The paths a merge adds that already have something of the person's on
813
+ * disk: an untracked file, an ignored one, or a file where the merge needs
814
+ * a folder. It reads the disk, not `ls-files --exclude-standard`: git's
815
+ * fast-forward overwrites an ignored file without a word, so an ignored
816
+ * `.env.local` the agent also committed would be lost. A parent the merge
817
+ * itself deletes (`removed`) is the merge's own and is not in the way. */
818
+ async function inTheWay(root, added, removed) {
819
+ const hit = [];
820
+ for (const path of added) {
821
+ const parts = path.split("/");
822
+ for (let i = 1; i <= parts.length; i++) {
823
+ const at = parts.slice(0, i).join("/");
824
+ if (i < parts.length && removed.has(at)) break;
825
+ const found = await lstat(join(root, at)).catch(() => null);
826
+ if (!found) break;
827
+ if (i === parts.length || !found.isDirectory()) {
828
+ hit.push(path);
829
+ break;
830
+ }
831
+ }
832
+ }
833
+ return hit;
834
+ }
835
+
836
+ /** Keep changes (#483): one gesture that lands a session's copy in the
837
+ * folder and clears the copy away.
838
+ *
839
+ * 1. Commit what the agent left uncommitted in the copy.
840
+ * 2. Work out the merge into the default branch WITHOUT touching the
841
+ * folder (`merge-tree`), and, when the folder is on that branch with
842
+ * changes of the person's own, whether those changes still sit on top
843
+ * of the result. Either one conflicting stops here: nothing in the
844
+ * folder has moved, and the answer names the files (`conflict: true`)
845
+ * so the app can send Coding back to fit its changes around them.
846
+ * 3. Land it. On the default branch: set the person's changes aside (a
847
+ * stash with its own name, tracked files only; untracked files are
848
+ * never touched), fast-forward to the merge commit, put the changes
849
+ * back. On any other branch: move the default branch's ref only, so
850
+ * the folder does not change at all.
851
+ * 4. Delete the copy and its branch.
852
+ *
853
+ * Nothing is pushed: the person pushes when they want to. Returns
854
+ * `{ ok, detail, files?, conflict? }`; a success detail starts "Merged " (the
855
+ * app's `keptOf` reads that). */
856
+ async function mergeWorktreeImpl(root, worktreePath, log = () => {}) {
857
+ if (!isContainedWorktree(root, worktreePath)) {
858
+ log(`refusing to merge a worktree outside .claude/worktrees/: ${worktreePath}`);
859
+ return { ok: false, detail: `Refusing to act on ${worktreePath}: not inside this project's .claude/worktrees/.` };
860
+ }
861
+ if (!(await registeredWorktree(root, worktreePath))) return { ok: false, detail: "Refusing: worktree ownership could not be verified." };
862
+ const branch = await branchOfWorktree(worktreePath);
863
+ if (!branch) {
864
+ return { ok: false, detail: `Could not determine branch for worktree at ${worktreePath}.` };
865
+ }
866
+ // SAME HELPER the worktree's own base used: a `master` repo merges into
867
+ // `master`, not a literal `main` that repo may not even have.
868
+ const defaultBranch = await defaultBranchOf(root);
869
+ if (!defaultBranch) {
870
+ return { ok: false, detail: `Could not find a local main or master branch to merge ${branch} into.` };
871
+ }
872
+ if (branch === defaultBranch) {
873
+ return { ok: false, detail: `Refusing to merge ${branch} into itself.` };
874
+ }
875
+ const folder = basename(root);
876
+ const ident = await identityArgs(root);
877
+
878
+ // 1. THE AGENT'S LEFTOVERS. Exclude the machine-specific preview record
879
+ // even if local exclude setup failed, along with the node_modules link the
880
+ // runner put there: `node_modules/` in a .gitignore matches a directory,
881
+ // not a symlink, so `add -A` alone would commit the link. The preview
882
+ // path uses a glob so git does not reject an already-ignored literal path.
883
+ // Older sessions may already have staged or committed the record. Remove
884
+ // only its index entry, even if the disk copy changed since staging; leave
885
+ // the live preview file intact. Do this before status so a clean tracked
886
+ // record produces a deletion commit too.
887
+ const untrack = await git(["rm", "--cached", "--force", "--ignore-unmatch", "--", ".viewable-server.json", ".viewable-page.json", ".glytch-server.json", ".glytch-page.json"], worktreePath);
888
+ if (!untrack.ok) return { ok: false, detail: `Could not commit what the session left: ${untrack.detail}` };
889
+ const leftovers = await git(["status", "--porcelain"], worktreePath);
890
+ if (!leftovers.ok) return { ok: false, detail: `Could not read the copy: ${leftovers.detail}` };
891
+ if (leftovers.stdout.trim() !== "") {
892
+ const add = await git(["add", "-A", "--", ".", ":(exclude)node_modules", ":(top,exclude,glob)[.]viewable-server.json", ":(top,exclude,glob)[.]viewable-page.json", ":(top,exclude,glob)[.]glytch-server.json", ":(top,exclude,glob)[.]glytch-page.json"], worktreePath);
893
+ const staged = add.ok ? await git(["diff", "--cached", "--quiet"], worktreePath) : add;
894
+ if (!add.ok) return { ok: false, detail: `Could not commit what the session left: ${add.detail}` };
895
+ if (staged.code === 1) {
896
+ const commit = await git([...ident, "commit", "-m", "What the session left uncommitted"], worktreePath);
897
+ if (!commit.ok) return { ok: false, detail: `Could not commit what the session left: ${commit.detail}` };
898
+ }
899
+ }
900
+
901
+ // 2. THE TRIAL, read-only. Every object it writes is unreferenced until
902
+ // step 3 points a ref at it.
903
+ const head = await git(["rev-parse", `refs/heads/${defaultBranch}`], root);
904
+ const tip = await git(["rev-parse", `refs/heads/${branch}`], root);
905
+ if (!head.ok || !tip.ok) return { ok: false, detail: `Could not read ${defaultBranch} or ${branch}: ${head.detail || tip.detail}` };
906
+ const base = head.stdout.trim();
907
+ const trial = await trialMerge(root, base, tip.stdout.trim());
908
+ if (trial.error) return { ok: false, detail: `Keep needs git 2.38 or newer to check for a conflict first. git said: ${trial.error}` };
909
+ const stopped = (files) => ({
910
+ ok: false,
911
+ conflict: true,
912
+ files,
913
+ detail: `Keep stopped: ${folder} changed while Coding worked${files.length ? ` (${files.join(", ")})` : ""}. Nothing in ${folder} was changed.`,
914
+ });
915
+ if (!trial.clean) return stopped(trial.files);
916
+ const made = await git(
917
+ [...ident, "commit-tree", trial.tree, "-p", base, "-p", tip.stdout.trim(), "-m", `Merge branch '${branch}'`],
918
+ root,
919
+ );
920
+ if (!made.ok) return { ok: false, detail: `Could not make the merge commit: ${made.detail}` };
921
+ const merged = made.stdout.trim();
922
+ const changed = await git(["diff", "--name-only", "-z", base, merged], root);
923
+ const files = changed.ok ? zList(changed.stdout).length : 0;
924
+
925
+ const current = await git(["symbolic-ref", "--quiet", "--short", "HEAD"], root);
926
+ const onDefault = current.ok && current.stdout.trim() === defaultBranch;
927
+
928
+ if (onDefault) {
929
+ // The person's untracked and ignored files stay where they are; one the
930
+ // merge would add under the same name is a conflict, not an overwrite.
931
+ // `--no-renames` so a rename or copy counts as its destination added
932
+ // (and a rename's source removed), whatever the folder's diff.renames.
933
+ const added = await git(["diff", "--no-renames", "--name-only", "-z", "--diff-filter=A", base, merged], root);
934
+ const removed = await git(["diff", "--no-renames", "--name-only", "-z", "--diff-filter=D", base, merged], root);
935
+ if (!added.ok || !removed.ok) return { ok: false, detail: `Could not read ${folder}: ${added.detail || removed.detail}` };
936
+ const clash = await inTheWay(root, zList(added.stdout), new Set(zList(removed.stdout)));
937
+ if (clash.length) return stopped(clash);
938
+ // Their tracked changes as a commit nothing points at (`stash create`
939
+ // touches neither the folder nor the stash list), tried on the result.
940
+ const pending = await git(["stash", "create"], root);
941
+ if (!pending.ok) return { ok: false, detail: `Could not read ${folder}: ${pending.detail}` };
942
+ if (pending.stdout.trim()) {
943
+ const fits = await trialMerge(root, merged, pending.stdout.trim());
944
+ if (fits.error) return { ok: false, detail: `Could not check your changes against the merge: ${fits.error}` };
945
+ if (!fits.clean) return stopped(fits.files);
946
+ }
947
+ } else {
948
+ // Another branch (or none) is checked out: the folder is never touched,
949
+ // so the only thing to refuse is a default branch checked out in some
950
+ // other worktree, whose files would fall out of step with its ref.
951
+ const trees = await git(["worktree", "list", "--porcelain"], root);
952
+ if (trees.ok && trees.stdout.split("\n").includes(`branch refs/heads/${defaultBranch}`)) {
953
+ return { ok: false, detail: `${defaultBranch} is open in another copy of this folder, so Keep left it alone.` };
954
+ }
955
+ }
956
+
957
+ // 3. LAND IT. The copy's processes go first: its dev server holds files
958
+ // open and `worktree remove` fails on them.
959
+ await killProcessesIn(worktreePath, log);
960
+ if (onDefault) {
961
+ const landed = await landOnCheckout(root, base, merged, log);
962
+ if (!landed.ok) return landed.conflict ? stopped(landed.files ?? []) : landed;
963
+ } else {
964
+ // Compare-and-swap: a default branch that moved since the trial fails here.
965
+ const moved = await git(["update-ref", `refs/heads/${defaultBranch}`, merged, base], root);
966
+ if (!moved.ok) return stopped([]);
967
+ }
968
+
969
+ // 4. THE COPY GOES. The merge is in; a copy that will not go is said, not hidden.
970
+ const removed = await git(["worktree", "remove", "--force", worktreePath], root);
971
+ const dropped = removed.ok ? await git(["branch", "-D", branch], root) : removed;
972
+ const tail = removed.ok
973
+ ? dropped.ok ? " The copy and its branch are gone." : ` The copy is gone; branch ${branch} was kept: ${dropped.detail}`
974
+ : ` The copy was kept: ${removed.detail}`;
975
+ log(`kept ${branch} in ${defaultBranch}: ${files} file(s)`);
976
+ return {
977
+ ok: true,
978
+ files,
979
+ detail: `Merged ${branch} into ${defaultBranch}, ${files} file${files === 1 ? "" : "s"} changed.${tail}`,
980
+ };
981
+ }
982
+
983
+ /** The folder is on the default branch: set the person's tracked changes
984
+ * aside under a name of our own, fast-forward to the merge, put them back.
985
+ * Any step that fails puts everything back as it was. */
986
+ async function landOnCheckout(root, base, merged, log) {
987
+ const tag = `viewable keep ${Date.now()}`;
988
+ const dirty = await git(["status", "--porcelain", "--untracked-files=no"], root);
989
+ let saved = null;
990
+ if (dirty.ok && dirty.stdout.trim() !== "") {
991
+ const push = await git(["stash", "push", "-m", tag], root);
992
+ if (!push.ok) return { ok: false, detail: `Could not set your changes aside: ${push.detail}` };
993
+ const list = await git(["stash", "list", "--format=%H%x09%gs"], root);
994
+ saved = list.stdout.split("\n").map((l) => l.split("\t")).find(([, msg]) => msg?.endsWith(tag))?.[0] ?? null;
995
+ if (!saved) return { ok: false, detail: `Your changes were set aside as "${tag}" in git stash and could not be found again. Nothing was merged.` };
996
+ }
997
+ const restore = async () => {
998
+ if (!saved) return true;
999
+ const back = await git(["stash", "apply", "--index", saved], root);
1000
+ const ok = back.ok || (await git(["stash", "apply", saved], root)).ok;
1001
+ if (ok) await dropStash(root, saved);
1002
+ else log(`could not put the person's changes back; they are in git stash as "${tag}"`);
1003
+ return ok;
1004
+ };
1005
+ const ff = await git(["merge", "--ff-only", merged], root);
1006
+ if (!ff.ok) {
1007
+ await restore();
1008
+ return { ok: false, conflict: true, files: [] };
1009
+ }
1010
+ if (!(await restore())) {
1011
+ // The trial said this fits; the folder changed between the trial and now.
1012
+ const unmerged = await git(["diff", "--name-only", "-z", "--diff-filter=U"], root);
1013
+ await git(["reset", "--hard", base], root);
1014
+ const again = await restore();
1015
+ return again
1016
+ ? { ok: false, conflict: true, files: unmerged.ok ? zList(unmerged.stdout) : [] }
1017
+ : { ok: false, detail: `Your changes are safe in git stash as "${tag}"; Keep could not put them back by itself.` };
1018
+ }
1019
+ return { ok: true };
1020
+ }
1021
+
1022
+ /** Drop one stash entry by its commit, re-finding its index first: other
1023
+ * stashes may have been pushed above it. */
1024
+ async function dropStash(root, sha) {
1025
+ const list = await git(["stash", "list", "--format=%H"], root);
1026
+ const at = list.stdout.split("\n").indexOf(sha);
1027
+ if (at >= 0) await git(["stash", "drop", `stash@{${at}}`], root);
1028
+ }
1029
+
1030
+ /** Throw away (#483): delete a session's copy and its branch without
1031
+ * merging. The run's history, plan and session stay readable on the board. */
1032
+ async function deleteWorktreeImpl(root, worktreePath, log = () => {}) {
1033
+ if (!isContainedWorktree(root, worktreePath)) {
1034
+ log(`refusing to delete a worktree outside .claude/worktrees/: ${worktreePath}`);
1035
+ return { ok: false, detail: `Refusing to act on ${worktreePath}: not inside this project's .claude/worktrees/.` };
1036
+ }
1037
+ if (!(await registeredWorktree(root, worktreePath))) {
1038
+ return { ok: false, detail: "Refusing: worktree ownership could not be verified." };
1039
+ }
1040
+ const branch = await branchOfWorktree(worktreePath);
1041
+ await killProcessesIn(worktreePath, log);
1042
+ // Recheck after asynchronous process cleanup. A failed Git removal never
1043
+ // authorizes a recursive filesystem deletion.
1044
+ if (!(await registeredWorktree(root, worktreePath))) return { ok: false, detail: "Refusing: worktree ownership changed." };
1045
+ const remove = await git(["worktree", "remove", "--force", worktreePath], root);
1046
+ if (!remove.ok) return { ok: false, detail: `Worktree was kept: ${remove.detail}` };
1047
+ if (branch && branch !== "main" && branch !== "master") {
1048
+ const deleted = await git(["branch", "-D", branch], root);
1049
+ if (!deleted.ok) return { ok: false, detail: `Worktree removed; branch ${branch} was kept: ${deleted.detail}` };
1050
+ }
1051
+ return { ok: true, detail: `Deleted worktree at ${worktreePath}${branch ? ` and branch ${branch}` : ""}.` };
1052
+ }
1053
+
1054
+ /** One summary line for a worktree: branch, commit count, files touched
1055
+ * (committed or not, #482). The ruling on q-mtf66we4: "One summary line: branch, commits, files touched."
1056
+ * Written by the runner into the sidecar so the app can show it on the run's
1057
+ * card before a merge (DATED REWRITE 2026-09-07, brief 26 ruling 4: was "in
1058
+ * the frame"). Returns null when the worktree is not a git checkout. */
1059
+ export async function worktreeSummary(worktreePath, root) {
1060
+ if (!worktreePath) return null;
1061
+ const branch = await branchOfWorktree(worktreePath);
1062
+ if (!branch) return null;
1063
+ const base = await baseOf({ branch }, root);
1064
+ const log = await git(["log", "--oneline", `${base}..HEAD`], worktreePath);
1065
+ const commits = log.ok && log.stdout.trim() !== "" ? log.stdout.trim().split("\n").length : 0;
1066
+ const { files: touched, uncommitted } = await changedPaths(worktreePath, base);
1067
+ const files = touched.length;
1068
+ return {
1069
+ branch, commits, files, uncommitted: uncommitted.length,
1070
+ line: `${branch}: ${commits} commit${commits === 1 ? "" : "s"}, ${files} file${files === 1 ? "" : "s"} touched`,
1071
+ };
1072
+ }
1073
+
1074
+ /** Every path the session changed in its copy, committed or not (#482). A
1075
+ * session stopped before it committed still changed files: they count, so
1076
+ * the ending offers Keep changes, and the merge commits them. */
1077
+ async function changedPaths(worktreePath, base) {
1078
+ const diff = await git(["diff", "--name-only", `${base}..HEAD`], worktreePath);
1079
+ const committed = diff.ok ? diff.stdout.trim().split("\n").filter((l) => l !== "") : [];
1080
+ const uncommitted = await uncommittedFiles(worktreePath);
1081
+ return { files: [...new Set([...committed, ...uncommitted])], uncommitted };
1082
+ }
1083
+
1084
+ /** The paths a run changed in its copy, for the page pictures (#2194). Empty
1085
+ * when the path is not a runner copy. */
1086
+ export async function changedFiles(worktreePath, root) {
1087
+ if (!worktreePath) return [];
1088
+ const branch = await branchOfWorktree(worktreePath);
1089
+ if (!branch) return [];
1090
+ const base = await baseOf({ branch }, root);
1091
+ return (await changedPaths(worktreePath, base)).files;
1092
+ }
1093
+
1094
+ /** The session's changes not yet committed in its copy, as paths. The
1095
+ * node_modules link the runner made (makeWorktree) is not the session's. */
1096
+ async function uncommittedFiles(worktreePath) {
1097
+ const status = await git(["status", "--porcelain", "-z", "--untracked-files=all"], worktreePath);
1098
+ if (!status.ok) return [];
1099
+ const parts = status.stdout.split("\0");
1100
+ const paths = [];
1101
+ for (let i = 0; i < parts.length; i++) {
1102
+ const entry = parts[i];
1103
+ if (entry.length < 4) continue;
1104
+ paths.push(entry.slice(3));
1105
+ // A rename or copy is followed by its old path, which is not a second file.
1106
+ if (entry[0] === "R" || entry[0] === "C") i++;
1107
+ }
1108
+ return paths.filter((p) => p !== "node_modules");
1109
+ }
1110
+
1111
+ // LOCKED WRAPPERS for the five mutation functions. Git mutations on the same
1112
+ // root run one at a time, serialized by the in-process lock. Two concurrent
1113
+ // mergeWorktree calls on the same root are guaranteed to run sequentially, order
1114
+ // preserved. Reads (branchOfWorktree, worktreeSummary)
1115
+ // stay unlocked.
1116
+ export async function makeWorktree(root, id, log = () => {}, title = "", lifecycle) {
1117
+ return withRepoLock(root, () => makeWorktreeImpl(root, id, log, title), log, lifecycle);
1118
+ }
1119
+
1120
+ /** The chat tree's mint takes the repo lock like every other git mutation:
1121
+ * two rooms' first messages to the same agent, typed at once, are two
1122
+ * `worktree add` calls for one path. */
1123
+ export async function makeChatWorktree(root, path, log = () => {}, lifecycle) {
1124
+ return withRepoLock(root, () => makeChatWorktreeImpl(root, path, log), log, lifecycle);
1125
+ }
1126
+
1127
+ export async function cleanWorktree(root, made, log = () => {}, wroteFiles = []) {
1128
+ return withRepoLock(root, () => cleanWorktreeImpl(root, made, log, wroteFiles), log);
1129
+ }
1130
+
1131
+ export async function reuseWorktree(root, existingPath, log = () => {}, lifecycle) {
1132
+ return withRepoLock(root, () => reuseWorktreeImpl(root, existingPath, log), log, lifecycle);
1133
+ }
1134
+
1135
+ export async function mergeWorktree(root, worktreePath, log = () => {}) {
1136
+ return withRepoLock(root, () => mergeWorktreeImpl(root, worktreePath, log), log);
1137
+ }
1138
+
1139
+ export async function deleteWorktree(root, worktreePath, log = () => {}) {
1140
+ return withRepoLock(root, () => deleteWorktreeImpl(root, worktreePath, log), log);
1141
+ }
1142
+