viewable-one 0.0.0-stage → 0.12.1

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 +1460 -0
  2. package/LICENSE +21 -0
  3. package/README.md +755 -2
  4. package/SPAWN.md +2105 -0
  5. package/glytch.mjs +517 -0
  6. package/lib/acp.mjs +978 -0
  7. package/lib/admission.mjs +105 -0
  8. package/lib/agent-selection.mjs +123 -0
  9. package/lib/agents.mjs +913 -0
  10. package/lib/atomic.mjs +96 -0
  11. package/lib/auth.mjs +1387 -0
  12. package/lib/authenticatedHttp.mjs +68 -0
  13. package/lib/boardside.mjs +225 -0
  14. package/lib/child.mjs +1252 -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 +747 -0
  20. package/lib/codexApps.mjs +332 -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 +605 -0
  28. package/lib/devserver.mjs +659 -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/glytchFolder.mjs +105 -0
  33. package/lib/hosted.mjs +112 -0
  34. package/lib/imageAttachments.mjs +31 -0
  35. package/lib/init-connect.mjs +418 -0
  36. package/lib/kill.mjs +104 -0
  37. package/lib/logindaemon.mjs +976 -0
  38. package/lib/logs.mjs +120 -0
  39. package/lib/mcpjson.mjs +242 -0
  40. package/lib/memberPreview.mjs +165 -0
  41. package/lib/multiwatch.mjs +428 -0
  42. package/lib/pagePictures.mjs +396 -0
  43. package/lib/pageRoutes.mjs +67 -0
  44. package/lib/paths.mjs +9 -0
  45. package/lib/permissionAsk.mjs +418 -0
  46. package/lib/picker.mjs +300 -0
  47. package/lib/platform.mjs +60 -0
  48. package/lib/previewGateway.mjs +129 -0
  49. package/lib/previewTunnel.mjs +85 -0
  50. package/lib/procs.mjs +428 -0
  51. package/lib/project-mcp.mjs +288 -0
  52. package/lib/projectPaths.mjs +31 -0
  53. package/lib/refusal.mjs +70 -0
  54. package/lib/registry.mjs +204 -0
  55. package/lib/repomap.mjs +322 -0
  56. package/lib/restartPass.mjs +151 -0
  57. package/lib/retired-address.mjs +78 -0
  58. package/lib/rowsAnswers.mjs +389 -0
  59. package/lib/rowsEvents.mjs +86 -0
  60. package/lib/run-engine.mjs +16 -0
  61. package/lib/runner-health.mjs +57 -0
  62. package/lib/runner.mjs +1569 -0
  63. package/lib/runnerAdmission.mjs +35 -0
  64. package/lib/runnerApi.mjs +312 -0
  65. package/lib/runnerCompletion.mjs +406 -0
  66. package/lib/runnerLease.mjs +37 -0
  67. package/lib/runnerMaintenance.mjs +27 -0
  68. package/lib/runnerPace.mjs +283 -0
  69. package/lib/runnerPerformance.mjs +26 -0
  70. package/lib/runnerRealtime.mjs +340 -0
  71. package/lib/runnerRegister.mjs +96 -0
  72. package/lib/runnerSettings.mjs +40 -0
  73. package/lib/runnerSettlement.mjs +138 -0
  74. package/lib/runnerWorkspace.mjs +162 -0
  75. package/lib/serverSweep.mjs +90 -0
  76. package/lib/sharedPreview.mjs +292 -0
  77. package/lib/signIn.mjs +47 -0
  78. package/lib/spawn.mjs +330 -0
  79. package/lib/sweep.mjs +161 -0
  80. package/lib/sync.mjs +484 -0
  81. package/lib/uninstall-project.mjs +588 -0
  82. package/lib/uninstall.mjs +457 -0
  83. package/lib/update.mjs +302 -0
  84. package/lib/usageLimit.mjs +108 -0
  85. package/lib/windows-command.mjs +85 -0
  86. package/lib/worktree.mjs +1138 -0
  87. package/lib/writePolicy.mjs +45 -0
  88. package/package.json +58 -4
  89. package/spawn-runner.mjs +167 -0
