@kienntpixon/dieu-phoi 0.7.2 → 0.7.4

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 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
@@ -1,6 +1,8 @@
1
1
  'use strict';
2
- // `dieu-phoi prune --after-retro <file> [--yes]` (core 0.5.2): post-retro cleanup of closed run dirs, bad plan
3
- // files and events.jsonl (moved to a gz archive, never deleted). Dry-run unless --yes.
2
+ // `dieu-phoi prune --after-retro <file> [--yes] [--delta-days N]` (core 0.5.2): post-retro cleanup of closed run dirs,
3
+ // bad plan files and events.jsonl (moved to a gz archive, never deleted). Dry-run unless --yes. 0.7.4 (s6): with
4
+ // --delta-days N (default 30) rejected unmerged deltas of runs idle > N days are deleted; accepted ones never.
5
+ // s9: `--delta-days N` alone (no --after-retro) runs only that delta step (cmdPruneDeltas).
4
6
  // Only touches: <runs dir>/<id>/, <data>/plans, <data>/events.jsonl, <data>/archive, <data>/sync-offset.json.
5
7
 
6
8
  const fs = require('fs');
@@ -58,6 +60,8 @@ function human(n) {
58
60
  // contract/steps/plan/handoff files of a run dir: the record of what was planned and handed over (never pruned)
59
61
  const KEEP_RE = /^(?:contract-r.*\.md|steps-r.*\.json|plan.*\.md|handoff-.*\.md)$/;
60
62
  const DELTA_RE = /^knowledge-delta.*\.md$/i;
63
+ const DAY_MS = 86400000;
64
+ const DELTA_DAYS_DEFAULT = 30;
61
65
 
62
66
  /**
63
67
  * Files to keep in a run dir: retro-*.md + per task/role the highest-try result AND the newest-by-mtime result
@@ -123,6 +127,7 @@ function taskIdsOfDir(id, names, plansDir) {
123
127
  function planRuns(ctx, events, cutoffMs, protect, popts) {
124
128
  const po = popts || {};
125
129
  const nowMs = typeof po.now === 'number' ? po.now : Date.now();
130
+ const deltaDays = typeof po.deltaDays === 'number' ? po.deltaDays : 0;
126
131
  const cls = core.deltaClassifier(events, ctx.paths.plans);
127
132
  const runs = core.runsDir();
128
133
  const byTask = new Map();
@@ -149,6 +154,19 @@ function planRuns(ctx, events, cutoffMs, protect, popts) {
149
154
  if (!inside(runs, dir)) continue;
150
155
  const names = fs.readdirSync(dir);
151
156
  const tasks = Array.from(taskIdsOfDir(id, names, ctx.paths.plans));
157
+ if (po.deltaOnly) { // s9 `prune --delta-days N` alone: only the rejected unmerged deltas of an idle run, nothing else
158
+ let runMs = 0;
159
+ for (const n of names) { try { runMs = Math.max(runMs, fs.lstatSync(path.join(dir, n)).mtimeMs); } catch (_) { /* gone */ } }
160
+ if (nowMs - runMs <= deltaDays * DAY_MS) continue;
161
+ const drops = names.filter((n) => {
162
+ if (!DELTA_RE.test(n) || cls.deltaState(id, core.deltaTagOf(n)) !== 'reject') return false;
163
+ let text = null;
164
+ try { text = fs.readFileSync(path.join(dir, n), 'utf8'); } catch (_) { return false; }
165
+ return !appliedSet().has(core.deltaHash(text));
166
+ });
167
+ if (drops.length) out.prune.push({ id, dir, tasks, keep: [], keep_deltas: 0, archive: [], drop_deltas: drops, del: [], delete_bytes: 0, keep_bytes: 0 });
168
+ continue;
169
+ }
152
170
  let anyVerdict = false; let reason = null; let latest = 0;
