@yemi33/minions 0.1.2352 → 0.1.2354

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,700 @@
1
+ /**
2
+ * engine/process-utils.js — Cross-platform process-management helpers
3
+ * (kill trees, PID liveness, CPU sampling, process-table enumeration, cwd
4
+ * holder detection, descendant/reachability BFS).
5
+ *
6
+ * Extracted from engine/shared.js (P-mrb8yg9x001k1401-b) — a self-contained
7
+ * slice with no dependency on the rest of shared.js's state/JSON machinery.
8
+ * shared.js re-exports these names so no caller needs to change its
9
+ * `require('./shared')` call. Where a function needs MINIONS_DIR /
10
+ * ENGINE_DEFAULTS from shared.js, it lazily `require('./shared')`s inside
11
+ * the function body (not at module top-level) to avoid a load-time
12
+ * circular-require deadlock with shared.js requiring this module back.
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { execSync: _execSync } = require('child_process');
18
+
19
+ // Shared.js-local PID liveness check, moved here unchanged. Avoids a circular
20
+ // require on engine/cli.js (which has its own isPidAlive) and engine/timeout.js
21
+ // (which has isOsPidAliveForDispatch — but that one looks up pid from a
22
+ // side-channel pid-file, whereas callers that already have the pid in-hand
23
+ // use this simple check).
24
+ function isPidAlive(pid) {
25
+ if (!Number.isFinite(pid) || pid <= 0) return false;
26
+ try { process.kill(pid, 0); return true; }
27
+ catch { return false; }
28
+ }
29
+
30
+ // ─── Cross-Platform Process Kill Helpers ─────────────────────────────────────
31
+
32
+ function normalizeKillPid(proc) {
33
+ const pid = Number(proc?.pid);
34
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
35
+ }
36
+
37
+ function unixChildPids(pid) {
38
+ if (!Number.isInteger(pid) || pid <= 0) return [];
39
+ try {
40
+ return _execSync(`pgrep -P ${pid}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 })
41
+ .split(/\r?\n/)
42
+ .map(line => Number(line.trim()))
43
+ .filter(childPid => Number.isInteger(childPid) && childPid > 0 && childPid !== pid);
44
+ } catch {
45
+ return [];
46
+ }
47
+ }
48
+
49
+ function killUnixProcessTree(pid, signal, seen = new Set()) {
50
+ if (!Number.isInteger(pid) || pid <= 0 || seen.has(pid)) return;
51
+ seen.add(pid);
52
+ for (const childPid of unixChildPids(pid)) {
53
+ killUnixProcessTree(childPid, signal, seen);
54
+ }
55
+ try { process.kill(pid, signal); } catch { /* process may be dead */ }
56
+ }
57
+
58
+ function unrefTimer(timer) {
59
+ if (timer && typeof timer.unref === 'function') timer.unref();
60
+ return timer;
61
+ }
62
+
63
+ function killGracefully(proc, graceMs = 5000) {
64
+ const pid = normalizeKillPid(proc);
65
+ if (!pid) return;
66
+ if (process.platform === 'win32') {
67
+ try { _execSync(`taskkill /PID ${pid} /T`, { stdio: 'pipe', timeout: 3000, windowsHide: true }); } catch { /* process may be dead */ }
68
+ unrefTimer(setTimeout(() => {
69
+ try { _execSync(`taskkill /PID ${pid} /F /T`, { stdio: 'pipe', timeout: 3000, windowsHide: true }); } catch { /* process may be dead */ }
70
+ }, graceMs));
71
+ } else {
72
+ killUnixProcessTree(pid, 'SIGTERM');
73
+ unrefTimer(setTimeout(() => {
74
+ killUnixProcessTree(pid, 'SIGKILL');
75
+ }, graceMs));
76
+ }
77
+ }
78
+
79
+ function killImmediate(proc) {
80
+ const pid = normalizeKillPid(proc);
81
+ if (!pid) return;
82
+ if (process.platform === 'win32') {
83
+ try { _execSync(`taskkill /PID ${pid} /F /T`, { stdio: 'pipe', timeout: 3000, windowsHide: true }); } catch { /* process may be dead */ }
84
+ } else {
85
+ killUnixProcessTree(pid, 'SIGKILL');
86
+ }
87
+ }
88
+
89
+ // Decide whether a Windows `tasklist /FI "PID eq <pid>" /NH` output proves the
90
+ // pid is alive. A bare `out.includes(String(pid))` can read a DEAD pid as alive
91
+ // on a digit-substring collision — the pid appears inside another column (a
92
+ // larger PID, a memory-KB figure, a session id). Require a word-boundary match
93
+ // (`\b<pid>\b`) and, when an `imageName` is supplied, that the expected process
94
+ // image is present too. Mirrors the hardened check in engine/restart-health.js.
95
+ // Pure (no shell-out) so callers can pass captured output and unit tests can
96
+ // feed crafted samples.
97
+ function tasklistOutputShowsPid(out, pid, { imageName } = {}) {
98
+ if (!out) return false;
99
+ const n = Number(pid);
100
+ if (!Number.isInteger(n) || n <= 0) return false;
101
+ if (!new RegExp(`\\b${n}\\b`).test(out)) return false;
102
+ if (imageName && !out.toLowerCase().includes(String(imageName).toLowerCase())) return false;
103
+ return true;
104
+ }
105
+
106
+ // W-mq0e2dae000a003d — cross-platform CPU-seconds sampler used by the
107
+ // spawn-phase watchdog to decide whether a process is genuinely wedged
108
+ // vs busy. Returns the cumulative user+system CPU time in seconds, or
109
+ // null when we can't determine it (process dead, sampler unavailable,
110
+ // shell errored). Fail-open: a null result MUST NOT cause the watchdog
111
+ // to fire — silence-from-the-OS is not the same as silence-from-the-process.
112
+ function getProcessCpuSeconds(pid) {
113
+ const n = Number(pid);
114
+ if (!Number.isInteger(n) || n <= 0) return null;
115
+ try {
116
+ if (process.platform === 'win32') {
117
+ // PowerShell. CPU = total processor time in seconds (sum of user + kernel).
118
+ // 2.5s timeout keeps the tick budget bounded if PS is sluggish.
119
+ const out = _execSync(
120
+ `powershell -NoProfile -NonInteractive -Command "(Get-Process -Id ${n} -ErrorAction Stop).CPU"`,
121
+ { stdio: ['ignore', 'pipe', 'pipe'], timeout: 2500, windowsHide: true, encoding: 'utf8' }
122
+ );
123
+ const v = parseFloat(String(out).trim());
124
+ return Number.isFinite(v) ? v : null;
125
+ }
126
+ if (process.platform === 'linux') {
127
+ // /proc/<pid>/stat fields 14 (utime) + 15 (stime) in clock ticks.
128
+ // _SC_CLK_TCK is 100 on virtually every Linux distro; sysconf isn't
129
+ // exposed from Node so we use the conventional value. If a host
130
+ // ever ships USER_HZ != 100 this would under/over-report by a
131
+ // constant factor; safe within an order of magnitude for our gate.
132
+ const raw = fs.readFileSync(`/proc/${n}/stat`, 'utf8');
133
+ // The comm field can contain spaces and parens — slice past the last ')'
134
+ const close = raw.lastIndexOf(')');
135
+ if (close < 0) return null;
136
+ const fields = raw.slice(close + 2).split(/\s+/);
137
+ // After comm: state(0) ppid(1) pgrp(2) ... utime is index 11, stime 12
138
+ const utime = Number(fields[11]);
139
+ const stime = Number(fields[12]);
140
+ if (!Number.isFinite(utime) || !Number.isFinite(stime)) return null;
141
+ return (utime + stime) / 100;
142
+ }
143
+ if (process.platform === 'darwin') {
144
+ // `ps -p N -o cputime=` → "MM:SS.ss" or "HH:MM:SS"
145
+ const out = _execSync(`ps -p ${n} -o cputime=`, { stdio: ['ignore', 'pipe', 'pipe'], timeout: 2500, windowsHide: true, encoding: 'utf8' });
146
+ const t = String(out).trim();
147
+ if (!t) return null;
148
+ const parts = t.split(':').map(s => parseFloat(s));
149
+ if (!parts.every(p => Number.isFinite(p))) return null;
150
+ let secs = 0;
151
+ while (parts.length) secs = secs * 60 + parts.shift();
152
+ return secs;
153
+ }
154
+ } catch { /* fail-open */ }
155
+ return null;
156
+ }
157
+
158
+ // Single-PID kill (no /T tree walk) — used by the orphan-MCP sweep where we
159
+ // already enumerated descendants ourselves and the parent is dead, so /T would
160
+ // be a no-op anyway.
161
+ function killByPidImmediate(pid) {
162
+ const n = Number(pid);
163
+ if (!Number.isInteger(n) || n <= 0 || n === process.pid) return false;
164
+ if (process.platform === 'win32') {
165
+ try { _execSync(`taskkill /PID ${n} /F`, { stdio: 'pipe', timeout: 3000, windowsHide: true }); return true; }
166
+ catch { return false; }
167
+ }
168
+ try { process.kill(n, 'SIGKILL'); return true; } catch { return false; }
169
+ }
170
+
171
+ // Batched kill — one OS process for N PIDs. `taskkill` accepts repeated /PID
172
+ // flags natively; on Unix we still loop process.kill, which is in-process and
173
+ // cheap. Returns the count of successful kills.
174
+ function killByPidsImmediate(pids) {
175
+ const valid = (Array.isArray(pids) ? pids : [])
176
+ .map(Number)
177
+ .filter(n => Number.isInteger(n) && n > 0 && n !== process.pid);
178
+ if (!valid.length) return 0;
179
+ if (process.platform === 'win32') {
180
+ const flags = valid.map(p => `/PID ${p}`).join(' ');
181
+ try { _execSync(`taskkill /F ${flags}`, { stdio: 'pipe', timeout: 5000, windowsHide: true }); return valid.length; }
182
+ catch {
183
+ let killed = 0;
184
+ for (const p of valid) { if (killByPidImmediate(p)) killed++; }
185
+ return killed;
186
+ }
187
+ }
188
+ let killed = 0;
189
+ for (const p of valid) {
190
+ try { process.kill(p, 'SIGKILL'); killed++; } catch { /* dead */ }
191
+ }
192
+ return killed;
193
+ }
194
+
195
+ // ─── Process Table Enumeration ───────────────────────────────────────────────
196
+ // Cross-platform listing of every live process as { pid, ppid, name, cmd? }.
197
+ // `cmd` is best-effort — included on Windows (via wmic) and Linux (/proc); may
198
+ // be empty on macOS without ps -ww.
199
+
200
+ function _parseWmicCsv(text) {
201
+ const lines = String(text || '').split(/\r?\n/).map(l => l.trim()).filter(Boolean);
202
+ if (lines.length < 2) return [];
203
+ const header = lines[0].split(',');
204
+ const idx = (col) => header.findIndex(h => h.toLowerCase() === col.toLowerCase());
205
+ const nameIdx = idx('Name');
206
+ const ppidIdx = idx('ParentProcessId');
207
+ const pidIdx = idx('ProcessId');
208
+ const cmdIdx = idx('CommandLine');
209
+ if (nameIdx < 0 || ppidIdx < 0 || pidIdx < 0) return [];
210
+ const out = [];
211
+ for (let i = 1; i < lines.length; i++) {
212
+ // CommandLine can contain commas — naive split is fine because Name + PIDs
213
+ // sit at fixed positions and CommandLine, when present, is the LAST column.
214
+ const cols = lines[i].split(',');
215
+ if (cols.length < 4) continue;
216
+ const pid = parseInt(cols[pidIdx], 10);
217
+ if (!Number.isInteger(pid) || pid <= 0) continue;
218
+ const ppid = parseInt(cols[ppidIdx], 10);
219
+ const name = cols[nameIdx] || '';
220
+ const cmd = cmdIdx >= 0 ? cols.slice(cmdIdx).join(',') : '';
221
+ out.push({ pid, ppid: Number.isInteger(ppid) ? ppid : 0, name, cmd });
222
+ }
223
+ return out;
224
+ }
225
+
226
+ // PowerShell Get-CimInstance is the modern path (wmic is removed on Win11
227
+ // 24H2+). We try it first and fall back to wmic for older Windows hosts.
228
+ function _psListProcesses() {
229
+ const script = "Get-CimInstance Win32_Process | Select-Object Name,ParentProcessId,ProcessId,CommandLine | ConvertTo-Json -Compress -Depth 2";
230
+ try {
231
+ const out = _execSync(
232
+ `powershell -NoProfile -NonInteractive -Command "${script}"`,
233
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }
234
+ );
235
+ const parsed = JSON.parse(out);
236
+ const arr = Array.isArray(parsed) ? parsed : [parsed];
237
+ return arr.map(p => ({
238
+ pid: Number(p.ProcessId),
239
+ ppid: Number(p.ParentProcessId) || 0,
240
+ name: p.Name || '',
241
+ cmd: p.CommandLine || '',
242
+ })).filter(p => Number.isInteger(p.pid) && p.pid > 0);
243
+ } catch { return null; }
244
+ }
245
+
246
+ function _winListProcesses() {
247
+ const ps = _psListProcesses();
248
+ if (ps && ps.length) return ps;
249
+ try {
250
+ const out = _execSync(
251
+ 'wmic process get Name,ParentProcessId,ProcessId,CommandLine /format:csv',
252
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }
253
+ );
254
+ return _parseWmicCsv(out);
255
+ } catch { return []; }
256
+ }
257
+
258
+ function _unixListProcesses() {
259
+ try {
260
+ const out = _execSync(
261
+ 'ps -A -o pid=,ppid=,comm=',
262
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }
263
+ );
264
+ return out.split(/\r?\n/).map(line => {
265
+ const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
266
+ if (!m) return null;
267
+ return { pid: parseInt(m[1], 10), ppid: parseInt(m[2], 10), name: m[3], cmd: '' };
268
+ }).filter(Boolean);
269
+ } catch { return []; }
270
+ }
271
+
272
+ function listAllProcesses() {
273
+ return process.platform === 'win32' ? _winListProcesses() : _unixListProcesses();
274
+ }
275
+
276
+ // W-mq6f2fe0000557fa — orphan-worktree GC: identify OS processes whose cwd
277
+ // is at or inside `dir`. Cross-platform with a fail-open contract: on any
278
+ // error or timeout we return [] (the caller continues with its existing
279
+ // escalation path and an unenriched note).
280
+ //
281
+ // Return shape: [{ pid, cmdline, startedAt, ageMs }, ...]
282
+ // - pid: numeric OS pid
283
+ // - cmdline: full command line as a single string (best-effort; possibly truncated)
284
+ // - startedAt: ms-since-epoch process start time (0 when unknown)
285
+ // - ageMs: Date.now() - startedAt (0 when startedAt is 0)
286
+ //
287
+ // Platform notes:
288
+ // - Windows: PowerShell `Get-CimInstance Win32_Process` filtered by the
289
+ // worktree basename appearing in CommandLine. (Win32_Process does not
290
+ // expose the working directory; the basename match is the most-reliable
291
+ // heuristic — every spawn-agent invocation embeds the dispatch id, which
292
+ // is the worktree basename, in its argv.)
293
+ // - Linux: walk `/proc/*/cwd` and keep PIDs whose readlinkSync resolves
294
+ // under `dir`. Cmdline read from `/proc/<pid>/cmdline` (NUL-separated).
295
+ // Start time derived from stat() mtime of the cwd entry as a proxy.
296
+ // - macOS: `lsof -F p -d cwd` filtered by directory, then `ps -p <pid>` for
297
+ // cmdline + start time.
298
+ function findProcessesWithCwdInside(dir, opts = {}) {
299
+ if (!dir || typeof dir !== 'string') return [];
300
+ let resolved;
301
+ try { resolved = path.resolve(dir); } catch { return []; }
302
+ if (!resolved) return [];
303
+ // Lazy require avoids a load-time circular-require deadlock: shared.js
304
+ // requires this module at top-level, so shared's own module.exports isn't
305
+ // populated yet at that point — but by the time this function actually
306
+ // RUNS, shared.js has finished loading and require('./shared') resolves
307
+ // to its full, cached exports object.
308
+ const { ENGINE_DEFAULTS } = require('./shared');
309
+ const timeoutMs = Number(opts.timeoutMs) > 0
310
+ ? Number(opts.timeoutMs)
311
+ : (ENGINE_DEFAULTS.orphanHolderScanTimeoutMs || 5000);
312
+ const now = Date.now();
313
+
314
+ try {
315
+ if (process.platform === 'win32') {
316
+ return _findWindowsProcessesWithCwdInside(resolved, timeoutMs, now);
317
+ }
318
+ if (process.platform === 'linux') {
319
+ return _findLinuxProcessesWithCwdInside(resolved, timeoutMs, now);
320
+ }
321
+ return _findMacProcessesWithCwdInside(resolved, timeoutMs, now);
322
+ } catch { return []; }
323
+ }
324
+
325
+ function _findWindowsProcessesWithCwdInside(resolved, timeoutMs, now) {
326
+ // Win32_Process exposes CommandLine but not the cwd. Match by basename of
327
+ // the worktree dir (the dispatch id like W-mq1k8z6o003acd89) appearing in
328
+ // the cmdline — every spawn-agent prompt-file path embeds it.
329
+ const basename = path.basename(resolved);
330
+ if (!basename || basename.length < 3) return [];
331
+ // PowerShell-quote: outer escape ' as ''
332
+ const psBasename = basename.replace(/'/g, "''");
333
+ const psResolved = resolved.replace(/'/g, "''");
334
+ // Use -like with wildcards on BOTH basename and full path so a process
335
+ // whose cmdline carries the worktree dir (but a different basename
336
+ // anywhere in argv) also matches.
337
+ const script = `Get-CimInstance Win32_Process | Where-Object { ($_.CommandLine -like '*${psBasename}*') -or ($_.CommandLine -like '*${psResolved}*') } | ForEach-Object { [PSCustomObject]@{ pid = $_.ProcessId; cmdline = $_.CommandLine; startedAt = if ($_.CreationDate) { [int64](([datetimeoffset]$_.CreationDate).ToUnixTimeMilliseconds()) } else { 0 } } } | ConvertTo-Json -Compress -Depth 2`;
338
+ let raw;
339
+ try {
340
+ raw = _execSync(
341
+ `powershell -NoProfile -NonInteractive -Command "${script}"`,
342
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }
343
+ );
344
+ } catch { return []; }
345
+ if (!raw || !String(raw).trim()) return [];
346
+ let parsed;
347
+ try { parsed = JSON.parse(raw); }
348
+ catch { return []; }
349
+ const arr = Array.isArray(parsed) ? parsed : [parsed];
350
+ const out = [];
351
+ for (const r of arr) {
352
+ if (!r) continue;
353
+ const pid = Number(r.pid);
354
+ if (!Number.isInteger(pid) || pid <= 0) continue;
355
+ const startedAt = Number(r.startedAt) || 0;
356
+ out.push({
357
+ pid,
358
+ cmdline: r.cmdline ? String(r.cmdline) : '',
359
+ startedAt,
360
+ ageMs: startedAt > 0 ? Math.max(0, now - startedAt) : 0,
361
+ });
362
+ }
363
+ return out;
364
+ }
365
+
366
+ function _findLinuxProcessesWithCwdInside(resolved, _timeoutMs, now) {
367
+ // /proc walk: scan numeric entries, readlink cwd, keep matches.
368
+ let entries;
369
+ try { entries = fs.readdirSync('/proc'); }
370
+ catch { return []; }
371
+ const prefix = resolved + path.sep;
372
+ const out = [];
373
+ for (const e of entries) {
374
+ if (!/^\d+$/.test(e)) continue;
375
+ const pid = Number(e);
376
+ let cwd;
377
+ try { cwd = fs.readlinkSync(`/proc/${pid}/cwd`); }
378
+ catch { continue; }
379
+ if (!cwd) continue;
380
+ if (cwd !== resolved && !cwd.startsWith(prefix)) continue;
381
+ let cmdline = '';
382
+ try {
383
+ const buf = fs.readFileSync(`/proc/${pid}/cmdline`);
384
+ cmdline = buf.toString('utf8').replace(/\0/g, ' ').trim();
385
+ } catch { /* cmdline read can fail on race */ }
386
+ let startedAt = 0;
387
+ try {
388
+ const st = fs.statSync(`/proc/${pid}`);
389
+ startedAt = st && st.ctimeMs ? Math.floor(st.ctimeMs) : 0;
390
+ } catch { /* stat can fail on race */ }
391
+ out.push({
392
+ pid,
393
+ cmdline,
394
+ startedAt,
395
+ ageMs: startedAt > 0 ? Math.max(0, now - startedAt) : 0,
396
+ });
397
+ }
398
+ return out;
399
+ }
400
+
401
+ function _findMacProcessesWithCwdInside(resolved, timeoutMs, now) {
402
+ // `lsof -a -d cwd -F pn` emits PID then cwd path. Filter for dir matches,
403
+ // then run a single `ps` pass to enrich cmdline + start time.
404
+ let raw;
405
+ try {
406
+ raw = _execSync('lsof -a -d cwd -F pn 2>/dev/null', {
407
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024,
408
+ });
409
+ } catch { return []; }
410
+ if (!raw) return [];
411
+ const prefix = resolved + path.sep;
412
+ const pids = new Set();
413
+ let currentPid = null;
414
+ for (const line of String(raw).split(/\r?\n/)) {
415
+ if (!line) continue;
416
+ const tag = line[0];
417
+ const val = line.slice(1);
418
+ if (tag === 'p') {
419
+ const n = Number(val);
420
+ currentPid = Number.isInteger(n) && n > 0 ? n : null;
421
+ } else if (tag === 'n' && currentPid != null) {
422
+ if (val === resolved || val.startsWith(prefix)) pids.add(currentPid);
423
+ }
424
+ }
425
+ if (pids.size === 0) return [];
426
+ const out = [];
427
+ for (const pid of pids) {
428
+ let line;
429
+ try {
430
+ line = String(_execSync(`ps -p ${pid} -o lstart=,command=`, {
431
+ stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000, encoding: 'utf8',
432
+ }) || '').trim();
433
+ } catch { line = ''; }
434
+ let startedAt = 0;
435
+ let cmdline = '';
436
+ if (line) {
437
+ // ps lstart= emits "Mon Jun 9 07:04:33 2026" (24 chars), then cmdline.
438
+ const lstart = line.slice(0, 24).trim();
439
+ cmdline = line.slice(24).trim();
440
+ const parsed = Date.parse(lstart);
441
+ if (!Number.isNaN(parsed)) startedAt = parsed;
442
+ }
443
+ out.push({
444
+ pid,
445
+ cmdline,
446
+ startedAt,
447
+ ageMs: startedAt > 0 ? Math.max(0, now - startedAt) : 0,
448
+ });
449
+ }
450
+ return out;
451
+ }
452
+
453
+ // W-mqila0t5 — Windows-only PEB-based CWD probe used by the worktree-holder
454
+ // reaper. Unlike findProcessesWithCwdInside (whose Windows path is a
455
+ // CommandLine-basename heuristic — Win32_Process does NOT expose the working
456
+ // directory), this reads each process's REAL current directory out of its PEB
457
+ // (NtQueryInformationProcess → PebBaseAddress, then ReadProcessMemory of
458
+ // RTL_USER_PROCESS_PARAMETERS.CurrentDirectory.DosPath). That's the only way
459
+ // to find a process that pins a directory via its CWD — e.g. an orphaned
460
+ // copilot.exe / cmd.exe / node.exe agent child whose cwd is the worktree root
461
+ // but whose argv never mentions the path (so the cmdline heuristic
462
+ // false-negatives, and the Restart Manager / file-handle detectors report ZERO
463
+ // lockers because the lock is a DIRECTORY handle, not a file handle).
464
+ //
465
+ // Returns [{ pid, name, cwd }] for processes whose CWD is at-or-under
466
+ // `worktreePath`. POSIX returns [] (the EBUSY race is Windows-specific; POSIX
467
+ // reaping stays a no-op). Fail-open: any error / timeout / spawn failure →
468
+ // [] (reap nothing). x64 offsets only (PebBaseAddress@8, PEB+0x20 →
469
+ // ProcessParameters, RTL_USER_PROCESS_PARAMETERS+0x38 → CurrentDirectory).
470
+ function _windowsCwdProbeScript(psTarget) {
471
+ return [
472
+ "$ErrorActionPreference='SilentlyContinue'",
473
+ `$target = '${psTarget}'.TrimEnd('\\').ToLower()`,
474
+ 'Add-Type -Namespace MinionsPeb -Name Native -MemberDefinition @"',
475
+ '[DllImport("ntdll.dll")] public static extern int NtQueryInformationProcess(IntPtr h, int cls, byte[] info, int len, ref int ret);',
476
+ '[DllImport("kernel32.dll")] public static extern IntPtr OpenProcess(int access, bool inherit, int pid);',
477
+ '[DllImport("kernel32.dll")] public static extern bool CloseHandle(IntPtr h);',
478
+ '[DllImport("kernel32.dll")] public static extern bool ReadProcessMemory(IntPtr h, IntPtr baseAddr, byte[] buf, int size, ref int read);',
479
+ '"@',
480
+ 'function Get-ProcCwd($procId) {',
481
+ ' $h = [MinionsPeb.Native]::OpenProcess(0x410, $false, $procId)',
482
+ ' if ($h -eq [IntPtr]::Zero) { return $null }',
483
+ ' try {',
484
+ ' $pbi = New-Object byte[] 48; $ret = 0',
485
+ ' if ([MinionsPeb.Native]::NtQueryInformationProcess($h, 0, $pbi, 48, [ref]$ret) -ne 0) { return $null }',
486
+ ' $pebBase = [System.BitConverter]::ToInt64($pbi, 8)',
487
+ ' if ($pebBase -eq 0) { return $null }',
488
+ ' $p8 = New-Object byte[] 8; $r = 0',
489
+ ' if (-not [MinionsPeb.Native]::ReadProcessMemory($h, [IntPtr]($pebBase + 0x20), $p8, 8, [ref]$r)) { return $null }',
490
+ ' $params = [System.BitConverter]::ToInt64($p8, 0)',
491
+ ' if ($params -eq 0) { return $null }',
492
+ ' $us = New-Object byte[] 16',
493
+ ' if (-not [MinionsPeb.Native]::ReadProcessMemory($h, [IntPtr]($params + 0x38), $us, 16, [ref]$r)) { return $null }',
494
+ ' $len = [System.BitConverter]::ToUInt16($us, 0)',
495
+ ' $bufPtr = [System.BitConverter]::ToInt64($us, 8)',
496
+ ' if ($len -le 0 -or $bufPtr -eq 0) { return $null }',
497
+ ' $sb = New-Object byte[] $len',
498
+ ' if (-not [MinionsPeb.Native]::ReadProcessMemory($h, [IntPtr]$bufPtr, $sb, $len, [ref]$r)) { return $null }',
499
+ ' return [System.Text.Encoding]::Unicode.GetString($sb, 0, $len)',
500
+ ' } finally { [void][MinionsPeb.Native]::CloseHandle($h) }',
501
+ '}',
502
+ 'foreach ($p in Get-Process) {',
503
+ ' $cwd = Get-ProcCwd $p.Id',
504
+ ' if (-not $cwd) { continue }',
505
+ " $c = $cwd.TrimEnd('\\').ToLower()",
506
+ " if ($c -eq $target -or $c.StartsWith($target + '\\')) {",
507
+ ' Write-Output ("{0}|{1}|{2}" -f $p.Id, $p.ProcessName, $cwd)',
508
+ ' }',
509
+ '}',
510
+ ].join('\n');
511
+ }
512
+
513
+ function _parseCwdHolderLines(raw, resolved) {
514
+ if (!raw || !String(raw).trim()) return [];
515
+ const prefix = (resolved.replace(/[\\/]+$/g, '') + path.sep).toLowerCase();
516
+ const target = resolved.replace(/[\\/]+$/g, '').toLowerCase();
517
+ const out = [];
518
+ for (const line of String(raw).split(/\r?\n/)) {
519
+ const t = line.trim();
520
+ if (!t) continue;
521
+ const parts = t.split('|');
522
+ if (parts.length < 3) continue;
523
+ const pid = Number(parts[0]);
524
+ if (!Number.isInteger(pid) || pid <= 0) continue;
525
+ const name = parts[1] || '';
526
+ const cwd = parts.slice(2).join('|');
527
+ // Defense-in-depth: re-confirm the cwd is at/under THIS worktree so a
528
+ // sibling worktree's live agent is never reaped (the script filters too).
529
+ const cwdLower = cwd.replace(/[\\/]+$/g, '').toLowerCase();
530
+ if (cwdLower !== target && !cwdLower.startsWith(prefix)) continue;
531
+ out.push({ pid, name, cwd });
532
+ }
533
+ return out;
534
+ }
535
+
536
+ function findProcessCwdHolders(worktreePath, opts = {}) {
537
+ if (process.platform !== 'win32') return [];
538
+ if (!worktreePath || typeof worktreePath !== 'string') return [];
539
+ let resolved;
540
+ try { resolved = path.resolve(worktreePath); } catch { return []; }
541
+ if (!resolved) return [];
542
+ // Lazy require — see comment in findProcessesWithCwdInside above.
543
+ const { ENGINE_DEFAULTS, MINIONS_DIR } = require('./shared');
544
+ const timeoutMs = Number(opts.timeoutMs) > 0
545
+ ? Number(opts.timeoutMs)
546
+ : (ENGINE_DEFAULTS.statusProbeKillTimeoutMs || 12000);
547
+ const psTarget = resolved.replace(/'/g, "''");
548
+ const script = _windowsCwdProbeScript(psTarget);
549
+ // Run from a temp .ps1 file to avoid fragile inline-quote escaping of the
550
+ // embedded C#. Fail-open at every step.
551
+ let scriptPath = null;
552
+ let raw;
553
+ try {
554
+ const dir = path.join(MINIONS_DIR, 'engine', 'tmp');
555
+ try { fs.mkdirSync(dir, { recursive: true }); } catch { /* exists */ }
556
+ scriptPath = path.join(dir, `cwd-probe-${process.pid}-${Date.now()}.ps1`);
557
+ fs.writeFileSync(scriptPath, script, 'utf8');
558
+ raw = _execSync(
559
+ `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${scriptPath}"`,
560
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }
561
+ );
562
+ } catch { raw = ''; /* ETIMEDOUT / spawn fail / write fail → reap nothing */ }
563
+ finally {
564
+ if (scriptPath) { try { fs.unlinkSync(scriptPath); } catch { /* best-effort */ } }
565
+ }
566
+ return _parseCwdHolderLines(raw, resolved);
567
+ }
568
+
569
+ // Cross-check a single PID's command line for a Minions agent invocation
570
+ // (`claude` or `copilot`, including the `node spawn-agent.js --runtime <name>`
571
+ // wrapper and `gh copilot` fallback). Used by orphan/recycled-PID safety:
572
+ // - engine/cleanup.js: gate before killing a PID found in engine/tmp/pid-*.pid
573
+ // - engine/timeout.js: gate before parking a dispatch as still-alive when
574
+ // the OS PID is alive but may belong to an unrelated recycled-PID process
575
+ //
576
+ // Windows: PowerShell Get-CimInstance for the full CommandLine of one PID.
577
+ // Linux: /proc/<pid>/cmdline (NUL-separated).
578
+ // macOS / when /proc isn't available: fallback `ps -p <pid> -o command=`.
579
+ //
580
+ // Returns false when the PID is invalid, the process doesn't exist, the
581
+ // command line can't be read, or the cmdline contains neither `claude` nor
582
+ // `copilot`. False is the safe default for both call sites: cleanup falls
583
+ // through to "skip kill" and timeout falls through to "treat PID as dead".
584
+ function isProcessCommandLineMatchingAgent(pid) {
585
+ const n = Number(pid);
586
+ if (!Number.isInteger(n) || n <= 0) return false;
587
+ let cmdline = '';
588
+ try {
589
+ if (process.platform === 'win32') {
590
+ const out = _execSync(
591
+ `powershell -NoProfile -NonInteractive -Command "(Get-CimInstance Win32_Process -Filter 'ProcessId=${n}').CommandLine"`,
592
+ { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, windowsHide: true, encoding: 'utf8' }
593
+ );
594
+ cmdline = String(out || '').trim();
595
+ } else {
596
+ try {
597
+ const buf = fs.readFileSync(`/proc/${n}/cmdline`);
598
+ cmdline = buf.toString('utf8').replace(/\0/g, ' ').trim();
599
+ } catch {
600
+ try {
601
+ cmdline = String(_execSync(`ps -p ${n} -o command=`,
602
+ { stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000, encoding: 'utf8' }) || '').trim();
603
+ } catch { return false; }
604
+ }
605
+ }
606
+ } catch { return false; }
607
+ if (!cmdline) return false;
608
+ const lower = cmdline.toLowerCase();
609
+ return lower.includes('claude') || lower.includes('copilot');
610
+ }
611
+
612
+ function _buildChildMap(processes) {
613
+ const childMap = new Map();
614
+ for (const p of processes) {
615
+ if (!childMap.has(p.ppid)) childMap.set(p.ppid, []);
616
+ childMap.get(p.ppid).push(p.pid);
617
+ }
618
+ return childMap;
619
+ }
620
+
621
+ // BFS descendants of rootPid given a process snapshot. `allProcesses` is
622
+ // injectable for tests and for amortizing one snapshot across multiple
623
+ // `listProcessDescendants` calls in the same tick.
624
+ function listProcessDescendants(rootPid, allProcesses = null) {
625
+ const root = Number(rootPid);
626
+ if (!Number.isInteger(root) || root <= 0) return [];
627
+ const procs = Array.isArray(allProcesses) ? allProcesses : listAllProcesses();
628
+ const childMap = _buildChildMap(procs);
629
+ const result = [];
630
+ const seen = new Set([root]);
631
+ const queue = [root];
632
+ while (queue.length) {
633
+ const cur = queue.shift();
634
+ const children = childMap.get(cur) || [];
635
+ for (const c of children) {
636
+ if (seen.has(c)) continue;
637
+ seen.add(c);
638
+ result.push(c);
639
+ queue.push(c);
640
+ }
641
+ }
642
+ return result;
643
+ }
644
+
645
+ // Same BFS, but starts from any number of root PIDs and returns the full reach
646
+ // set (including the roots themselves). Used by the orphan-MCP sweep to compute
647
+ // "everything anchored to Minions" so non-anchored MCP node processes can be
648
+ // identified.
649
+ function listProcessReachable(rootPids, allProcesses = null) {
650
+ const roots = (Array.isArray(rootPids) ? rootPids : [rootPids])
651
+ .map(Number)
652
+ .filter(n => Number.isInteger(n) && n > 0);
653
+ if (!roots.length) return new Set();
654
+ const procs = Array.isArray(allProcesses) ? allProcesses : listAllProcesses();
655
+ const childMap = _buildChildMap(procs);
656
+ const seen = new Set();
657
+ const queue = [];
658
+ for (const r of roots) {
659
+ if (!seen.has(r)) { seen.add(r); queue.push(r); }
660
+ }
661
+ while (queue.length) {
662
+ const cur = queue.shift();
663
+ const children = childMap.get(cur) || [];
664
+ for (const c of children) {
665
+ if (seen.has(c)) continue;
666
+ seen.add(c);
667
+ queue.push(c);
668
+ }
669
+ }
670
+ return seen;
671
+ }
672
+
673
+ module.exports = {
674
+ normalizeKillPid,
675
+ unixChildPids,
676
+ killUnixProcessTree,
677
+ killGracefully,
678
+ killImmediate,
679
+ killByPidImmediate,
680
+ killByPidsImmediate,
681
+ tasklistOutputShowsPid,
682
+ getProcessCpuSeconds,
683
+ _parseWmicCsv,
684
+ _psListProcesses,
685
+ _winListProcesses,
686
+ _unixListProcesses,
687
+ listAllProcesses,
688
+ findProcessesWithCwdInside,
689
+ _findWindowsProcessesWithCwdInside,
690
+ _findLinuxProcessesWithCwdInside,
691
+ _findMacProcessesWithCwdInside,
692
+ _windowsCwdProbeScript,
693
+ _parseCwdHolderLines,
694
+ findProcessCwdHolders,
695
+ isProcessCommandLineMatchingAgent,
696
+ _buildChildMap,
697
+ listProcessDescendants,
698
+ listProcessReachable,
699
+ isPidAlive,
700
+ };