@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/CHANGELOG.md +33 -0
- package/agents/dp-exec.md +6 -1
- package/agents/dp-helper.md +6 -0
- package/agents/dp-scout.md +7 -1
- package/agents/dp-verify.md +6 -0
- package/bin/dieu-phoi.cjs +29 -13
- package/hook.cjs +854 -154
- package/lib/aggregate.cjs +20 -2
- package/lib/args.cjs +1 -1
- package/lib/cbm.cjs +8 -2
- package/lib/choose.cjs +27 -22
- package/lib/commands.cjs +448 -39
- package/lib/deps.cjs +162 -0
- package/lib/knowledge.cjs +47 -4
- package/lib/load.cjs +332 -0
- package/lib/procs.cjs +200 -1
- package/lib/prune.cjs +91 -8
- package/lib/setup.cjs +28 -5
- package/lib/worktree.cjs +250 -19
- package/package.json +1 -1
- package/profile/lessons.md +2 -1
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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 ===
|
|
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
|
|
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;
|