153
171
  for (const t of tasks) {
154
172
  const evs = byTask.get(t) || [];
@@ -189,6 +207,21 @@ function planRuns(ctx, events, cutoffMs, protect, popts) {
189
207
  if (nowMs - m > core.DELTA_WINDOW_MS && cls.deltaState(id, core.deltaTagOf(n)) === null) archive.push(n);
190
208
  }
191
209
  for (const n of archive) keep.delete(n);
210
+ // s6 --delta-days N: a delta the user REJECTED, unmerged, in a run idle for more than N days, is no work any more:
211
+ // deleted with --yes. Accepted-but-unmerged (K7) and no-verdict deltas (K1: archived above, never deleted) stay.
212
+ const dropDeltas = [];
213
+ if (deltaDays > 0) {
214
+ let runMs = 0;
215
+ for (const n of names) { try { runMs = Math.max(runMs, fs.lstatSync(path.join(dir, n)).mtimeMs); } catch (_) { /* gone */ } }
216
+ if (nowMs - runMs > deltaDays * DAY_MS) {
217
+ for (const n of names) {
218
+ if (!DELTA_RE.test(n) || !keep.has(n) || archive.includes(n)) continue;
219
+ if (cls.deltaState(id, core.deltaTagOf(n)) !== 'reject' || !unmerged(n)) continue;
220
+ keep.delete(n);
221
+ dropDeltas.push(n);
222
+ }
223
+ }
224
+ }
192
225
  const keepDeltas = names.filter((n) => DELTA_RE.test(n) && keep.has(n)).length;
193
226
  const del = [];
194
227
  let delBytes = 0; let keepBytes = 0;
@@ -200,7 +233,7 @@ function planRuns(ctx, events, cutoffMs, protect, popts) {
200
233
  del.push({ name: n, bytes: b });
201
234
  delBytes += b;
202
235
  }
203
- out.prune.push({ id, dir, tasks, keep: Array.from(keep).sort(), keep_deltas: keepDeltas, archive, del, delete_bytes: delBytes, keep_bytes: keepBytes });
236
+ out.prune.push({ id, dir, tasks, keep: Array.from(keep).sort(), keep_deltas: keepDeltas, archive, drop_deltas: dropDeltas, del, delete_bytes: delBytes, keep_bytes: keepBytes });
204
237
  }
205
238
  return out;
206
239
  }
@@ -275,10 +308,46 @@ function otherSessionActive(ctx, opts, events) {
275
308
  return count ? { count, sessions: Array.from(sessions), last: new Date(last).toISOString(), ownKnown: !!own } : null;
276
309
  }
277
310
 
