@kienntpixon/dieu-phoi 0.7.1 → 0.7.3

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/lib/procs.cjs ADDED
@@ -0,0 +1,422 @@
1
+ 'use strict';
2
+ // s16 (core 0.7.2): read-only look at the process table. Everything here that decides something is a pure function over a
3
+ // plain list [{pid, ppid, name, created (epoch ms | null), ws (bytes | null), cmd}], so tests feed fixture lists and never
4
+ // touch the system. Only listProcesses() and killTree() reach the OS; listProcesses never throws (null on any failure),
5
+ // killTree (kill_leftovers:true, the 0.7.2 behaviour) and killVerified (s7, default 'guarded') are the only kill paths.
6
+
7
+ const cp = require('child_process');
8
+
9
+ const DEFAULT_TIMEOUT_MS = 4000;
10
+ const ORPHAN_NAMES = ['claude.exe', 'codebase-memory-mcp.exe', 'node.exe', 'bash.exe'];
11
+ // never reported as a leftover of one agent: the Claude Code processes themselves and the code-graph server (shared)
12
+ const LEFTOVER_SKIP_NAMES = ['claude.exe', 'claude', 'codebase-memory-mcp.exe', 'codebase-memory-mcp', 'conhost.exe'];
13
+
14
+ const PS_WIN = [
15
+ 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,',
16
+ "@{n='Created';e={if($_.CreationDate){[int64]($_.CreationDate.ToUniversalTime()-[datetime]'1970-01-01').TotalMilliseconds}else{$null}}},",
17
+ 'WorkingSetSize,',
18
+ "@{n='Cmd';e={if($_.CommandLine){$_.CommandLine.Substring(0,[Math]::Min(200,$_.CommandLine.Length))}else{$null}}}",
19
+ ' | ConvertTo-Json -Compress',
20
+ ].join('');
21
+
22
+ const lower = (s) => String(s || '').toLowerCase();
23
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && /^\d+$/.test(v) ? Number(v) : null));
24
+
25
+ /** Windows JSON from PS_WIN (also a single object when only one process) -> list, or null when it is not that shape. */
26
+ function parseWindowsJson(text) {
27
+ let j;
28
+ try { j = JSON.parse(String(text).replace(/^/, '')); } catch (_) { return null; }
29
+ const arr = Array.isArray(j) ? j : (j && typeof j === 'object' ? [j] : null);
30
+ if (!arr) return null;
31
+ const out = [];
32
+ for (const r of arr) {
33
+ if (!r || typeof r !== 'object') continue;
34
+ const pid = num(r.ProcessId);
35
+ if (pid === null) continue;
36
+ out.push({
37
+ pid,
38
+ ppid: num(r.ParentProcessId) === null ? 0 : num(r.ParentProcessId),
39
+ name: typeof r.Name === 'string' ? r.Name : '',
40
+ created: num(r.Created),
41
+ ws: num(r.WorkingSetSize),
42
+ cmd: typeof r.Cmd === 'string' ? r.Cmd : null,
43
+ });
44
+ }
45
+ return out.length ? out : null;
46
+ }
47
+
48
+ const MONTHS = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 };
49
+
50
+ /** `ps -eo pid,ppid,lstart,comm,args` (lstart = "Wed Nov 5 21:06:01 2026", local time) -> list, or null. */
51
+ function parsePs(text) {
52
+ const out = [];
53
+ for (const line of String(text).split(/\r?\n/)) {
54
+ const m = /^\s*(\d+)\s+(\d+)\s+\w{3}\s+(\w{3})\s+(\d{1,2})\s+(\d{2}):(\d{2}):(\d{2})\s+(\d{4})\s+(\S+)\s*(.*)$/.exec(line);
55
+ if (!m) continue;
56
+ const t = new Date(Number(m[8]), MONTHS[m[3]], Number(m[4]), Number(m[5]), Number(m[6]), Number(m[7])).getTime();
57
+ out.push({ pid: Number(m[1]), ppid: Number(m[2]), name: m[9].replace(/^.*\//, ''), created: Number.isFinite(t) ? t : null, ws: null, cmd: m[10] ? m[10].slice(0, 200) : null });
58
+ }
59
+ return out.length ? out : null;
60
+ }
61
+
62
+ /**
63
+ * Process list of this machine, or null (any error, timeout, empty or unreadable output). Never throws.
64
+ * `exec(file, args, {timeout})` -> stdout string is injectable for tests.
65
+ */
66
+ function listProcesses(opts) {
67
+ const o = opts || {};
68
+ const timeout = typeof o.timeoutMs === 'number' && o.timeoutMs > 0 ? o.timeoutMs : DEFAULT_TIMEOUT_MS;
69
+ const platform = o.platform || process.platform;
70
+ const exec = typeof o.exec === 'function' ? o.exec : defaultExec;
71
+ if (process.env.DIEU_PHOI_NO_PROCS === '1' && typeof o.exec !== 'function') return null; // tests: never look at the real machine
72
+ try {
73
+ if (platform === 'win32') {
74
+ const out = exec('powershell', ['-NoProfile', '-NonInteractive', '-Command', PS_WIN], { timeout });
75
+ return typeof out === 'string' ? parseWindowsJson(out) : null;
76
+ }
77
+ const out = exec('ps', ['-eo', 'pid,ppid,lstart,comm,args'], { timeout });
78
+ return typeof out === 'string' ? parsePs(out) : null;
79
+ } catch (_) {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ function defaultExec(file, args, o) {
85
+ const r = cp.spawnSync(file, args, { encoding: 'utf8', timeout: o.timeout, windowsHide: true, maxBuffer: 32 * 1024 * 1024 });
86
+ if (r.error || r.status !== 0 || r.signal) return null;
87
+ return r.stdout || '';
88
+ }
89
+
90
+ function byPid(list) {
91
+ const m = new Map();
92
+ for (const p of list || []) m.set(p.pid, p);
93
+ return m;
94
+ }
95
+
96
+ /** All descendants of rootPid (children, grandchildren, ...; not rootPid itself). Cycle-safe (pid reuse). */
97
+ function descendants(list, rootPid) {
98
+ const kids = new Map();
99
+ for (const p of list || []) {
100
+ if (p.pid === p.ppid) continue;
101
+ if (!kids.has(p.ppid)) kids.set(p.ppid, []);
102
+ kids.get(p.ppid).push(p);
103
+ }
104
+ const out = [];
105
+ const seen = new Set([rootPid]);
106
+ const queue = [rootPid];
107
+ while (queue.length) {
108
+ const cur = queue.shift();
109
+ for (const c of kids.get(cur) || []) {
110
+ if (seen.has(c.pid)) continue;
111
+ seen.add(c.pid);
112
+ out.push(c);
113
+ queue.push(c.pid);
114
+ }
115
+ }
116
+ return out;
117
+ }
118
+
119
+ /** Ancestors of pid, nearest first (stops at a missing parent, a cycle or 64 hops). */
120
+ function ancestors(list, pid) {
121
+ const idx = byPid(list);
122
+ const out = [];
123
+ const seen = new Set([pid]);
124
+ let cur = idx.get(pid);
125
+ for (let i = 0; cur && i < 64; i++) {
126
+ const par = idx.get(cur.ppid);
127
+ if (!par || seen.has(par.pid)) break;
128
+ seen.add(par.pid);
129
+ out.push(par);
130
+ cur = par;
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /** Nearest ancestor of pid named claude(.exe), or null. */
136
+ function claudeAncestor(list, pid) {
137
+ return ancestors(list, pid).find((p) => /^claude(\.exe)?$/.test(lower(p.name))) || null;
138
+ }
139
+
140
+ /** A parent that is gone, or whose pid was reused by a process created after the child. */
141
+ function isOrphan(p, idx) {
142
+ if (!p.ppid) return false; // pid 0: the system's own roots
143
+ const par = idx.get(p.ppid);
144
+ if (!par) return true;
145
+ return Boolean(par.created && p.created && par.created > p.created);
146
+ }
147
+
148
+ /**
149
+ * Processes whose parent is no longer in the list. `names` (case-insensitive; default claude.exe, codebase-memory-mcp.exe,
150
+ * node.exe, bash.exe), `minAgeMs` + `now` to keep only old ones, `skip(p)` to drop known-legitimate ones.
151
+ */
152
+ function orphans(list, o) {
153
+ const opt = o || {};
154
+ const names = new Set((opt.names || ORPHAN_NAMES).map(lower));
155
+ const now = typeof opt.now === 'number' ? opt.now : Date.now();
156
+ const idx = byPid(list);
157
+ const out = [];
158
+ for (const p of list || []) {
159
+ if (!names.has(lower(p.name))) continue;
160
+ if (!isOrphan(p, idx)) continue;
161
+ if (opt.minAgeMs && !(p.created && now - p.created >= opt.minAgeMs)) continue;
162
+ if (typeof opt.skip === 'function' && opt.skip(p)) continue;
163
+ out.push(p);
164
+ }
165
+ return out;
166
+ }
167
+
168
+ /** The code-graph daemon is meant to outlive any session (shared by all of them): never an orphan to clean. */
169
+ function isCbmDaemon(p) {
170
+ return /codebase-memory-mcp/i.test(p.name) && /daemon/i.test(p.cmd || '');
171
+ }
172
+
173
+ /**
174
+ * Orphaned Claude Code / code-graph processes older than minAgeMs (doctor and state): [{pid, name, age_h, ws_mb}] oldest first.
175
+ */
176
+ function staleOrphans(list, o) {
177
+ const opt = o || {};
178
+ const now = typeof opt.now === 'number' ? opt.now : Date.now();
179
+ return orphans(list, { names: ['claude.exe', 'claude', 'codebase-memory-mcp.exe', 'codebase-memory-mcp'], minAgeMs: opt.minAgeMs, now, skip: isCbmDaemon })
180
+ .sort((a, b) => (a.created || 0) - (b.created || 0))
181
+ .map((p) => ({ pid: p.pid, name: p.name, age_h: Math.round(((now - (p.created || now)) / 3600000) * 10) / 10, ws_mb: p.ws ? Math.round(p.ws / 1048576) : null }));
182
+ }
183
+
184
+ /**
185
+ * Processes an agent left behind: descendants of the Claude Code process (rootPid) created at or after startMs, still in the
186
+ * list, not on the hook's own chain (selfPid and its ancestors), not claude/code-graph/conhost. Pure. Returns the list items;
187
+ * `roots` = those whose parent is not itself a leftover (what a kill must target; taskkill /T takes their children).
188
+ */
189
+ function leftoverProcesses(list, o) {
190
+ const opt = o || {};
191
+ if (!Array.isArray(list) || !opt.rootPid || !opt.startMs) return { all: [], roots: [] };
192
+ const own = new Set([opt.selfPid, ...ancestors(list, opt.selfPid).map((p) => p.pid)].filter(Boolean));
193
+ const skip = new Set((opt.skipNames || LEFTOVER_SKIP_NAMES).map(lower));
194
+ const all = descendants(list, opt.rootPid).filter((p) => !own.has(p.pid) && p.created && p.created >= opt.startMs && !skip.has(lower(p.name)));
195
+ const ids = new Set(all.map((p) => p.pid));
196
+ return { all, roots: all.filter((p) => !ids.has(p.ppid)) };
197
+ }
198
+
199
+ /** Kill process trees. Windows `taskkill /PID n /T /F`, else SIGTERM. `deadline` (epoch ms) stops early. Returns the pids stopped; never throws. */
200
+ function killTree(pids, o) {
201
+ const opt = o || {};
202
+ const done = [];
203
+ for (const pid of pids || []) {
204
+ const left = opt.deadline ? opt.deadline - Date.now() : 3000;
205
+ if (left <= 0) break; // out of time budget: the rest stay reported, not killed
206
+ try {
207
+ if ((opt.platform || process.platform) === 'win32') {
208
+ const r = cp.spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { timeout: Math.min(3000, left), windowsHide: true });
209
+ if (!r.error && r.status === 0) done.push(pid);
210
+ } else {
211
+ process.kill(pid, 'SIGTERM');
212
+ done.push(pid);
213
+ }
214
+ } catch (_) { /* gone already or not ours */ }
215
+ }
216
+ return done;
217
+ }
218
+
219
+ // ------------------------------------------------------------------ guarded kill (core 0.7.3 s7)
220
+
221
+ // Never killed by any dieu-phoi path, whatever the attribution says: Claude Code itself, the shared code graph, console
222
+ // hosts, Docker / WSL / Hyper-V, Unity, the user's apps (Orca, PixChat, editors, terminals, Explorer) and Windows itself.
223
+ const NEVER_KILL_RE = [
224
+ /^claude(\.exe)?$/, /^codebase-memory-mcp/, /^conhost(\.exe)?$/, /^openconsole(\.exe)?$/, /^windowsterminal(\.exe)?$/,
225
+ /^com\.docker\./, /^docker/, /^wsl/, /^vmmem/, /^vmwp(\.exe)?$/, /^vmcompute(\.exe)?$/, /^unity/,
226
+ /^orca/, /^pixchat/, /^explorer(\.exe)?$/, /^code(\.exe)?$/, /^cursor(\.exe)?$/, /^devenv(\.exe)?$/, /^rider/,
227
+ /^(system|idle|registry|smss|csrss|wininit|winlogon|services|lsass|svchost|dwm|sihost|taskhostw|fontdrvhost|ctfmon|runtimebroker|searchhost|shellexperiencehost|startmenuexperiencehost|memory compression)(\.exe)?$/,
228
+ ];
229
+ const isNeverKill = (p) => NEVER_KILL_RE.some((re) => re.test(lower(p && p.name)));
230
+ // What Claude Code's Bash / PowerShell tools start under claude.exe. Other direct children of claude.exe are MCP servers
231
+ // (node, npx, cmd /c, uvx, python...): the session's own, never an agent's leftover.
232
+ const AGENT_SHELL_RE = /^(bash|sh|zsh|dash|powershell|pwsh)(\.exe)?$/;
233
+ // Orphans `reap --orphans` may clean: keep-alive / watcher commands, and Claude Code shells (the snapshot path is in their cmd).
234
+ const ORPHAN_KILL_NAMES = ['tail.exe', 'tail', 'sleep.exe', 'sleep'];
235
+ const isClaudeShell = (p) => AGENT_SHELL_RE.test(lower(p.name)) && /shell-snapshots/i.test(p.cmd || '');
236
+ const normPath = (s) => String(s || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
237
+
238
+ /**
239
+ * `kill_leftovers` mode: env DIEU_PHOI_KILL_LEFTOVERS → config → 'guarded'. true/'true'/'1' = 'tree' (0.7.2: kill the tree
240
+ * roots with taskkill /T), false/'false'/'0'/'off' = 'off', anything else = 'guarded'.
241
+ */
242
+ function killMode(config, env) {
243
+ const e = (env || process.env).DIEU_PHOI_KILL_LEFTOVERS;
244
+ for (const v of [e, config && config.kill_leftovers]) {
245
+ if (v === undefined || v === null || v === '') continue;
246
+ if (v === true || /^(true|1|yes|tree)$/i.test(String(v))) return 'tree';
247
+ if (v === false || /^(false|0|no|off)$/i.test(String(v))) return 'off';
248
+ return 'guarded';
249
+ }
250
+ return 'guarded';
251
+ }
252
+
253
+ /** The value written as `kill_leftovers` in events: true / false / 'guarded' (what the user would put in config). */
254
+ const killModeValue = (mode) => (mode === 'tree' ? true : mode === 'off' ? false : 'guarded');
255
+
256
+ /** Has p a claude.exe below it (another session / a nested Claude Code)? Killing p would take that session down. */
257
+ function hostsClaude(list, pid) {
258
+ return descendants(list, pid).some((c) => /^claude(\.exe)?$/.test(lower(c.name)));
259
+ }
260
+
261
+ /**
262
+ * Guarded selection over leftover candidates (descendants of the session's claude.exe created since the agent started, see
263
+ * leftoverProcesses). Keeps only processes attributable to that agent:
264
+ * - not on the never-kill list (nor any ancestor below the root), no claude.exe below it;
265
+ * - reached through an agent shell (bash / powershell) that claude.exe started, not through an MCP server;
266
+ * - its command line (or an ancestor's, up to the root) is inside the agent's own worktree (`worktree`; the caller passes
267
+ * it only when no other agent shares it), OR `exclusive` (no other dp agent of this home ran meanwhile).
268
+ * Returns { kill: [p], skip: [{pid, name, reason}] }. Pure.
269
+ */
270
+ function guardedSelect(list, cands, o) {
271
+ const opt = o || {};
272
+ const idx = byPid(list);
273
+ const wt = opt.worktree ? normPath(opt.worktree) : '';
274
+ const kill = [];
275
+ const skip = [];
276
+ for (const p of cands || []) {
277
+ let reason = null;
278
+ if (isNeverKill(p)) reason = 'never-kill';
279
+ else if (hostsClaude(list, p.pid)) reason = 'hosts-claude';
280
+ else {
281
+ const chain = [p]; // p up to (not including) the root
282
+ let cur = p;
283
+ for (let i = 0; i < 64 && cur && cur.ppid !== opt.rootPid; i++) { cur = idx.get(cur.ppid); if (cur) chain.push(cur); }
284
+ const top = chain[chain.length - 1];
285
+ if (!cur || !opt.rootPid || top.ppid !== opt.rootPid) reason = 'not-under-session';
286
+ else if (!AGENT_SHELL_RE.test(lower(top.name))) reason = 'not-via-agent-shell';
287
+ else if (chain.some(isNeverKill)) reason = 'never-kill-ancestor';
288
+ else if (!opt.exclusive && !(wt && chain.some((c) => normPath(c.cmd).includes(wt)))) reason = 'not-attributable';
289
+ }
290
+ if (reason) skip.push({ pid: p.pid, name: p.name, reason });
291
+ else kill.push(p);
292
+ }
293
+ return { kill, skip };
294
+ }
295
+
296
+ /**
297
+ * Orphans `reap --orphans` may kill: tail / sleep (any cmd) and Claude Code shells (cmd has shell-snapshots) whose parent
298
+ * is gone (or whose parent pid was reused later), older than minAgeMs, not never-kill, no claude.exe below. Oldest first.
299
+ */
300
+ function killableOrphans(list, o) {
301
+ const opt = o || {};
302
+ const now = typeof opt.now === 'number' ? opt.now : Date.now();
303
+ const names = new Set(ORPHAN_KILL_NAMES);
304
+ const idx = byPid(list);
305
+ return (list || []).filter((p) => (names.has(lower(p.name)) || isClaudeShell(p)) && isOrphan(p, idx)
306
+ && p.created && now - p.created >= (opt.minAgeMs || 0) && !isNeverKill(p) && !hostsClaude(list, p.pid))
307
+ .sort((a, b) => a.created - b.created);
308
+ }
309
+
310
+ // Git-bash (MSYS2) trap: when bash runs its last command it execs it, and on Windows that means a NEW process whose
311
+ // Windows parent (the bash.exe stub) exits at once. A live `bash -c "tail -f x"` / Monitor therefore shows a dead PPID from
312
+ // the first second. Only the MSYS process table (`ps -al`: PID PPID PGID WINPID ...) keeps the logical parent, so on
313
+ // Windows an orphan counts only if MSYS also says so: MSYS parent 1 or no longer in the table.
314
+ const MSYS_PS_CANDIDATES = ['C:\\Program Files\\Git\\usr\\bin\\ps.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\ps.exe', 'C:\\msys64\\usr\\bin\\ps.exe'];
315
+
316
+ /** `ps -al` of MSYS -> { byWin: Map(winpid -> {pid, ppid}), pids: Set(msys pid) }, or null when nothing parses. */
317
+ function parseMsysPs(text) {
318
+ const byWin = new Map();
319
+ const pids = new Set();
320
+ for (const line of String(text || '').split(/\r?\n/)) {
321
+ const m = /^[ A-Z]\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s/.exec(line);
322
+ if (!m) continue;
323
+ const pid = Number(m[1]);
324
+ pids.add(pid);
325
+ byWin.set(Number(m[4]), { pid, ppid: Number(m[2]) });
326
+ }
327
+ return pids.size ? { byWin, pids } : null;
328
+ }
329
+
330
+ /**
331
+ * MSYS process table of this machine, or null (not Windows, no Git-bash ps.exe, error). `hints` = command lines of the
332
+ * candidates (the ps.exe next to their tail.exe is the right runtime); `exec(file, args, {timeout})` injectable. Never throws.
333
+ */
334
+ function msysTable(o) {
335
+ const opt = o || {};
336
+ const exec = typeof opt.exec === 'function' ? opt.exec : defaultExec;
337
+ if (process.env.DIEU_PHOI_NO_PROCS === '1' && typeof opt.exec !== 'function') return null;
338
+ const dirs = [];
339
+ for (const c of opt.hints || []) {
340
+ const m = /^"?([A-Za-z]:\\[^"]*?\\usr\\bin)\\[^\\"]+\.exe/i.exec(String(c || ''));
341
+ if (m && !dirs.includes(m[1])) dirs.push(m[1]);
342
+ }
343
+ const files = dirs.map((d) => `${d}\\ps.exe`).concat(MSYS_PS_CANDIDATES, ['ps']);
344
+ for (const f of files) {
345
+ try {
346
+ const out = exec(f, ['-al'], { timeout: opt.timeoutMs || DEFAULT_TIMEOUT_MS });
347
+ const t = typeof out === 'string' ? parseMsysPs(out) : null;
348
+ if (t) return t;
349
+ } catch (_) { /* next */ }
350
+ }
351
+ return null;
352
+ }
353
+
354
+ /**
355
+ * Orphan candidates that the MSYS table confirms (MSYS parent 1 or gone). msys null = unreadable: nothing is confirmed.
356
+ * Returns { keep: [p], skip: [{pid, name, reason: 'msys-parent-alive'|'msys-unknown'|'msys-unverifiable'}] }. Pure.
357
+ */
358
+ function msysConfirm(cands, msys) {
359
+ const keep = [];
360
+ const skip = [];
361
+ for (const p of cands || []) {
362
+ const m = msys && msys.byWin.get(p.pid);
363
+ if (!msys) skip.push({ pid: p.pid, name: p.name, reason: 'msys-unverifiable' });
364
+ else if (!m) skip.push({ pid: p.pid, name: p.name, reason: 'msys-unknown' });
365
+ else if (m.ppid !== 1 && msys.pids.has(m.ppid)) skip.push({ pid: p.pid, name: p.name, reason: 'msys-parent-alive' });
366
+ else keep.push(p);
367
+ }
368
+ return { keep, skip };
369
+ }
370
+
371
+ /** Kill single processes (no /T: what is below stays unless it is a target too). Returns the pids stopped; never throws. */
372
+ function killPids(pids, o) {
373
+ const opt = o || {};
374
+ const done = [];
375
+ for (const pid of pids || []) {
376
+ const left = opt.deadline ? opt.deadline - Date.now() : 3000;
377
+ if (left <= 0) break;
378
+ try {
379
+ if ((opt.platform || process.platform) === 'win32') {
380
+ const r = cp.spawnSync('taskkill', ['/PID', String(pid), '/F'], { timeout: Math.min(3000, left), windowsHide: true });
381
+ if (!r.error && r.status === 0) done.push(pid);
382
+ } else {
383
+ process.kill(pid, 'SIGTERM');
384
+ done.push(pid);
385
+ }
386
+ } catch (_) { /* gone already or not ours */ }
387
+ }
388
+ return done;
389
+ }
390
+
391
+ /**
392
+ * Re-read the process table right before killing; kill only targets still there with the same creation time and name
393
+ * (a pid reused by an unrelated process is never killed). `relist()` -> list|null, `kill(pids)` -> pids (default killPids).
394
+ * Returns { killed: [pid], skipped: [{pid, name, reason: 'gone'|'pid-reused'|'no-created'|'unverifiable'}] }. Never throws.
395
+ */
396
+ function killVerified(targets, o) {
397
+ const opt = o || {};
398
+ let fresh = null;
399
+ try { fresh = typeof opt.relist === 'function' ? opt.relist() : null; } catch (_) { fresh = null; }
400
+ if (!Array.isArray(fresh)) return { killed: [], skipped: (targets || []).map((t) => ({ pid: t.pid, name: t.name, reason: 'unverifiable' })) };
401
+ const idx = byPid(fresh);
402
+ const ok = [];
403
+ const skipped = [];
404
+ for (const t of targets || []) {
405
+ const cur = idx.get(t.pid);
406
+ if (!t.created) skipped.push({ pid: t.pid, name: t.name, reason: 'no-created' });
407
+ else if (!cur) skipped.push({ pid: t.pid, name: t.name, reason: 'gone' });
408
+ else if (cur.created !== t.created || lower(cur.name) !== lower(t.name)) skipped.push({ pid: t.pid, name: t.name, reason: 'pid-reused' });
409
+ else ok.push(t.pid);
410
+ }
411
+ let killed = [];
412
+ try { killed = ok.length ? ((typeof opt.kill === 'function' ? opt.kill(ok) : killPids(ok, { deadline: opt.deadline })) || []) : []; } catch (_) { killed = []; }
413
+ return { killed, skipped };
414
+ }
415
+
416
+ module.exports = {
417
+ PS_WIN, ORPHAN_NAMES, LEFTOVER_SKIP_NAMES,
418
+ parseWindowsJson, parsePs, listProcesses,
419
+ descendants, ancestors, claudeAncestor, orphans, staleOrphans, isCbmDaemon, leftoverProcesses, killTree,
420
+ NEVER_KILL_RE, ORPHAN_KILL_NAMES, AGENT_SHELL_RE, isNeverKill, isClaudeShell, isOrphan, killMode, killModeValue, hostsClaude,
421
+ guardedSelect, killableOrphans, killPids, killVerified, MSYS_PS_CANDIDATES, parseMsysPs, msysTable, msysConfirm,
422
+ };
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+ // `dieu-phoi profile scan` (core 0.7.2): look for secrets / machine-specific data in the profile files before they are
3
+ // published (Skill Hub zip, repo). Regex-only: it cannot see customer, person or secret-project NAMES - the user still
4
+ // reads the lessons diff (see profile publish). It never prints a matched value, only file:line:kind.
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ const SCAN_FILES = ['lessons.md', 'profile.json', 'unity-lanes.md'];
10
+
11
+ const PLACEHOLDER = /^(?:<[^>]*>?|\$\{?\w+\}?|%\w+%|\*+|\.{2,}|…|x{3,}|["'`]?[.…]*["'`]?|["'`]?<.*)$/i;
12
+
13
+ // kind → [regex, optional filter(matchText, line) → boolean]. Every regex needs the g flag.
14
+ const RULES = [
15
+ ['abs-path', /(?<![A-Za-z0-9])[A-Za-z]:[\\/](?![\\/])[^\s`'")\]]*|(?<![\w.])\/(?:Users|home)\/[^\s/`'")]+/g],
16
+ ['ipv4', /(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?![\d.]*\d)/g, (m) => {
17
+ const o = m.split('.').map(Number);
18
+ if (o.some((n) => n > 255)) return false;
19
+ return !(m === '127.0.0.1' || m === '0.0.0.0'); // loopback / any-address: not machine data
20
+ }],
21
+ ['email', /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/g],
22
+ ['signed-url', /https?:\/\/[^\s)>"'`]*[?&](?:sig|signature|token|access_token|key|X-Amz-[A-Za-z-]+|X-Goog-[A-Za-z-]+)=[^\s)>"'`]*/gi],
23
+ ['token-prefix', /\b(?:sk-[A-Za-z0-9_-]{8,}|ghp_[A-Za-z0-9]{8,}|gho_[A-Za-z0-9]{8,}|glpat-[A-Za-z0-9_-]{8,}|npm_[A-Za-z0-9]{8,}|xox[abprso]-[A-Za-z0-9-]{8,}|AKIA[0-9A-Z]{12,})|\bBearer\s+[A-Za-z0-9._~+/-]{8,}/g],
24
+ ['secret-assign', /\b[A-Za-z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)[A-Za-z0-9_]*\s*=\s*(\S+)/gi, (m) => {
25
+ const v = m.slice(m.indexOf('=') + 1).trim();
26
+ return !!v && !PLACEHOLDER.test(v);
27
+ }],
28
+ ['long-hex', /\b[0-9a-fA-F]{32,}\b/g],
29
+ ['long-base64', /(?<![A-Za-z0-9+/_-])[A-Za-z0-9+/_-]{32,}={0,2}(?![A-Za-z0-9+/_-])/g, (m) => /[a-z]/.test(m) && /[A-Z]/.test(m) && /\d/.test(m)],
30
+ ];
31
+
32
+ // Known false positives. Match is the literal matched text (never a secret); `reason` is shown in the output.
33
+ const ALLOW = [
34
+ { file: 'unity-lanes.md', kind: 'abs-path', match: 'D:/', reason: 'ví dụ gốc ổ đĩa "D:/" cho PFF_DATA_ROOT, không có đường dẫn thật' },
35
+ ];
36
+
37
+ function allowed(file, kind, match) {
38
+ return ALLOW.find((a) => a.file === file && a.kind === kind && a.match === match) || null;
39
+ }
40
+
41
+ /** Findings of one text: [{line, kind, allowed: reason|null}] (values are not returned). */
42
+ function scanText(file, text) {
43
+ const out = [];
44
+ const lines = String(text).replace(/^/, '').split(/\r?\n/);
45
+ lines.forEach((ln, i) => {
46
+ const seen = new Set();
47
+ for (const [kind, re, filter] of RULES) {
48
+ re.lastIndex = 0;
49
+ let m;
50
+ while ((m = re.exec(ln))) {
51
+ if (m[0] === '') { re.lastIndex++; continue; }
52
+ if (filter && !filter(m[0], ln)) continue;
53
+ if (seen.has(kind)) continue; // one finding per kind per line is enough to point at it
54
+ seen.add(kind);
55
+ const al = allowed(file, kind, m[0]);
56
+ out.push({ file, line: i + 1, kind, allowed: al ? al.reason : null });
57
+ }
58
+ }
59
+ });
60
+ return out;
61
+ }
62
+
63
+ /** Scan files of a profile dir (or {file: text} map). → {findings, files, clean}. */
64
+ function scanProfile(source) {
65
+ const texts = {};
66
+ if (typeof source === 'string') {
67
+ for (const f of SCAN_FILES) {
68
+ try { texts[f] = fs.readFileSync(path.join(source, f), 'utf8'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
69
+ }
70
+ } else Object.assign(texts, source);
71
+ const findings = [];
72
+ for (const [f, t] of Object.entries(texts)) findings.push(...scanText(f, t));
73
+ const hits = findings.filter((x) => !x.allowed);
74
+ return { files: Object.keys(texts), findings, hits, skipped: findings.filter((x) => x.allowed), clean: hits.length === 0 };
75
+ }
76
+
77
+ function formatScan(r, dir) {
78
+ const L = [`profile scan ${dir ? `${String(dir).replace(/\\/g, '/')} ` : ''}— ${r.files.join(', ') || '(không có file)'}`];
79
+ for (const x of r.hits) L.push(` ! ${x.file}:${x.line}: ${x.kind}`);
80
+ for (const x of r.skipped) L.push(` ~ ${x.file}:${x.line}: ${x.kind} (bỏ qua — ${x.allowed})`);
81
+ L.push(r.clean ? ` sạch: ${r.skipped.length} dương tính giả đã bỏ qua` : ` ${r.hits.length} chỗ nghi ngờ (giá trị không in). Xem dòng đó, sửa hoặc xác nhận dương tính giả, rồi quét lại.`);
82
+ return L.join('\n');
83
+ }
84
+
85
+ module.exports = { scanText, scanProfile, formatScan, SCAN_FILES, ALLOW };
@@ -0,0 +1,147 @@
1
+ 'use strict';
2
+ // Gói npm mang bản tuyển của profile (cli/profile/, do scripts/pack-profile.cjs chép từ skill/profile lúc prepack).
3
+ // `setup` gộp vào profile đã cài (<skill>/profile):
4
+ // - lessons.md: dòng gạch đầu dòng nào chưa có → nối vào CUỐI file; dòng có sẵn và dòng riêng của máy không đụng tới;
5
+ // - .lessons.published.md: cộng dồn các dòng đã tuyển (dòng riêng máy không bao giờ vào đây, nên publish vẫn nhận ra chúng);
6
+ // - unity-lanes.md: chưa có → chép; đã có → không ghi đè, chỉ báo số dòng khác;
7
+ // - profile.json: không đụng.
8
+ // Idempotent: chạy lại không thêm gì. So khớp dòng theo normLine = norm() của tools/lessons_sync.cjs
9
+ // (bỏ gạch đầu dòng, [ngày] đầu, khoảng trắng thừa, chữ hoa, dấu chấm cuối) → dòng chỉ khác ngày/dấu chấm không nhân đôi.
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const core = require('../hook.cjs');
14
+
15
+ const PUBLISHED = '.lessons.published.md';
16
+ const UNITY = 'unity-lanes.md';
17
+ const BULLET_RE = /^\s*[-*] +/;
18
+
19
+ const lf = (txt) => String(txt).replace(/^/, '').replace(/\r\n/g, '\n');
20
+
21
+ function readLF(file) {
22
+ try { return lf(fs.readFileSync(file, 'utf8')); } catch (e) { if (e.code === 'ENOENT') return null; throw e; }
23
+ }
24
+
25
+ /** Bullet lines outside ``` fences, trimmed (same reading as lessons_sync.cjs). */
26
+ function bulletsOf(txt) {
27
+ const out = [];
28
+ let fence = false;
29
+ for (const raw of String(txt || '').split(/\r?\n/)) {
30
+ if (/^\s*```/.test(raw)) { fence = !fence; continue; }
31
+ if (fence || !BULLET_RE.test(raw)) continue;
32
+ out.push(raw.trim());
33
+ }
34
+ return out;
35
+ }
36
+
37
+ /** Comparison key, same as lessons_sync.cjs norm(): no bullet, no leading [date], lower-case, single spaces, no final period. */
38
+ function normLine(line) {
39
+ return String(line).replace(BULLET_RE, '').replace(/^\[\d{4}-\d{2}-\d{2}\]\s*/, '')
40
+ .replace(/\s+/g, ' ').trim().replace(/[.\s]+$/, '').toLowerCase();
41
+ }
42
+
43
+ /** Bullets of srcTxt whose key is not in `have` (and not repeated inside srcTxt). */
44
+ function newBullets(srcTxt, have) {
45
+ const seen = new Set(have);
46
+ const out = [];
47
+ for (const l of bulletsOf(srcTxt)) {
48
+ const k = normLine(l);
49
+ if (!k || seen.has(k)) continue;
50
+ seen.add(k);
51
+ out.push(l);
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /** Text with `lines` appended after its last line (one newline between). */
57
+ function appendLines(txt, lines) {
58
+ return `${txt.replace(/\n*$/, '\n')}${lines.join('\n')}\n`;
59
+ }
60
+
61
+ /**
62
+ * seedProfile(srcDir, dstDir, { dryRun }) — srcDir: <pkg>/profile, dstDir: <skill>/profile (phải đã tồn tại).
63
+ * → { src, dst, dry_run, skipped?, lessons: {action, added}, published: {action}, unity_lanes: {action, diff} }
64
+ * action: lessons copied|appended|unchanged; published written|appended|unchanged; unity_lanes copied|kept|unchanged.
65
+ */
66
+ function seedProfile(srcDir, dstDir, opts) {
67
+ const dry = !!(opts && opts.dryRun);
68
+ const res = {
69
+ src: srcDir, dst: dstDir, dry_run: dry,
70
+ lessons: { action: 'unchanged', added: 0 }, published: { action: 'unchanged' }, unity_lanes: { action: 'unchanged', diff: 0 },
71
+ };
72
+ const srcL = readLF(path.join(srcDir, 'lessons.md'));
73
+ if (srcL === null) return Object.assign(res, { skipped: 'no-package-lessons' });
74
+ if (!fs.existsSync(dstDir) || !fs.statSync(dstDir).isDirectory()) return Object.assign(res, { skipped: 'no-skill-profile' });
75
+
76
+ // lessons.md
77
+ const dstFile = path.join(dstDir, 'lessons.md');
78
+ const dstL = readLF(dstFile);
79
+ let lessonsOut = null;
80
+ if (dstL === null) {
81
+ lessonsOut = srcL;
82
+ res.lessons = { action: 'copied', added: bulletsOf(srcL).length };
83
+ } else {
84
+ const add = newBullets(srcL, new Set(bulletsOf(dstL).map(normLine)));
85
+ if (add.length) {
86
+ lessonsOut = appendLines(dstL, add);
87
+ res.lessons = { action: 'appended', added: add.length };
88
+ }
89
+ }
90
+
91
+ // .lessons.published.md
92
+ const pubFile = path.join(dstDir, PUBLISHED);
93
+ const pubOld = readLF(pubFile);
94
+ let pubOut = null;
95
+ if (pubOld === null) {
96
+ pubOut = srcL;
97
+ res.published = { action: 'written' };
98
+ } else {
99
+ const add = newBullets(srcL, new Set(bulletsOf(pubOld).map(normLine)));
100
+ if (add.length) {
101
+ pubOut = appendLines(pubOld, add);
102
+ res.published = { action: 'appended' };
103
+ }
104
+ }
105
+
106
+ // unity-lanes.md
107
+ const srcU = readLF(path.join(srcDir, UNITY));
108
+ let unityOut = null;
109
+ if (srcU !== null) {
110
+ const dstU = readLF(path.join(dstDir, UNITY));
111
+ if (dstU === null) {
112
+ unityOut = srcU;
113
+ res.unity_lanes = { action: 'copied', diff: srcU.split('\n').filter((l) => l.trim()).length };
114
+ } else if (dstU !== srcU) {
115
+ const have = new Set(dstU.split('\n'));
116
+ res.unity_lanes = { action: 'kept', diff: srcU.split('\n').filter((l) => l.trim() && !have.has(l)).length };
117
+ }
118
+ }
119
+
120
+ if (!dry) {
121
+ if (lessonsOut !== null) core.writeFileAtomic(dstFile, lessonsOut);
122
+ if (pubOut !== null) core.writeFileAtomic(pubFile, pubOut);
123
+ if (unityOut !== null) core.writeFileAtomic(path.join(dstDir, UNITY), unityOut);
124
+ }
125
+ return res;
126
+ }
127
+
128
+ /** Dòng tóm tắt tiếng Việt cho `setup` (không in nội dung dòng). */
129
+ function seedSummary(res) {
130
+ if (res.skipped) {
131
+ return res.skipped === 'no-package-lessons'
132
+ ? 'Profile gói npm: không có (chạy từ bản đã đóng gói mới có lessons đã tuyển)'
133
+ : 'Profile gói npm: bỏ qua (không thấy profile/ của skill)';
134
+ }
135
+ const tag = res.dry_run ? ' (dry-run, không ghi)' : '';
136
+ const L = res.lessons.action === 'copied' ? `lessons.md chép mới (${res.lessons.added} dòng bài học)`
137
+ : res.lessons.action === 'appended' ? `lessons.md thêm ${res.lessons.added} dòng mới của bản tuyển`
138
+ : 'lessons.md không đổi';
139
+ const U = res.unity_lanes.action === 'copied' ? 'unity-lanes.md chép mới'
140
+ : res.unity_lanes.action === 'kept' ? `unity-lanes.md giữ bản cài (khác ${res.unity_lanes.diff} dòng so với gói; không ghi đè)`
141
+ : 'unity-lanes.md không đổi';
142
+ const P = res.published.action === 'appended' ? '; .lessons.published.md cộng thêm dòng đã tuyển'
143
+ : res.published.action === 'written' ? '; .lessons.published.md ghi mới' : '';
144
+ return `Profile: ${L}; ${U}${P}${tag}`;
145
+ }
146
+
147
+ module.exports = { PUBLISHED, BULLET_RE, bulletsOf, normLine, seedProfile, seedSummary };