@kienntpixon/dieu-phoi 0.7.2 → 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/load.cjs ADDED
@@ -0,0 +1,332 @@
1
+ 'use strict';
2
+ // core 0.7.3 (s5): đo tải để kiểm giả thuyết (contract jiptvo5g, mục 1) — số agent đồng thời và IO đĩa.
3
+ // dieu-phoi load sample [--interval 10] [--count 12] [--repo R] [--json] → data/load.jsonl, một mẫu một dòng
4
+ // dieu-phoi load report [--days 21] [--json] → phút ở mỗi mức đồng thời + tương quan IO
5
+ // Mẫu chỉ chứa số đếm và tốc độ IO: không có đường dẫn, prompt hay nội dung task (log không lộ nội dung).
6
+ // Hook SubagentStart ghi thêm mẫu chỉ-số-đếm (không IO) qua hook.cjs appendLoadSample.
7
+
8
+ const fs = require('fs');
9
+ const cp = require('child_process');
10
+ const core = require('../hook.cjs');
11
+ const args = require('./args.cjs');
12
+ const { mainCheckoutOf, markedWorktrees } = require('./worktree.cjs');
13
+
14
+ const DISK_READ = '\\PhysicalDisk(_Total)\\Disk Read Bytes/sec';
15
+ const DISK_WRITE = '\\PhysicalDisk(_Total)\\Disk Write Bytes/sec';
16
+ const PROC_DISKSTATS = '/proc/diskstats';
17
+ const SECTOR_BYTES = 512;
18
+ const DISK_WINDOW_MS = 1000;
19
+ /** Dispatch chưa có complete: tính tối đa bằng này (= stuck_hard_min mặc định của s6). */
20
+ const OPEN_CAP_MS = 120 * 60 * 1000;
21
+ const MIN_MS = 60 * 1000;
22
+ const WHOLE_DISK_RE = /^(sd[a-z]+|vd[a-z]+|xvd[a-z]+|hd[a-z]+|nvme\d+n\d+|mmcblk\d+)$/;
23
+ const MB = 1024 * 1024;
24
+
25
+ function sleepSync(ms) {
26
+ if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
27
+ }
28
+
29
+ function num(s) {
30
+ const t = String(s === undefined || s === null ? '' : s).trim();
31
+ return t === '' ? NaN : Number(t);
32
+ }
33
+
34
+ /** typeperf CSV: the last data row ("<date>","<read>","<write>") → {read, write}, or null. */
35
+ function parseTypeperf(text) {
36
+ let row = null;
37
+ for (const line of String(text || '').split(/\r?\n/)) {
38
+ const m = /^"(\d[^"]*)","([^"]*)","([^"]*)"$/.exec(line.trim());
39
+ if (m) row = m;
40
+ }
41
+ if (!row) return null;
42
+ const read = num(row[2]);
43
+ const write = num(row[3]);
44
+ if (!Number.isFinite(read) || !Number.isFinite(write)) return null;
45
+ return { read, write };
46
+ }
47
+
48
+ /** /proc/diskstats → byte totals over whole disks only (partitions, loop and ram devices skipped), or null. */
49
+ function parseDiskstats(text) {
50
+ let read = 0;
51
+ let write = 0;
52
+ let any = false;
53
+ for (const line of String(text || '').split('\n')) {
54
+ const p = line.trim().split(/\s+/);
55
+ if (p.length < 10 || !WHOLE_DISK_RE.test(p[2])) continue;
56
+ read += Number(p[5]) * SECTOR_BYTES;
57
+ write += Number(p[9]) * SECTOR_BYTES;
58
+ any = true;
59
+ }
60
+ return any ? { read, write } : null;
61
+ }
62
+
63
+ function typeperfRates() {
64
+ const out = cp.execFileSync('typeperf', [DISK_READ, DISK_WRITE, '-sc', '1'], {
65
+ encoding: 'utf8', windowsHide: true, timeout: 15000, stdio: ['ignore', 'pipe', 'ignore'],
66
+ });
67
+ return parseTypeperf(out);
68
+ }
69
+
70
+ function readDiskstats() {
71
+ try { return parseDiskstats(fs.readFileSync(PROC_DISKSTATS, 'utf8')); } catch (_) { return null; }
72
+ }
73
+
74
+ /**
75
+ * Bytes/s over one window. Windows: typeperf samples the window itself. POSIX: /proc/diskstats read twice around a
76
+ * DISK_WINDOW_MS sleep. Anything missing → null (never throws). deps lets the tests inject the platform and readers.
77
+ */
78
+ function diskRates(deps) {
79
+ const d = deps || {};
80
+ const platform = d.platform || process.platform;
81
+ let r = null;
82
+ try {
83
+ if (platform === 'win32') {
84
+ r = (d.typeperf || typeperfRates)();
85
+ } else {
86
+ const read = d.readDiskstats || readDiskstats;
87
+ const a = read();
88
+ (d.sleep || sleepSync)(DISK_WINDOW_MS);
89
+ const b = read();
90
+ if (a && b) {
91
+ const secs = DISK_WINDOW_MS / 1000;
92
+ r = { read: (b.read - a.read) / secs, write: (b.write - a.write) / secs };
93
+ }
94
+ }
95
+ } catch (_) {
96
+ r = null;
97
+ }
98
+ if (!r) return { disk_read_Bps: null, disk_write_Bps: null };
99
+ return {
100
+ disk_read_Bps: Math.max(0, Math.round(r.read)),
101
+ disk_write_Bps: Math.max(0, Math.round(r.write)),
102
+ };
103
+ }
104
+
105
+ /** Marked worktrees that still exist in the repo at `repoDir`, or null when it is not a git checkout. */
106
+ function markedCount(repoDir) {
107
+ try {
108
+ const { common } = mainCheckoutOf(repoDir);
109
+ return markedWorktrees(common).filter((w) => w.exists).length;
110
+ } catch (_) {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ /** One sample: counts (liveLoadCounts) + IO rates + marked worktrees. */
116
+ function collectSample(ctx, o) {
117
+ const now = Date.now();
118
+ const counts = core.liveLoadCounts(ctx, now);
119
+ const io = diskRates(o.deps);
120
+ return Object.assign({ ts: new Date(now).toISOString(), src: 'sample' }, counts,
121
+ { worktrees_marked: markedCount(o.repo) }, io);
122
+ }
123
+
124
+ /** Takes `count` samples `interval` seconds apart (start to start) and appends each to data/load.jsonl. */
125
+ function sampleLoop(ctx, o) {
126
+ const file = core.loadFile(ctx);
127
+ fs.mkdirSync(ctx.paths.data, { recursive: true });
128
+ const recs = [];
129
+ for (let i = 0; i < o.count; i++) {
130
+ const t0 = Date.now();
131
+ const rec = collectSample(ctx, o);
132
+ fs.appendFileSync(file, `${JSON.stringify(rec)}\n`);
133
+ recs.push(rec);
134
+ if (i < o.count - 1) sleepSync(Math.max(0, o.interval * 1000 - (Date.now() - t0)));
135
+ }
136
+ return recs;
137
+ }
138
+
139
+ function numArg(v, dflt, name, min, max) {
140
+ if (v === undefined) return dflt;
141
+ const n = num(v);
142
+ if (!Number.isFinite(n) || n < min || n > max) throw new Error(`--${name} phải là số trong [${min}, ${max}]`);
143
+ return n;
144
+ }
145
+
146
+ function mbps(v) {
147
+ return Number.isFinite(v) ? `${(v / MB).toFixed(2)} MB/s` : 'n/a';
148
+ }
149
+
150
+ function cmdLoadSample(opts, deps) {
151
+ const ctx = args.ctxOf(opts);
152
+ const count = Math.floor(numArg(opts.count, 12, 'count', 1, 100000));
153
+ const interval = numArg(opts.interval, 10, 'interval', 0, 86400);
154
+ const repo = args.repoOf(opts);
155
+ const recs = sampleLoop(ctx, { count, interval, repo, deps });
156
+ if (opts.json) return { code: 0, out: JSON.stringify(recs, null, 2) };
157
+ const lines = recs.map((r) => ` ${r.ts} · máy ${r.live_machine} · hàng đợi ${r.queue} · đọc ${mbps(r.disk_read_Bps)} · ghi ${mbps(r.disk_write_Bps)}`);
158
+ return { code: 0, out: `dieu-phoi load sample: ${recs.length} mẫu → ${core.loadFile(ctx)}\n${lines.join('\n')}` };
159
+ }
160
+
161
+ /** Dispatch/complete events → intervals. A complete closes the oldest open dispatch of the same session+task+role. */
162
+ function pairIntervals(events, nowMs) {
163
+ const sorted = events.slice().sort((a, b) => Date.parse(a.ts) - Date.parse(b.ts));
164
+ const open = new Map();
165
+ const out = [];
166
+ for (const e of sorted) {
167
+ const t = Date.parse(e.ts);
168
+ const key = `${e.session || ''}|${e.task_id || ''}|${e.role || ''}`;
169
+ if (e.kind === 'dispatch') {
170
+ if (!open.has(key)) open.set(key, []);
171
+ open.get(key).push({ t, session: e.session || '-' });
172
+ } else {
173
+ const q = open.get(key);
174
+ if (q && q.length) {
175
+ const s = q.shift();
176
+ out.push({ s: s.t, e: t, session: s.session, paired: true });
177
+ }
178
+ }
179
+ }
180
+ let unpaired = 0;
181
+ for (const q of open.values()) {
182
+ for (const s of q) {
183
+ unpaired += 1;
184
+ out.push({ s: s.t, e: Math.min(s.t + OPEN_CAP_MS, nowMs), session: s.session, paired: false });
185
+ }
186
+ }
187
+ return { intervals: out, unpaired };
188
+ }
189
+
190
+ function addMs(map, level, ms) {
191
+ map.set(level, (map.get(level) || 0) + ms);
192
+ }
193
+
194
+ /** Milliseconds at each concurrency level: machine (every interval) and busiest session (max over sessions). */
195
+ function levelTimes(intervals) {
196
+ const marks = [];
197
+ for (const iv of intervals) {
198
+ marks.push({ t: iv.s, d: 1, sess: iv.session });
199
+ marks.push({ t: iv.e, d: -1, sess: iv.session });
200
+ }
201
+ marks.sort((a, b) => (a.t - b.t) || (a.d - b.d)); // ends before starts at the same instant
202
+ const machine = new Map();
203
+ const busiest = new Map();
204
+ const perSession = new Map();
205
+ let cur = 0;
206
+ let maxS = 0;
207
+ let last = null;
208
+ for (const m of marks) {
209
+ if (last !== null && m.t > last) {
210
+ if (cur > 0) addMs(machine, cur, m.t - last);
211
+ if (maxS > 0) addMs(busiest, maxS, m.t - last);
212
+ }
213
+ last = m.t;
214
+ cur += m.d;
215
+ perSession.set(m.sess, (perSession.get(m.sess) || 0) + m.d);
216
+ maxS = 0;
217
+ for (const v of perSession.values()) if (v > maxS) maxS = v;
218
+ }
219
+ return { machine, busiest };
220
+ }
221
+
222
+ function pearson(xs, ys) {
223
+ const n = xs.length;
224
+ if (n < 3) return null;
225
+ const mx = xs.reduce((a, b) => a + b, 0) / n;
226
+ const my = ys.reduce((a, b) => a + b, 0) / n;
227
+ let cov = 0;
228
+ let vx = 0;
229
+ let vy = 0;
230
+ for (let i = 0; i < n; i++) {
231
+ cov += (xs[i] - mx) * (ys[i] - my);
232
+ vx += (xs[i] - mx) ** 2;
233
+ vy += (ys[i] - my) ** 2;
234
+ }
235
+ if (vx === 0 || vy === 0) return null;
236
+ return cov / Math.sqrt(vx * vy);
237
+ }
238
+
239
+ function readLoadSamples(ctx, sinceMs) {
240
+ let text = '';
241
+ try { text = fs.readFileSync(core.loadFile(ctx), 'utf8'); } catch (_) { return []; }
242
+ const out = [];
243
+ for (const line of text.split('\n')) {
244
+ if (!line.trim()) continue;
245
+ let r = null;
246
+ try { r = JSON.parse(line); } catch (_) { continue; }
247
+ if (r && Date.parse(r.ts) >= sinceMs) out.push(r);
248
+ }
249
+ return out;
250
+ }
251
+
252
+ /** Correlation of disk read with the machine's live agent count, on samples that carry both. */
253
+ function ioCorrelation(samples) {
254
+ const rows = samples.filter((r) => Number.isFinite(r.live_machine) && Number.isFinite(r.disk_read_Bps));
255
+ const byLevel = new Map();
256
+ for (const r of rows) {
257
+ const g = byLevel.get(r.live_machine) || { n: 0, read: 0, write: 0, writeN: 0 };
258
+ g.n += 1;
259
+ g.read += r.disk_read_Bps;
260
+ if (Number.isFinite(r.disk_write_Bps)) { g.write += r.disk_write_Bps; g.writeN += 1; }
261
+ byLevel.set(r.live_machine, g);
262
+ }
263
+ const levels = [...byLevel.entries()].sort((a, b) => a[0] - b[0]).map(([level, g]) => ({
264
+ live_machine: level,
265
+ samples: g.n,
266
+ mean_read_Bps: Math.round(g.read / g.n),
267
+ mean_write_Bps: g.writeN ? Math.round(g.write / g.writeN) : null,
268
+ }));
269
+ return {
270
+ samples: rows.length,
271
+ r: pearson(rows.map((r) => r.live_machine), rows.map((r) => r.disk_read_Bps)),
272
+ levels,
273
+ };
274
+ }
275
+
276
+ function cmdLoadReport(opts) {
277
+ const ctx = args.ctxOf(opts);
278
+ const days = numArg(opts.days, 21, 'days', 0.01, 3650);
279
+ const now = Date.now();
280
+ const since = now - days * 86400000;
281
+ const events = core.readEvents(ctx).filter((e) => e && (e.kind === 'dispatch' || e.kind === 'complete')
282
+ && Date.parse(e.ts) >= since);
283
+ const { intervals, unpaired } = pairIntervals(events, now);
284
+ const { machine, busiest } = levelTimes(intervals);
285
+ const toMin = (ms) => Math.round(ms / MIN_MS * 10) / 10;
286
+ const levels = new Map();
287
+ for (const k of new Set([...machine.keys(), ...busiest.keys()])) {
288
+ levels.set(k, { machine_min: toMin(machine.get(k) || 0), busiest_session_min: toMin(busiest.get(k) || 0) });
289
+ }
290
+ const atLeast = (m, n) => toMin([...m.entries()].filter(([k]) => k >= n).reduce((a, [, v]) => a + v, 0));
291
+ const samples = readLoadSamples(ctx, since);
292
+ const corr = ioCorrelation(samples);
293
+ const summary = {
294
+ days,
295
+ pairs: intervals.filter((i) => i.paired).length,
296
+ unpaired,
297
+ machine_min_at_least: { 7: atLeast(machine, 7), 9: atLeast(machine, 9) },
298
+ session_min_at_least: { 4: atLeast(busiest, 4) },
299
+ levels: [...levels.entries()].sort((a, b) => a[0] - b[0]).map(([level, v]) => Object.assign({ level }, v)),
300
+ load: { samples: samples.length, io_samples: corr.samples, r: corr.r === null ? null : Math.round(corr.r * 100) / 100, by_machine: corr.levels },
301
+ };
302
+ if (opts.json) return { code: 0, out: JSON.stringify(summary, null, 2) };
303
+ return { code: 0, out: formatReport(summary) };
304
+ }
305
+
306
+ function formatReport(s) {
307
+ const head = `dieu-phoi load report — ${s.days} ngày · ${s.pairs} agent có cả dispatch và complete · ${s.unpaired} dispatch chưa đóng (tính tối đa 120 phút)`;
308
+ const rows = ['Đồng thời | toàn máy (phút) | phiên lớn nhất (phút)', '---|---|---'];
309
+ for (const l of s.levels) rows.push(`${l.level} | ${l.machine_min} | ${l.busiest_session_min}`);
310
+ if (!s.levels.length) rows.push('(không có agent trong khoảng này)');
311
+ const mark = `Mốc: toàn máy ≥7 agent ${s.machine_min_at_least[7]} phút · toàn máy ≥9 ${s.machine_min_at_least[9]} phút · một phiên ≥4 ${s.session_min_at_least[4]} phút`;
312
+ const ld = s.load;
313
+ const corrLine = ld.io_samples < 3
314
+ ? `Tải IO: ${ld.samples} mẫu trong data/load.jsonl, ${ld.io_samples} có IO đĩa — chưa đủ để tương quan (chạy \`dieu-phoi load sample\`).`
315
+ : `Tải IO: ${ld.io_samples} mẫu có IO · tương quan r(agent toàn máy, đọc đĩa) = ${ld.r === null ? 'n/a' : ld.r} (chỉ báo: IO là của cả máy, không riêng agent).`;
316
+ const lvl = ld.by_machine.map((g) => `${g.live_machine} | ${g.samples} | ${(g.mean_read_Bps / MB).toFixed(2)} | ${g.mean_write_Bps === null ? 'n/a' : (g.mean_write_Bps / MB).toFixed(2)}`);
317
+ const lvlTable = lvl.length ? ['Agent toàn máy | mẫu | đọc TB (MB/s) | ghi TB (MB/s)', '---|---|---|---', ...lvl] : [];
318
+ return [head, '', ...rows, '', mark, '', corrLine, ...lvlTable].join('\n');
319
+ }
320
+
321
+ function cmdLoad(opts) {
322
+ const sub = opts._[0];
323
+ if (sub === 'sample') return cmdLoadSample(opts);
324
+ if (sub === 'report') return cmdLoadReport(opts);
325
+ throw new Error('Cú pháp: dieu-phoi load sample [--interval 10] [--count 12] [--repo R] [--json]\n'
326
+ + ' dieu-phoi load report [--days 21] [--json]');
327
+ }
328
+
329
+ module.exports = {
330
+ cmdLoad, cmdLoadSample, cmdLoadReport, parseTypeperf, parseDiskstats, diskRates, collectSample, sampleLoop,
331
+ pairIntervals, levelTimes, pearson, ioCorrelation, readLoadSamples, DISK_READ, DISK_WRITE, OPEN_CAP_MS,
332
+ };
package/lib/procs.cjs CHANGED
@@ -2,7 +2,7 @@
2
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
3
  // plain list [{pid, ppid, name, created (epoch ms | null), ws (bytes | null), cmd}], so tests feed fixture lists and never
4
4
  // touch the system. Only listProcesses() and killTree() reach the OS; listProcesses never throws (null on any failure),
5
- // killTree is called by the hook only when config kill_leftovers is true.
5
+ // killTree (kill_leftovers:true, the 0.7.2 behaviour) and killVerified (s7, default 'guarded') are the only kill paths.
6
6
 
7
7
  const cp = require('child_process');
8
8
 
@@ -216,8 +216,207 @@ function killTree(pids, o) {
216
216
  return done;
217
217
  }
218
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
+
219
416
  module.exports = {
220
417
  PS_WIN, ORPHAN_NAMES, LEFTOVER_SKIP_NAMES,
221
418
  parseWindowsJson, parsePs, listProcesses,
222
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,
223
422
  };
package/lib/prune.cjs CHANGED
@@ -321,6 +321,8 @@ function cmdPrune(opts) {
321
321
  bytes: rp.prune.reduce((n, r) => n + r.delete_bytes, 0) + plans.reduce((n, p) => n + p.bytes, 0),
322
322
  };
323
323
 
324
+ let removedLines = 0; // lines actually dropped from events.jsonl under the lock (what the message reports)
325
+ let freshCount = 0; // of those, lines newly written into the archive (the rest were there from an earlier run)
324
326
  if (yes) {
325
327
  // 0. a session other than this one touched the log recently => refuse (a live hook could be mid-append)
326
328
  const busy = opts.force === true ? null : otherSessionActive(ctx, opts, allEvents);
@@ -335,6 +337,7 @@ function cmdPrune(opts) {
335
337
  // 1. events → archive (archive first + verified, then rewrite events.jsonl via temp + rename)
336
338
  if (moving.length) {
337
339
  const fresh = moving.filter((l) => !archived.has(l.text));
340
+ freshCount = fresh.length;
338
341
  const lock = core.acquireLock(ctx.paths.lock);
339
342
  if (!lock) return { code: 1, out: 'prune: sync đang giữ khoá — thử lại sau' };
340
343
  try {
@@ -351,6 +354,7 @@ function cmdPrune(opts) {
351
354
  for (const l of core.eventLinesFrom(buf, 0)) {
352
355
  parts.push(buf.subarray(pos, l.start)); // blank lines between
353
356
  if (drop.get(l.start) === l.text) {
357
+ removedLines++;
354
358
  if (l.end <= off) removedBeforeOffset += l.end - l.start;
355
359
  } else parts.push(buf.subarray(l.start, l.end));
356
360
  pos = l.end;
@@ -367,6 +371,8 @@ function cmdPrune(opts) {
367
371
  try { fs.unlinkSync(ctx.paths.lock); } catch (_) { /* gone */ }
368
372
  }
369
373
  }
374
+ report.events.moved = removedLines;
375
+ report.events.archived_new = freshCount;
370
376
  // 2a. unmerged deltas older than 30 d without a user verdict -> runs/_archive/deltas/<run>/ (moved, not deleted)
371
377
  for (const r of rp.prune) {
372
378
  for (const n of r.archive) {
@@ -398,7 +404,10 @@ function cmdPrune(opts) {
398
404
  L.push(`Run dir: ${rp.prune.length} sẽ dọn, ${rp.skipped.length} giữ nguyên (còn mở / sau mốc)`);
399
405
  for (const r of rp.prune) L.push(` ${r.id}: xoá ${r.del.length} mục (${human(r.delete_bytes)}), giữ ${r.keep.length} (${human(r.keep_bytes)}), trong đó delta chưa gộp ${r.keep_deltas}${r.archive.length ? `; chuyển ${r.archive.length} delta >30 ngày chưa có verdict sang _archive/deltas/` : ''}`);
400
406
  L.push(`Plan hỏng: ${plans.length} (${human(plans.reduce((n, p) => n + p.bytes, 0))})${plans.length ? ` — ${plans.map((p) => p.name).join(', ')}` : ''}`);
401
- L.push(`events.jsonl: ${moving.length}/${lines.length} dòng → archive/events-${archiveToday()}.jsonl.gz (${human(evBytes)})${heldBack ? `; ${heldBack} dòng chưa đẩy lên sink nên giữ lại` : ''}; không xoá dòng nào`);
407
+ const evTxt = yes
408
+ ? `đã chuyển ${removedLines}/${lines.length} dòng vào archive và xoá khỏi events.jsonl (${freshCount} dòng ghi mới${freshCount ? ` vào archive/events-${archiveToday()}.jsonl.gz` : ''}; ${human(evBytes)})`
409
+ : `sẽ chuyển ${moving.length}/${lines.length} dòng vào archive/events-${archiveToday()}.jsonl.gz và xoá khỏi events.jsonl (${human(evBytes)})`;
410
+ L.push(`events.jsonl: ${evTxt}${heldBack ? `; ${heldBack} dòng chưa đẩy lên sink nên giữ lại` : ''}`);
402
411
  L.push(`Tổng xoá khỏi đĩa: ${report.totals.files} mục run dir + ${plans.length} plan = ${human(report.totals.bytes)}`);
403
412
  return { code: 0, out: L.join('\n') };
404
413
  }