311
+ /**
312
+ * s9 `prune --delta-days N` without --after-retro: deletes (with --yes) only the rejected, unmerged deltas of runs idle
313
+ * for more than N days. No run dir is archived, no event moves, no bad plan goes. Dry-run otherwise.
314
+ */
315
+ function cmdPruneDeltas(opts, ctx) {
316
+ const dd = opts['delta-days'];
317
+ const deltaDays = dd === true ? DELTA_DAYS_DEFAULT : Number(dd);
318
+ if (!Number.isFinite(deltaDays) || deltaDays < 0) return { code: 2, out: `prune: --delta-days phải là số ngày ≥ 0 (nhận: ${dd})` };
319
+ const runs = core.runsDir();
320
+ if (!path.isAbsolute(runs) || runs === path.parse(runs).root) return { code: 2, out: `prune: từ chối đường dẫn runs không an toàn: ${runs}` };
321
+ const yes = opts.yes === true || opts.yes === 'y' || opts.yes === 'yes';
322
+ const rp = planRuns(ctx, core.readEvents(ctx), 0, null, { now: typeof opts.now === 'number' ? opts.now : undefined, deltaDays, deltaOnly: true });
323
+ let removed = 0;
324
+ if (yes) {
325
+ for (const r of rp.prune) {
326
+ for (const n of r.drop_deltas) {
327
+ const p = path.join(r.dir, n);
328
+ if (!inside(r.dir, p) || !inside(runs, p)) continue;
329
+ fs.rmSync(p, { force: true });
330
+ removed++;
331
+ }
332
+ }
333
+ }
334
+ const total = rp.prune.reduce((n, r) => n + r.drop_deltas.length, 0);
335
+ if (opts.json) {
336
+ return { code: 0, out: JSON.stringify({ mode: yes ? 'apply' : 'dry-run', delta_only: true, delta_days: deltaDays,
337
+ runs: rp.prune.map((r) => ({ id: r.id, drop_deltas: r.drop_deltas })), totals: { drop_deltas: total, removed } }, null, 2) };
338
+ }
339
+ const L = [];
340
+ L.push(`prune --delta-days ${deltaDays} ${yes ? '(ĐÃ THỰC HIỆN)' : '(dry-run — thêm --yes để thực hiện)'}: chỉ delta đã từ chối, chưa gộp, của run rảnh > ${deltaDays} ngày`);
341
+ for (const r of rp.prune) L.push(` ${r.id}: ${yes ? 'xoá' : 'sẽ xoá'} ${r.drop_deltas.length} delta (${r.drop_deltas.join(', ')})`);
342
+ L.push(`Tổng: ${total} delta ${yes ? `đã xoá ${removed}` : 'sẽ xoá'}. Không đụng run dir khác, events.jsonl, plan hay archive.`);
343
+ return { code: 0, out: L.join('\n') };
344
+ }
345
+
278
346
  function cmdPrune(opts) {
279
347
  const ctx = ctxOf(opts);
280
348
  const retro = typeof opts['after-retro'] === 'string' ? path.resolve(opts['after-retro']) : null;
281
- if (!retro) return { code: 2, out: 'prune: thiếu --after-retro <file retro>' };
349
+ if (!retro && opts['delta-days'] !== undefined) return cmdPruneDeltas(opts, ctx);
350
+ if (!retro) return { code: 2, out: 'prune: thiếu --after-retro <file retro> (hoặc --delta-days N để chỉ dọn delta đã từ chối)' };
282
351
  let st; try { st = fs.statSync(retro); } catch (_) { st = null; }
283
352
  if (!st || !st.isFile()) return { code: 2, out: `prune: không đọc được file retro: ${retro}` };
284
353
  const runs = core.runsDir();
@@ -287,10 +356,14 @@ function cmdPrune(opts) {
287
356
  if (!path.isAbsolute(p) || p === path.parse(p).root) return { code: 2, out: `prune: từ chối đường dẫn ${name} không an toàn: ${p}` };
288
357
  }
289
358
  const yes = opts.yes === true || opts.yes === 'y' || opts.yes === 'yes';
359
+ // --delta-days N (default 30; 0 = off): rejected unmerged deltas of runs idle > N days are deleted with --yes
360
+ const dd = opts['delta-days'];
361
+ const deltaDays = dd === undefined ? DELTA_DAYS_DEFAULT : (dd === true ? DELTA_DAYS_DEFAULT : Number(dd));
362
+ if (!Number.isFinite(deltaDays) || deltaDays < 0) return { code: 2, out: `prune: --delta-days phải là số ngày ≥ 0 (nhận: ${dd})` };
290
363
  const cut = cutoffOf(retro);
291
364
  const { lines } = fs.existsSync(ctx.paths.events) ? eventLines(ctx) : { lines: [] };
292
365
  const allEvents = core.readEvents(ctx);
293
- const rp = planRuns(ctx, allEvents, cut.ms, retro, { now: typeof opts.now === 'number' ? opts.now : undefined });
366
+ const rp = planRuns(ctx, allEvents, cut.ms, retro, { now: typeof opts.now === 'number' ? opts.now : undefined, deltaDays });
294
367
  const prunedTasks = new Set();
295
368
  for (const r of rp.prune) for (const t of r.tasks) prunedTasks.add(t);
296
369
  const plans = badPlans(ctx);
@@ -308,7 +381,7 @@ function cmdPrune(opts) {
308
381
 
309
382
  const report = {
310
383
  mode: yes ? 'apply' : 'dry-run', retro, cutoff: new Date(cut.ms).toISOString(), cutoff_source: cut.source,
311
- runs: rp.prune.map((r) => ({ id: r.id, delete_files: r.del.length, delete_bytes: r.delete_bytes, keep: r.keep, keep_deltas: r.keep_deltas, archive_deltas: r.archive.length, keep_bytes: r.keep_bytes })),
384
+ runs: rp.prune.map((r) => ({ id: r.id, delete_files: r.del.length, delete_bytes: r.delete_bytes, keep: r.keep, keep_deltas: r.keep_deltas, archive_deltas: r.archive.length, drop_deltas: r.drop_deltas.length, keep_bytes: r.keep_bytes })),
312
385
  runs_skipped: rp.skipped.length,
313
386
  skipped_reasons: rp.skipped.reduce((a, s) => { const k = s.reason.replace(/task \S+/, 'task *').replace(/\(task \S+\)/, ''); a[k] = (a[k] || 0) + 1; return a; }, {}),
314
387
  bad_plans: plans.map((p) => ({ name: p.name, bytes: p.bytes })),
@@ -317,10 +390,13 @@ function cmdPrune(opts) {
317
390
  report.totals = {
318
391
  run_dirs: rp.prune.length,
319
392
  archive_deltas: rp.prune.reduce((n, r) => n + r.archive.length, 0),
393
+ drop_deltas: rp.prune.reduce((n, r) => n + r.drop_deltas.length, 0),
320
394
  files: rp.prune.reduce((n, r) => n + r.del.length, 0),
321
395
  bytes: rp.prune.reduce((n, r) => n + r.delete_bytes, 0) + plans.reduce((n, p) => n + p.bytes, 0),
322
396
  };
323
397
 
398
+ let removedLines = 0; // lines actually dropped from events.jsonl under the lock (what the message reports)
399
+ let freshCount = 0; // of those, lines newly written into the archive (the rest were there from an earlier run)
324
400
  if (yes) {
325
401
  // 0. a session other than this one touched the log recently => refuse (a live hook could be mid-append)
326
402
  const busy = opts.force === true ? null : otherSessionActive(ctx, opts, allEvents);
@@ -335,6 +411,7 @@ function cmdPrune(opts) {
335
411
  // 1. events → archive (archive first + verified, then rewrite events.jsonl via temp + rename)
336
412
  if (moving.length) {
337
413
  const fresh = moving.filter((l) => !archived.has(l.text));
414
+ freshCount = fresh.length;
338
415
  const lock = core.acquireLock(ctx.paths.lock);
339
416
  if (!lock) return { code: 1, out: 'prune: sync đang giữ khoá — thử lại sau' };
340
417
  try {
@@ -351,6 +428,7 @@ function cmdPrune(opts) {
351
428
  for (const l of core.eventLinesFrom(buf, 0)) {
352
429
  parts.push(buf.subarray(pos, l.start)); // blank lines between
353
430
  if (drop.get(l.start) === l.text) {
431
+ removedLines++;
354
432
  if (l.end <= off) removedBeforeOffset += l.end - l.start;
355
433
  } else parts.push(buf.subarray(l.start, l.end));
356
434
  pos = l.end;
@@ -367,6 +445,8 @@ function cmdPrune(opts) {
367
445
  try { fs.unlinkSync(ctx.paths.lock); } catch (_) { /* gone */ }
368
446
  }
369
447
  }
448
+ report.events.moved = removedLines;
449
+ report.events.archived_new = freshCount;
370
450
  // 2a. unmerged deltas older than 30 d without a user verdict -> runs/_archive/deltas/<run>/ (moved, not deleted)
371
451
  for (const r of rp.prune) {
372
452
  for (const n of r.archive) {
@@ -396,9 +476,12 @@ function cmdPrune(opts) {
396
476
  const L = [];
397
477
  L.push(`prune ${yes ? '(ĐÃ THỰC HIỆN)' : '(dry-run — thêm --yes để thực hiện)'}: mốc cắt ${report.cutoff} (${cut.source}) từ ${retro}`);
398
478
  L.push(`Run dir: ${rp.prune.length} sẽ dọn, ${rp.skipped.length} giữ nguyên (còn mở / sau mốc)`);
399
- 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/` : ''}`);
479
+ 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/` : ''}${r.drop_deltas.length ? `; xoá ${r.drop_deltas.length} delta đã từ chối (run nằm yên >${deltaDays} ngày)` : ''}`);
400
480
  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`);
481
+ const evTxt = yes
482
+ ? `đã 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)})`
483
+ : `sẽ chuyển ${moving.length}/${lines.length} dòng vào archive/events-${archiveToday()}.jsonl.gz và xoá khỏi events.jsonl (${human(evBytes)})`;
484
+ L.push(`events.jsonl: ${evTxt}${heldBack ? `; ${heldBack} dòng chưa đẩy lên sink nên giữ lại` : ''}`);
402
485
  L.push(`Tổng xoá khỏi đĩa: ${report.totals.files} mục run dir + ${plans.length} plan = ${human(report.totals.bytes)}`);
403
486
  return { code: 0, out: L.join('\n') };
404
487
  }
package/lib/setup.cjs CHANGED
@@ -67,7 +67,7 @@ function stripOurHooks(settings) {
67
67
  // (an old `Agent`-only registration is not "already registered", so `setup` rewrites it).
68
68
  const AGENT_MATCHER = 'Agent|Task';
69
69
 
70
- /** True when settings already hold exactly our five entries with this command. */
70
+ /** True when settings already hold exactly our six entries with this command. */
71
71
  function alreadyRegistered(settings, cmd) {
72
72
  const hooks = settings && settings.hooks;
73
73
  if (!hooks || typeof hooks !== 'object') return false;
@@ -81,13 +81,14 @@ function alreadyRegistered(settings, cmd) {
81
81
  if (h.command !== cmd) continue;
82
82
  if (event === 'PostToolUse' && g.matcher === AGENT_MATCHER) good++;
83
83
  else if (event === 'SubagentStop') good++;
84
+ else if (event === 'SubagentStart' && !g.matcher) good++;
84
85
  else if (event === 'SessionStart' && g.matcher === 'compact') good++;
85
86
  else if (event === 'UserPromptSubmit' && !g.matcher) good++;
86
87
  else if (event === 'PreToolUse' && g.matcher === AGENT_MATCHER) good++;
87
88
  }
88
89
  }
89
90
  }
90
- return ours === 5 && good === 5;
91
+ return ours === 6 && good === 6;
91
92
  }
92
93
 
93
94
  function addOurHooks(settings, hookPath) {
@@ -98,6 +99,9 @@ function addOurHooks(settings, hookPath) {
98
99
  h.PostToolUse.push({ matcher: AGENT_MATCHER, hooks: [{ type: 'command', command: cmd, timeout: 10 }] });
99
100
  if (!Array.isArray(h.SubagentStop)) h.SubagentStop = [];
100
101
  h.SubagentStop.push({ hooks: [{ type: 'command', command: cmd, timeout: 10 }] });
102
+ // core 0.7.3 (s1): live-agent register (data/live/<agent_id>.json) for the parallel cap; no matcher = every subagent
103
+ if (!Array.isArray(h.SubagentStart)) h.SubagentStart = [];
104
+ h.SubagentStart.push({ hooks: [{ type: 'command', command: cmd, timeout: 5 }] });
101
105
  // after /compact or auto-compact: re-inject the orchestration state rebuilt from the log
102
106
  if (!Array.isArray(h.SessionStart)) h.SessionStart = [];
103
107
  h.SessionStart.push({ matcher: 'compact', hooks: [{ type: 'command', command: cmd, timeout: 10 }] });
@@ -282,6 +286,22 @@ function nodePathOf(cmd) {
282
286
  return m ? m[1] : undefined;
283
287
  }
284
288
 
289
+ function realOf(p) {
290
+ try { return fs.realpathSync.native(p); } catch (_) { return path.resolve(p); }
291
+ }
292
+
293
+ /**
294
+ * Rewrite hook commands inside an installed agent file whose node/hook paths resolve (symlink/junction, e.g. an
295
+ * Orca profile linking ~/.claude) to the same real files as ours, so the comparison ignores path spelling only.
296
+ */
297
+ function normalizeHookPaths(cur, hookPath, nodePath) {
298
+ const myNode = realOf(nodePath || process.execPath);
299
+ const myHook = realOf(hookPath);
300
+ const mine = hookCommand(hookPath, nodePath).replace(/'/g, "''");
301
+ return cur.replace(/'"([^"']+)" "([^"']+)"( --[A-Za-z-]+)'/g, (m, n, h, flag) =>
302
+ (realOf(n) === myNode && realOf(h) === myHook ? `'${mine}${flag}'` : m));
303
+ }
304
+
285
305
  /**
286
306
  * State of each dp-* agent file: ok (ours, identical), outdated (ours, differs), missing, foreign (a file with
287
307
  * that name we did not write — never touched).
@@ -295,7 +315,10 @@ function agentStatus(claudeDir, hookPath, nodePath) {
295
315
  let state = 'missing';
296
316
  if (cur !== null) {
297
317
  if (!cur.includes(AGENT_MARKER)) state = 'foreign';
298
- else state = cur === renderAgent(name, hookPath, nodePath) ? 'ok' : 'outdated';
318
+ else {
319
+ const want = renderAgent(name, hookPath, nodePath);
320
+ state = (cur === want || normalizeHookPaths(cur, hookPath, nodePath) === want) ? 'ok' : 'outdated';
321
+ }
299
322
  }
300
323
  return { name, file, state };
301
324
  });
@@ -445,7 +468,7 @@ function registration(claudeDir) {
445
468
  const perms = permissionEntries(runsDir());
446
469
  const legacy = permissionEntries(legacyRunsDirOf(claudeDir));
447
470
  const out = {
448
- file, PostToolUse: false, SubagentStop: false, SessionStart: false, UserPromptSubmit: false, PreToolUse: false, commands: [], permissions: false, permission_entries: perms,
471
+ file, PostToolUse: false, SubagentStop: false, SubagentStart: false, SessionStart: false, UserPromptSubmit: false, PreToolUse: false, commands: [], permissions: false, permission_entries: perms,
449
472
  runs_dir: perms.dir, runs_dir_exists: fs.existsSync(perms.dir), legacy_permissions: false,
450
473
  };
451
474
  let obj;
@@ -455,7 +478,7 @@ function registration(claudeDir) {
455
478
  out.legacy_permissions = legacy.allow.some((r) => Array.isArray(lp.allow) && lp.allow.includes(r))
456
479
  || (Array.isArray(lp.additionalDirectories) && lp.additionalDirectories.includes(legacy.dir));
457
480
  const hooks = obj.hooks || {};
458
- for (const ev of ['PostToolUse', 'SubagentStop', 'SessionStart', 'UserPromptSubmit', 'PreToolUse']) {
481
+ for (const ev of ['PostToolUse', 'SubagentStop', 'SubagentStart', 'SessionStart', 'UserPromptSubmit', 'PreToolUse']) {
459
482
  for (const g of Array.isArray(hooks[ev]) ? hooks[ev] : []) {
460
483
  for (const h of (g && Array.isArray(g.hooks) ? g.hooks : [])) {
461
484
  if (!isOurs(h)) continue;