package/lib/procs.mjs ADDED
@@ -0,0 +1,428 @@
1
+ // KILLING WHAT A SESSION LEFT RUNNING, and nothing about git.
2
+ //
3
+ // Split out of worktree.mjs on 2026-09-02, verbatim. Both halves answer one
4
+ // question - "is this process mine to kill?" - and both learned the same
5
+ // lesson the same way, which is why they belong beside each other rather than
6
+ // in the middle of a file about worktrees:
7
+ //
8
+ // the reaper matched a bare substring of argv against the worktrees path,
9
+ // so an editor opened on a deleted worktree, a `grep -r` naming one, a
10
+ // `tail -f` on its log or a shell whose prompt carried the path all got
11
+ // SIGTERM'd on a poll tick;
12
+ // killProcessesIn killed every pid `lsof +D` returned, which is every
13
+ // process with a file open under the tree - and Merge and Delete are
14
+ // ordinary buttons on his board.
15
+ //
16
+ // So both now ask the same two things: is it one of OUR binaries, and does
17
+ // its parent chain lead back to this runner. Everything else is somebody's
18
+ // own process. `exists` is passed in rather than imported, so this file
19
+ // depends on nothing but node.
20
+
21
+ import { execFile } from "node:child_process";
22
+ import { access } from "node:fs/promises";
23
+ import { join, win32 } from "node:path";
24
+
25
+ import { killTree } from "./kill.mjs";
26
+
27
+ /** argv[0]'s basename, on either slash style. `win32.basename` treats BOTH
28
+ * "/" and "\" as separators (Windows accepts a forward-slash path too), so
29
+ * it is a strict superset of the old `argv0.slice(argv0.lastIndexOf("/") +
30
+ * 1)`: identical output for every POSIX path (no "\" ever appears in one),
31
+ * and correct for a Windows one ("C:\Users\...\node.exe" -> "node.exe"),
32
+ * without checking `process.platform` or needing a different code path on
33
+ * the machine this actually runs on. */
34
+ const basenameOf = (argv0) => win32.basename(argv0);
35
+
36
+ /** The prefix every board-spawned tree and branch carries.
37
+ *
38
+ * The ask, 2026-08-28: "let the branch name + worktrees of coding sessions run
39
+ * on the board start with glytch, then the name of the actual prompt on the
40
+ * board.. that way we are able to corrolate things". One word in `git branch`
41
+ * or `git worktree list` says which of those trees came off the canvas, and
42
+ * the words after it say WHICH circle asked - which `spawn/spawn-mtahtd9d-e1552c`
43
+ * could not, because a base36 timestamp correlates with nothing a person
44
+ * holds in their head. */
45
+ export const GLYTCH = "glytch";
46
+
47
+ /** The prefix a CHAT worktree carries instead (brief 25, ruling 1,
48
+ * 2026-09-07): `<root>/.claude/worktrees/chat-<agentId>`, one per agent per
49
+ * folder, made once on the agent's first chat turn there and kept.
50
+ *
51
+ * It lives beside GLYTCH because this file is where a worktree name is
52
+ * MATCHED, and the two rules are read together: `glytch-` is a session's
53
+ * tree, made and thrown away per run, and `chat-` is an agent's, which
54
+ * outlives every turn that runs in it. */
55
+ export const CHAT_PREFIX = "chat-";
56
+
57
+ const exists = async (path) => {
58
+ try {
59
+ await access(path);
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ };
65
+
66
+ /** Every process on the machine as `pid -> { ppid, args, binary }`.
67
+ *
68
+ * `binary` is argv[0]'s basename, so an absolute path to the binary
69
+ * (/opt/homebrew/bin/claude) counts the same as a bare `claude` - the same
70
+ * comparison reapableWorktreeName makes. Split out from the `ps` call so the
71
+ * parsing can be pinned without spawning anything. */
72
+ export function parseProcessTable(psOutput) {
73
+ const table = new Map();
74
+ for (const line of String(psOutput ?? "").trim().split("\n").slice(1)) {
75
+ const match = line.trim().match(/^([0-9]+)[ \t]+([0-9]+)[ \t]+(.*)$/);
76
+ if (!match) continue;
77
+ const args = match[3];
78
+ const argv0 = args.trim().split(/\s+/)[0] ?? "";
79
+ table.set(Number(match[1]), {
80
+ ppid: Number(match[2]),
81
+ args,
82
+ binary: basenameOf(argv0),
83
+ });
84
+ }
85
+ return table;
86
+ }
87
+
88
+ // ---------- the same table, on a machine with no `ps` ----------
89
+ //
90
+ // Brief 21 session 3 (2026-09-07). The brief left this a choice: a Windows
91
+ // process table, or an honest refusal that logs one line and spares
92
+ // everything. IT IS THE TABLE, because the guard this file exists for CAN be
93
+ // reproduced faithfully on Windows - `Get-CimInstance Win32_Process` hands
94
+ // back ProcessId, ParentProcessId, Name and CommandLine, which is pid, ppid,
95
+ // binary and argv, exactly what `ps -eo pid,ppid,args` gives and exactly what
96
+ // isOursToKill's two tests need. Nothing about the decision changes; only
97
+ // where the rows come from.
98
+ //
99
+ // THE ONE HONEST REFUSAL IS killProcessesIn, below: `lsof +D <dir>` has no
100
+ // Windows counterpart that ships with the OS (Sysinternals handle.exe is a
101
+ // download, not a given), so on Windows there is no way to learn WHICH pids
102
+ // hold a file open under a worktree. Ruling 4A says never kill on a bare pid,
103
+ // and a list of candidates we cannot produce is not a licence to kill the
104
+ // table instead: it logs one line and spares everything.
105
+
106
+ /** The PowerShell one-liner that stands in for `ps -eo pid,ppid,args`. */
107
+ export const CIM_TABLE_ARGS = [
108
+ "-NoProfile",
109
+ "-NonInteractive",
110
+ "-Command",
111
+ "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress",
112
+ ];
113
+
114
+ /** argv[0]'s basename, on either separator, lowercased and with the Windows
115
+ * executable suffix removed.
116
+ *
117
+ * A command line on Windows quotes a path with spaces
118
+ * (`"C:\Program Files\nodejs\node.exe" foo.js`), so a leading quoted token is
119
+ * read whole. The suffix is stripped so `node.exe` and `claude.cmd` match the
120
+ * AGENT_BINARIES set the POSIX side already uses - the guard asks "is this one
121
+ * of our binaries", and `.exe` is spelling, not identity. */
122
+ export function windowsBinaryOf(commandLine, fallbackName = "") {
123
+ const line = String(commandLine ?? "").trim();
124
+ let argv0 = "";
125
+ if (line.startsWith('"')) {
126
+ const close = line.indexOf('"', 1);
127
+ argv0 = close > 0 ? line.slice(1, close) : line.slice(1);
128
+ } else {
129
+ argv0 = line.split(/\s+/)[0] ?? "";
130
+ }
131
+ const raw = argv0 || String(fallbackName ?? "");
132
+ const base = raw.slice(Math.max(raw.lastIndexOf("/"), raw.lastIndexOf("\\")) + 1);
133
+ return base.toLowerCase().replace(/\.(exe|cmd|bat|com)$/, "");
134
+ }
135
+
136
+ /** `Get-CimInstance Win32_Process | ConvertTo-Json` as the same
137
+ * `pid -> { ppid, args, binary }` Map `parseProcessTable` builds, so
138
+ * isOursToKill and the reaper never learn which platform they are on.
139
+ *
140
+ * `CommandLine` is null for a process this user may not read; `Name` carries
141
+ * the binary in that case and the argv is that name alone, which is honest -
142
+ * a process we cannot see the arguments of is one we should not match on
143
+ * arguments. Bad JSON is an empty Map, the same "we could not see" the POSIX
144
+ * path returns when `ps` fails. */
145
+ export function parseWin32ProcessTable(json) {
146
+ const table = new Map();
147
+ const text = String(json ?? "").trim();
148
+ if (text === "" || text === "null") return table;
149
+ let parsed;
150
+ try {
151
+ parsed = JSON.parse(text);
152
+ } catch {
153
+ return table;
154
+ }
155
+ for (const row of Array.isArray(parsed) ? parsed : [parsed]) {
156
+ const pid = Number(row?.ProcessId);
157
+ if (!Number.isFinite(pid)) continue;
158
+ const args = String(row?.CommandLine ?? row?.Name ?? "").trim();
159
+ table.set(pid, {
160
+ ppid: Number(row?.ParentProcessId ?? 0) || 0,
161
+ args,
162
+ binary: windowsBinaryOf(row?.CommandLine, row?.Name),
163
+ });
164
+ }
165
+ return table;
166
+ }
167
+
168
+ const runOut = (bin, args, timeout = 15_000) =>
169
+ new Promise((done) => {
170
+ execFile(bin, args, { timeout, maxBuffer: 8 * 1024 * 1024, windowsHide: true }, (error, stdout) =>
171
+ done(error ? "" : String(stdout ?? "")),
172
+ );
173
+ });
174
+
175
+ async function processTable(platform = process.platform) {
176
+ if (platform === "win32") return parseWin32ProcessTable(await runOut("powershell.exe", CIM_TABLE_ARGS));
177
+ return new Promise((done) => {
178
+ execFile("ps", ["-eo", "pid,ppid,args"], { timeout: 10_000, maxBuffer: 4 * 1024 * 1024 }, (error, stdout) => {
179
+ done(error ? new Map() : parseProcessTable(stdout));
180
+ });
181
+ });
182
+ }
183
+
184
+ /** May this daemon kill `pid`? Only if it is a session this daemon left
185
+ * behind, by one of two tests:
186
+ *
187
+ * 1. the process IS one of the agent binaries (claude, opencode, node) -
188
+ * the same guard reapableWorktreeName applies below;
189
+ * 2. its parent chain leads back to THIS runner, so we started it however
190
+ * it has since renamed itself.
191
+ *
192
+ * Anything else with a file open under the worktree is somebody's own
193
+ * process, and killing it is the bug this exists to stop. Exported because
194
+ * it is a pure decision over a ps snapshot and deserves to be pinned without
195
+ * spawning anything. */
196
+ export function isOursToKill(pid, table, runnerPid, agentBinaries = AGENT_BINARIES) {
197
+ const entry = table.get(pid);
198
+ if (!entry) return false; // gone, or ps could not see it: leave it alone
199
+ if (agentBinaries.has(entry.binary)) return true;
200
+ let cur = entry.ppid;
201
+ // Bounded: a cycle in a ps snapshot should be impossible and is free to
202
+ // guard against here.
203
+ for (let hops = 0; hops < 64 && cur > 1; hops += 1) {
204
+ if (cur === runnerPid) return true;
205
+ const parent = table.get(cur);
206
+ if (!parent) return false;
207
+ cur = parent.ppid;
208
+ }
209
+ return false;
210
+ }
211
+
212
+ /** Kill processes whose cwd is inside a worktree, so removing the directory
213
+ * does not fail on open files and dev servers do not outlive their session.
214
+ *
215
+ * Best-effort: a failure here does not prevent the worktree operation from
216
+ * proceeding. The most common case is a Vite/webpack dev server the session
217
+ * started and never stopped.
218
+ *
219
+ * Uses lsof on macOS and fuser on Linux. Neither is guaranteed to exist on
220
+ * every system, so a missing binary is handled the same as an empty result.
221
+ *
222
+ * IT USED TO KILL EVERY PID lsof RETURNED, and lsof +D returns every process
223
+ * with a file open under the tree: the user's editor with one of its files in
224
+ * a tab, a `tail -f` on its log, a shell sitting in the directory, a `grep -r`
225
+ * passing through. Merge and Delete are ordinary buttons on his board, and
226
+ * pressing one SIGTERM'd whatever he happened to have open.
227
+ *
228
+ * The reaper below already learned this class of bug and grew a binary guard -
229
+ * see reapableWorktreeName for what a bare substring match did to an editor -
230
+ * so this applies the same one, plus a parent-chain test, because a process
231
+ * THIS runner started is ours to clean up whatever it renamed itself to. */
232
+ export async function killProcessesIn(dirPath, log = () => {}, platform = process.platform) {
233
+ if (!dirPath) return;
234
+ // THE HONEST REFUSAL (brief 21, 2026-09-07). `lsof +D` is how this function
235
+ // learns which pids to consider, and Windows ships nothing equivalent. With
236
+ // no candidate list there is no way to honour ruling 4A's "never kill on a
237
+ // bare pid", and killing the whole process table instead is the bug this
238
+ // file exists to prevent. So it spares everything and says so once; the
239
+ // worktree rm that follows may then fail EBUSY, which is a visible failure
240
+ // rather than a silent wrong kill.
241
+ if (platform === "win32") {
242
+ log(`not looking for processes with files open in ${dirPath}: Windows has no lsof, so nothing here is safe to kill`);
243
+ return;
244
+ }
245
+ // lsof +D is slow on large trees; instead find PIDs whose cwd is exactly
246
+ // this directory or a child. lsof -t +d (non-recursive) gives PIDs with
247
+ // open files in the directory.
248
+ const pids = await new Promise((resolve) => {
249
+ // On macOS, `lsof +D <dir> -t` gives all PIDs with open files under dir.
250
+ // On failure or empty, resolve with an empty list.
251
+ execFile("lsof", ["+D", dirPath, "-t"], { timeout: 10_000 }, (error, stdout) => {
252
+ if (error || !stdout.trim()) return resolve([]);
253
+ resolve(
254
+ stdout
255
+ .trim()
256
+ .split("\n")
257
+ .map((p) => parseInt(p, 10))
258
+ .filter((p) => Number.isFinite(p) && p > 1),
259
+ );
260
+ });
261
+ });
262
+ if (pids.length === 0) return;
263
+ const own = process.pid;
264
+ const candidates = pids.filter((p) => p !== own);
265
+ if (candidates.length === 0) return;
266
+ const table = await processTable();
267
+ const toKill = candidates.filter((p) => isOursToKill(p, table, own));
268
+ const spared = candidates.length - toKill.length;
269
+ if (spared > 0) {
270
+ log(`leaving ${spared} process(es) with files open in ${dirPath} alone: not this runner's to kill`);
271
+ }
272
+ if (toKill.length === 0) return;
273
+ log(`killing ${toKill.length} process(es) in ${dirPath}: ${toKill.join(", ")}`);
274
+ for (const pid of toKill) {
275
+ try { await killTree(pid); } catch { /* already gone */ }
276
+ }
277
+ // Give them a moment to exit before we rm the directory.
278
+ await new Promise((r) => setTimeout(r, 1000));
279
+ }
280
+
281
+ // ---------- orphaned process reaper ----------
282
+ //
283
+ // killProcessesIn uses `lsof +D <dir>`, which returns nothing once the
284
+ // directory has been deleted. On 2026-08-30 five vite servers were alive
285
+ // pointing at deleted worktrees. This sweep finds processes whose command
286
+ // line names a path under .claude/worktrees/ that no longer exists.
287
+ //
288
+ // Called at spawn time or teardown time, never as a daemon (ruling
289
+ // q-mt9as5a0: no reaper daemon, no heartbeat, no third state).
290
+
291
+ /** Kill processes whose argv references a .claude/worktrees/<name> path
292
+ * that no longer exists on disk.
293
+ *
294
+ * Uses `ps -eo pid,args` to find processes, matches their command line
295
+ * against the worktrees directory, checks whether the named directory
296
+ * still exists, and kills those pointing at gone trees. */
297
+ /** The agent binaries this runner ever spawns (agents.mjs). Anything else in a
298
+ * worktree is somebody's own process, not a session this daemon left behind. */
299
+ const AGENT_BINARIES = new Set(["claude", "opencode", "node"]);
300
+
301
+ /** The spawn-worktree name this command line is a REAPABLE session for, or null.
302
+ *
303
+ * Two guards, both learned from how badly a bare substring match behaves. It
304
+ * used to be `args.indexOf("<wtRoot>/")` and nothing else, run on every poll
305
+ * against every process on the machine - so an editor opened on a since-deleted
306
+ * worktree, a `grep -r` whose arguments named one, a `tail -f` on its log, or a
307
+ * shell whose prompt string carried the path all got SIGTERM'd. The user's own
308
+ * editor was one SIGTERM away from a poll tick.
309
+ *
310
+ * 1. the directory must be one makeWorktree could have created (`glytch-`),
311
+ * which the old comment claimed was checked and never was;
312
+ * 2. the process must BE one of the agent binaries, so a tool that merely
313
+ * mentions the path is never a candidate.
314
+ *
315
+ * Exported for the test: this is a pure string decision and deserves to be
316
+ * pinned without spawning anything. */
317
+ export function reapableWorktreeName(args, wtRoot) {
318
+ if (typeof args !== "string" || args === "") return null;
319
+ // `wtRoot` comes from `join()`, so it already carries whichever separator
320
+ // built it - "\" on Windows, "/" everywhere else. The old code hardcoded
321
+ // "/", so on Windows the pattern was "C:\...\worktrees/" and never matched
322
+ // a real argv string, which is backslash-joined there too. Read the
323
+ // separator OFF wtRoot itself rather than off `process.platform` (or off
324
+ // `path.sep`, which is bound to the OS running the test, not the OS that
325
+ // produced the string) so the fix is a pure function of its input and
326
+ // provable on any machine.
327
+ const wtSep = wtRoot.includes("\\") ? "\\" : "/";
328
+ const wtPattern = `${wtRoot}${wtSep}`;
329
+ const idx = args.indexOf(wtPattern);
330
+ if (idx < 0) return null;
331
+
332
+ // The directory name is everything up to the next "/" or "\" or
333
+ // whitespace - both separators always, not just the platform's own, so a
334
+ // path that mixes them (as a Windows argv string sometimes does) still
335
+ // stops at the right place.
336
+ const nameMatch = args.slice(idx + wtPattern.length).match(/^([^\s/\\]+)/);
337
+ if (!nameMatch) return null;
338
+ const wtName = nameMatch[1];
339
+ // A CHAT WORKTREE IS NEVER REAPED (brief 25, ruling 1, 2026-09-07: one
340
+ // long-lived chat worktree per agent per folder, kept). The reaper's whole
341
+ // question is "the tree is gone, so is this process an orphan of it?" - and
342
+ // a chat tree that is momentarily gone (swept by hand, pruned) is minted
343
+ // again by the next message rather than abandoned, so a process running in
344
+ // one is never an orphan of a dead session. A `chat-` name also never
345
+ // carries the `glytch-` prefix below, so this states the rule rather than
346
+ // adding one: it is here so that renaming either prefix cannot quietly put
347
+ // a chat conversation's child on the kill list.
348
+ if (wtName.startsWith(CHAT_PREFIX)) return null;
349
+ if (!wtName.startsWith(`${GLYTCH}-`)) return null;
350
+
351
+ // argv[0] is the command; compare on its basename so an absolute path to the
352
+ // binary (/opt/homebrew/bin/claude, or C:\...\claude.exe) counts the same
353
+ // as a bare `claude`.
354
+ const argv0 = args.trim().split(/\s+/)[0] ?? "";
355
+ const binary = basenameOf(argv0);
356
+ if (!AGENT_BINARIES.has(binary)) return null;
357
+
358
+ return wtName;
359
+ }
360
+
361
+ export async function reapOrphanedWorktreeProcesses(root, log = () => {}, platform = process.platform) {
362
+ const wtRoot = join(root, ".claude", "worktrees");
363
+ // On Windows the same rows come from Win32_Process rather than from `ps`;
364
+ // everything after this line is the same decision over the same shape.
365
+ // (The worktrees pattern and the argv0 basename inside reapableWorktreeName
366
+ // are still POSIX-shaped - `${wtRoot}/` and a "/" split - and are session
367
+ // 1's to fix. Until they are, this list is built correctly on Windows and
368
+ // matches nothing, which is the safe direction.)
369
+ if (platform === "win32") {
370
+ const table = await processTable(platform);
371
+ return reapFromList(
372
+ [...table].map(([pid, entry]) => ({ pid, args: entry.args })),
373
+ wtRoot,
374
+ log,
375
+ );
376
+ }
377
+ // Pattern to match in process argv: the worktrees directory path.
378
+ const psList = await new Promise((resolve) => {
379
+ execFile("ps", ["-eo", "pid,args"], { timeout: 10_000, maxBuffer: 4 * 1024 * 1024 }, (error, stdout) => {
380
+ if (error || !stdout.trim()) return resolve([]);
381
+ resolve(
382
+ stdout
383
+ .trim()
384
+ .split("\n")
385
+ .slice(1) // skip header
386
+ .map((line) => {
387
+ const trimmed = line.trim();
388
+ const spaceIdx = trimmed.indexOf(" ");
389
+ if (spaceIdx < 0) return null;
390
+ const pid = parseInt(trimmed.slice(0, spaceIdx), 10);
391
+ const args = trimmed.slice(spaceIdx + 1);
392
+ return Number.isFinite(pid) && pid > 1 ? { pid, args } : null;
393
+ })
394
+ .filter(Boolean),
395
+ );
396
+ });
397
+ });
398
+ return reapFromList(psList, wtRoot, log);
399
+ }
400
+
401
+ /** The decision half of the reaper, over a `{pid, args}` list from whichever
402
+ * process source the platform has. */
403
+ async function reapFromList(psList, wtRoot, log) {
404
+ if (psList.length === 0) return { killed: 0 };
405
+
406
+ const own = process.pid;
407
+ const killed = [];
408
+
409
+ for (const { pid, args } of psList) {
410
+ if (pid === own) continue;
411
+ const wtName = reapableWorktreeName(args, wtRoot);
412
+ if (!wtName) continue;
413
+ const wtPath = join(wtRoot, wtName);
414
+
415
+ if (await exists(wtPath)) continue; // directory still exists, leave it
416
+
417
+ // The directory is gone but the process is alive. Kill it.
418
+ log(`reaping pid ${pid} (${wtName}): worktree deleted, process still running`);
419
+ try { await killTree(pid); killed.push(pid); } catch { /* already gone */ }
420
+ }
421
+
422
+ if (killed.length > 0) {
423
+ log(`reaped ${killed.length} orphaned process(es): ${killed.join(", ")}`);
424
+ // Give them a moment to exit.
425
+ await new Promise((r) => setTimeout(r, 1000));
426
+ }
427
+ return { killed: killed.length };
428
+ }