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.
- package/CHANGELOG.md +1460 -0
- package/LICENSE +21 -0
- package/README.md +755 -2
- package/SPAWN.md +2105 -0
- package/glytch.mjs +517 -0
- package/lib/acp.mjs +978 -0
- package/lib/admission.mjs +105 -0
- package/lib/agent-selection.mjs +123 -0
- package/lib/agents.mjs +913 -0
- package/lib/atomic.mjs +96 -0
- package/lib/auth.mjs +1387 -0
- package/lib/authenticatedHttp.mjs +68 -0
- package/lib/boardside.mjs +225 -0
- package/lib/child.mjs +1252 -0
- package/lib/cli-output.mjs +176 -0
- package/lib/cloudflared-release.json +25 -0
- package/lib/cloudflared.mjs +81 -0
- package/lib/codex-rpc.mjs +92 -0
- package/lib/codex.mjs +747 -0
- package/lib/codexApps.mjs +332 -0
- package/lib/codexPlanWindow.mjs +84 -0
- package/lib/codexToolInputs.mjs +139 -0
- package/lib/codingChecks.mjs +55 -0
- package/lib/compose.mjs +76 -0
- package/lib/computerApps.mjs +110 -0
- package/lib/conversationRules.mjs +45 -0
- package/lib/detect.mjs +605 -0
- package/lib/devserver.mjs +659 -0
- package/lib/digest.mjs +290 -0
- package/lib/doctor.mjs +464 -0
- package/lib/exitClass.mjs +64 -0
- package/lib/glytchFolder.mjs +105 -0
- package/lib/hosted.mjs +112 -0
- package/lib/imageAttachments.mjs +31 -0
- package/lib/init-connect.mjs +418 -0
- package/lib/kill.mjs +104 -0
- package/lib/logindaemon.mjs +976 -0
- package/lib/logs.mjs +120 -0
- package/lib/mcpjson.mjs +242 -0
- package/lib/memberPreview.mjs +165 -0
- package/lib/multiwatch.mjs +428 -0
- package/lib/pagePictures.mjs +396 -0
- package/lib/pageRoutes.mjs +67 -0
- package/lib/paths.mjs +9 -0
- package/lib/permissionAsk.mjs +418 -0
- package/lib/picker.mjs +300 -0
- package/lib/platform.mjs +60 -0
- package/lib/previewGateway.mjs +129 -0
- package/lib/previewTunnel.mjs +85 -0
- package/lib/procs.mjs +428 -0
- package/lib/project-mcp.mjs +288 -0
- package/lib/projectPaths.mjs +31 -0
- package/lib/refusal.mjs +70 -0
- package/lib/registry.mjs +204 -0
- package/lib/repomap.mjs +322 -0
- package/lib/restartPass.mjs +151 -0
- package/lib/retired-address.mjs +78 -0
- package/lib/rowsAnswers.mjs +389 -0
- package/lib/rowsEvents.mjs +86 -0
- package/lib/run-engine.mjs +16 -0
- package/lib/runner-health.mjs +57 -0
- package/lib/runner.mjs +1569 -0
- package/lib/runnerAdmission.mjs +35 -0
- package/lib/runnerApi.mjs +312 -0
- package/lib/runnerCompletion.mjs +406 -0
- package/lib/runnerLease.mjs +37 -0
- package/lib/runnerMaintenance.mjs +27 -0
- package/lib/runnerPace.mjs +283 -0
- package/lib/runnerPerformance.mjs +26 -0
- package/lib/runnerRealtime.mjs +340 -0
- package/lib/runnerRegister.mjs +96 -0
- package/lib/runnerSettings.mjs +40 -0
- package/lib/runnerSettlement.mjs +138 -0
- package/lib/runnerWorkspace.mjs +162 -0
- package/lib/serverSweep.mjs +90 -0
- package/lib/sharedPreview.mjs +292 -0
- package/lib/signIn.mjs +47 -0
- package/lib/spawn.mjs +330 -0
- package/lib/sweep.mjs +161 -0
- package/lib/sync.mjs +484 -0
- package/lib/uninstall-project.mjs +588 -0
- package/lib/uninstall.mjs +457 -0
- package/lib/update.mjs +302 -0
- package/lib/usageLimit.mjs +108 -0
- package/lib/windows-command.mjs +85 -0
- package/lib/worktree.mjs +1138 -0
- package/lib/writePolicy.mjs +45 -0
- package/package.json +58 -4
- 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
|
+
}
|