z-clean 0.1.1 → 0.3.0
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/LICENSE +1 -1
- package/README.ko.md +118 -0
- package/README.md +241 -97
- package/README.zh.md +118 -0
- package/assets/zclean-hero.png +0 -0
- package/bin/zclean.js +95 -59
- package/package.json +45 -8
- package/src/audit-actions.js +88 -0
- package/src/audit-candidates.js +86 -0
- package/src/audit-history.js +69 -0
- package/src/audit-printer.js +83 -0
- package/src/audit-report.js +135 -0
- package/src/audit.js +35 -0
- package/src/cache.js +256 -0
- package/src/commands/scheduler.js +58 -0
- package/src/commands/trust.js +157 -0
- package/src/config.js +125 -20
- package/src/detector/orphan.js +21 -131
- package/src/detector/patterns.js +97 -14
- package/src/detector/whitelist.js +26 -105
- package/src/doctor/checks.js +229 -0
- package/src/doctor/render.js +37 -0
- package/src/doctor.js +22 -0
- package/src/installer/bin-path.js +106 -0
- package/src/installer/hook.js +15 -4
- package/src/installer/launchd.js +57 -28
- package/src/installer/systemd.js +18 -29
- package/src/installer/taskscheduler.js +29 -36
- package/src/killer.js +68 -52
- package/src/process-tree.js +338 -0
- package/src/reporter.js +61 -2
- package/src/scanner.js +97 -241
- package/src/windows-processes.js +257 -0
package/src/config.js
CHANGED
|
@@ -4,19 +4,34 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const os = require('os');
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
7
|
+
const CONFIG_DIR_ENV = 'ZCLEAN_CONFIG_DIR';
|
|
8
|
+
|
|
9
|
+
function getConfigDir() {
|
|
10
|
+
const configured = process.env[CONFIG_DIR_ENV];
|
|
11
|
+
if (configured) return path.resolve(configured);
|
|
12
|
+
if (process.env.NODE_TEST_CONTEXT) {
|
|
13
|
+
return path.join(os.tmpdir(), 'zclean-node-test', String(process.pid));
|
|
14
|
+
}
|
|
15
|
+
return path.join(os.homedir(), '.zclean');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getConfigFile() {
|
|
19
|
+
return path.join(getConfigDir(), 'config.json');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getLogFile() {
|
|
23
|
+
return path.join(getConfigDir(), 'history.jsonl');
|
|
24
|
+
}
|
|
11
25
|
|
|
12
26
|
const DEFAULT_CONFIG = {
|
|
13
27
|
whitelist: [],
|
|
14
28
|
maxAge: '24h',
|
|
15
29
|
memoryThreshold: '500MB',
|
|
16
|
-
schedule: 'hourly',
|
|
17
30
|
sigterm_timeout: 10,
|
|
18
31
|
dryRunDefault: true,
|
|
19
32
|
logRetention: '30d',
|
|
33
|
+
maxKillBatch: 20,
|
|
34
|
+
customAiDirs: [],
|
|
20
35
|
};
|
|
21
36
|
|
|
22
37
|
/**
|
|
@@ -53,8 +68,9 @@ function parseMemory(str) {
|
|
|
53
68
|
* Ensure the config directory exists.
|
|
54
69
|
*/
|
|
55
70
|
function ensureConfigDir() {
|
|
56
|
-
|
|
57
|
-
|
|
71
|
+
const configDir = getConfigDir();
|
|
72
|
+
if (!fs.existsSync(configDir)) {
|
|
73
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
58
74
|
}
|
|
59
75
|
}
|
|
60
76
|
|
|
@@ -63,9 +79,10 @@ function ensureConfigDir() {
|
|
|
63
79
|
*/
|
|
64
80
|
function loadConfig() {
|
|
65
81
|
ensureConfigDir();
|
|
66
|
-
|
|
82
|
+
const configFile = getConfigFile();
|
|
83
|
+
if (fs.existsSync(configFile)) {
|
|
67
84
|
try {
|
|
68
|
-
const raw = fs.readFileSync(
|
|
85
|
+
const raw = fs.readFileSync(configFile, 'utf-8');
|
|
69
86
|
const userConfig = JSON.parse(raw);
|
|
70
87
|
return { ...DEFAULT_CONFIG, ...userConfig };
|
|
71
88
|
} catch {
|
|
@@ -81,7 +98,7 @@ function loadConfig() {
|
|
|
81
98
|
*/
|
|
82
99
|
function saveConfig(config) {
|
|
83
100
|
ensureConfigDir();
|
|
84
|
-
fs.writeFileSync(
|
|
101
|
+
fs.writeFileSync(getConfigFile(), JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
85
102
|
}
|
|
86
103
|
|
|
87
104
|
/**
|
|
@@ -91,16 +108,17 @@ function saveConfig(config) {
|
|
|
91
108
|
function appendLog(entry) {
|
|
92
109
|
ensureConfigDir();
|
|
93
110
|
const line = JSON.stringify({ timestamp: new Date().toISOString(), ...entry }) + '\n';
|
|
94
|
-
fs.appendFileSync(
|
|
111
|
+
fs.appendFileSync(getLogFile(), line, 'utf-8');
|
|
95
112
|
}
|
|
96
113
|
|
|
97
114
|
/**
|
|
98
115
|
* Read recent log entries (up to `limit`).
|
|
99
116
|
*/
|
|
100
117
|
function readLogs(limit = 50) {
|
|
101
|
-
|
|
118
|
+
const logFile = getLogFile();
|
|
119
|
+
if (!fs.existsSync(logFile)) return [];
|
|
102
120
|
try {
|
|
103
|
-
const lines = fs.readFileSync(
|
|
121
|
+
const lines = fs.readFileSync(logFile, 'utf-8').trim().split('\n').filter(Boolean);
|
|
104
122
|
return lines
|
|
105
123
|
.slice(-limit)
|
|
106
124
|
.map((line) => {
|
|
@@ -117,10 +135,11 @@ function readLogs(limit = 50) {
|
|
|
117
135
|
*/
|
|
118
136
|
function pruneLogs(config) {
|
|
119
137
|
const retentionMs = parseDuration(config.logRetention || '30d');
|
|
120
|
-
|
|
138
|
+
const logFile = getLogFile();
|
|
139
|
+
if (!retentionMs || !fs.existsSync(logFile)) return;
|
|
121
140
|
|
|
122
141
|
const cutoff = Date.now() - retentionMs;
|
|
123
|
-
const lines = fs.readFileSync(
|
|
142
|
+
const lines = fs.readFileSync(logFile, 'utf-8').trim().split('\n').filter(Boolean);
|
|
124
143
|
const kept = lines.filter((line) => {
|
|
125
144
|
try {
|
|
126
145
|
const entry = JSON.parse(line);
|
|
@@ -129,20 +148,106 @@ function pruneLogs(config) {
|
|
|
129
148
|
return false;
|
|
130
149
|
}
|
|
131
150
|
});
|
|
132
|
-
fs.writeFileSync(
|
|
151
|
+
fs.writeFileSync(logFile, kept.join('\n') + (kept.length ? '\n' : ''), 'utf-8');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Compute cumulative stats from history.jsonl.
|
|
156
|
+
* Returns { totalKilled, totalMemFreed, weekKilled, weekMemFreed, lastRun }.
|
|
157
|
+
*/
|
|
158
|
+
function getCumulativeStats() {
|
|
159
|
+
const logs = readLogs(10000);
|
|
160
|
+
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
161
|
+
|
|
162
|
+
let totalKilled = 0;
|
|
163
|
+
let totalMemFreed = 0;
|
|
164
|
+
let weekKilled = 0;
|
|
165
|
+
let weekMemFreed = 0;
|
|
166
|
+
let lastRun = null;
|
|
167
|
+
|
|
168
|
+
for (const entry of logs) {
|
|
169
|
+
if (entry.action === 'cleanup-summary') {
|
|
170
|
+
totalKilled += entry.killed || 0;
|
|
171
|
+
totalMemFreed += entry.totalMemFreed || 0;
|
|
172
|
+
if (!lastRun || entry.timestamp > lastRun) lastRun = entry.timestamp;
|
|
173
|
+
|
|
174
|
+
const ts = new Date(entry.timestamp).getTime();
|
|
175
|
+
if (ts >= weekAgo) {
|
|
176
|
+
weekKilled += entry.killed || 0;
|
|
177
|
+
weekMemFreed += entry.totalMemFreed || 0;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { totalKilled, totalMemFreed, weekKilled, weekMemFreed, lastRun };
|
|
133
183
|
}
|
|
134
184
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
185
|
+
function summarizeHistory(logs, stats = {}) {
|
|
186
|
+
let dryRuns = 0;
|
|
187
|
+
let scans = 0;
|
|
188
|
+
let scanFailures = 0;
|
|
189
|
+
let cleanupSummaries = 0;
|
|
190
|
+
let summaryFailedKills = 0;
|
|
191
|
+
let looseFailedKills = 0;
|
|
192
|
+
|
|
193
|
+
for (const entry of logs || []) {
|
|
194
|
+
switch (entry.action) {
|
|
195
|
+
case 'dry-run':
|
|
196
|
+
dryRuns++;
|
|
197
|
+
break;
|
|
198
|
+
case 'scan':
|
|
199
|
+
scans++;
|
|
200
|
+
break;
|
|
201
|
+
case 'scan-failed':
|
|
202
|
+
scanFailures++;
|
|
203
|
+
break;
|
|
204
|
+
case 'cleanup-summary':
|
|
205
|
+
cleanupSummaries++;
|
|
206
|
+
summaryFailedKills += entry.failed || 0;
|
|
207
|
+
break;
|
|
208
|
+
case 'kill-failed':
|
|
209
|
+
looseFailedKills++;
|
|
210
|
+
break;
|
|
211
|
+
default:
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
dryRuns,
|
|
218
|
+
scans,
|
|
219
|
+
scanFailures,
|
|
220
|
+
cleanupSummaries,
|
|
221
|
+
failedKills: cleanupSummaries > 0 ? summaryFailedKills : looseFailedKills,
|
|
222
|
+
totalKilled: stats.totalKilled || 0,
|
|
223
|
+
totalMemFreed: stats.totalMemFreed || 0,
|
|
224
|
+
weekKilled: stats.weekKilled || 0,
|
|
225
|
+
weekMemFreed: stats.weekMemFreed || 0,
|
|
226
|
+
lastRun: stats.lastRun || null,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const exported = {
|
|
139
231
|
DEFAULT_CONFIG,
|
|
140
232
|
parseDuration,
|
|
141
233
|
parseMemory,
|
|
234
|
+
getConfigDir,
|
|
235
|
+
getConfigFile,
|
|
236
|
+
getLogFile,
|
|
142
237
|
loadConfig,
|
|
143
238
|
saveConfig,
|
|
144
239
|
appendLog,
|
|
145
240
|
readLogs,
|
|
146
241
|
pruneLogs,
|
|
147
242
|
ensureConfigDir,
|
|
243
|
+
getCumulativeStats,
|
|
244
|
+
summarizeHistory,
|
|
148
245
|
};
|
|
246
|
+
|
|
247
|
+
Object.defineProperties(exported, {
|
|
248
|
+
CONFIG_DIR: { enumerable: true, get: getConfigDir },
|
|
249
|
+
CONFIG_FILE: { enumerable: true, get: getConfigFile },
|
|
250
|
+
LOG_FILE: { enumerable: true, get: getLogFile },
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
module.exports = exported;
|
package/src/detector/orphan.js
CHANGED
|
@@ -1,149 +1,39 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { execSync } = require('child_process');
|
|
4
|
-
const os = require('os');
|
|
5
|
-
|
|
6
|
-
const platform = os.platform();
|
|
7
|
-
|
|
8
3
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* Orphan definition by platform:
|
|
12
|
-
* macOS: PPID === 1 (launchd)
|
|
13
|
-
* Linux: PPID === 1 OR PPID === systemd --user PID
|
|
14
|
-
* Windows: parent process no longer exists
|
|
4
|
+
* Orphan detection — now a thin wrapper around ProcessTree.
|
|
15
5
|
*
|
|
16
|
-
*
|
|
17
|
-
|
|
18
|
-
function checkOrphan(pid) {
|
|
19
|
-
try {
|
|
20
|
-
if (platform === 'win32') {
|
|
21
|
-
return checkOrphanWindows(pid);
|
|
22
|
-
} else {
|
|
23
|
-
return checkOrphanUnix(pid);
|
|
24
|
-
}
|
|
25
|
-
} catch {
|
|
26
|
-
// Process may have disappeared during check
|
|
27
|
-
return { isOrphan: false, ppid: null, reason: 'check-failed' };
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Unix (macOS/Linux) orphan check.
|
|
6
|
+
* scanner.js uses ProcessTree directly. This module is kept for
|
|
7
|
+
* backward compatibility if external code imports checkOrphan().
|
|
33
8
|
*/
|
|
34
|
-
function checkOrphanUnix(pid) {
|
|
35
|
-
let ppidStr;
|
|
36
|
-
try {
|
|
37
|
-
ppidStr = execSync(`ps -o ppid= -p ${pid}`, { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
38
|
-
} catch {
|
|
39
|
-
return { isOrphan: false, ppid: null, reason: 'process-gone' };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const ppid = parseInt(ppidStr, 10);
|
|
43
|
-
if (isNaN(ppid)) {
|
|
44
|
-
return { isOrphan: false, ppid: null, reason: 'invalid-ppid' };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// macOS: PPID 1 means reparented to launchd
|
|
48
|
-
if (platform === 'darwin' && ppid === 1) {
|
|
49
|
-
return { isOrphan: true, ppid, reason: 'reparented-to-launchd' };
|
|
50
|
-
}
|
|
51
9
|
|
|
52
|
-
|
|
53
|
-
if (platform === 'linux' && ppid === 1) {
|
|
54
|
-
return { isOrphan: true, ppid, reason: 'reparented-to-init' };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Linux: also check if reparented to systemd --user
|
|
58
|
-
if (platform === 'linux' && ppid > 1) {
|
|
59
|
-
try {
|
|
60
|
-
const parentCmd = execSync(`ps -o comm= -p ${ppid}`, { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
61
|
-
if (parentCmd === 'systemd') {
|
|
62
|
-
return { isOrphan: true, ppid, reason: 'reparented-to-systemd-user' };
|
|
63
|
-
}
|
|
64
|
-
} catch {
|
|
65
|
-
// Parent might have died between checks
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return { isOrphan: false, ppid, reason: 'has-parent' };
|
|
70
|
-
}
|
|
10
|
+
const { ProcessTree } = require('../process-tree');
|
|
71
11
|
|
|
72
12
|
/**
|
|
73
|
-
*
|
|
13
|
+
* Check if a PID is an orphan process.
|
|
14
|
+
* Builds a fresh ProcessTree — prefer tree.isOrphan(pid) in hot paths.
|
|
15
|
+
*
|
|
16
|
+
* @param {number} pid
|
|
17
|
+
* @returns {{ isOrphan: boolean, ppid: number|null, reason: string }}
|
|
74
18
|
*/
|
|
75
|
-
function
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const output = execSync(
|
|
79
|
-
`wmic process where ProcessId=${pid} get ParentProcessId /value`,
|
|
80
|
-
{ encoding: 'utf-8', timeout: 5000 }
|
|
81
|
-
).trim();
|
|
82
|
-
|
|
83
|
-
const match = output.match(/ParentProcessId=(\d+)/);
|
|
84
|
-
if (!match) {
|
|
85
|
-
return { isOrphan: false, ppid: null, reason: 'no-ppid-info' };
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const ppid = parseInt(match[1], 10);
|
|
89
|
-
|
|
90
|
-
// Check if parent process still exists
|
|
91
|
-
try {
|
|
92
|
-
execSync(`wmic process where ProcessId=${ppid} get ProcessId /value`, {
|
|
93
|
-
encoding: 'utf-8',
|
|
94
|
-
timeout: 5000,
|
|
95
|
-
});
|
|
96
|
-
return { isOrphan: false, ppid, reason: 'has-parent' };
|
|
97
|
-
} catch {
|
|
98
|
-
// Parent doesn't exist — orphan
|
|
99
|
-
return { isOrphan: true, ppid, reason: 'parent-gone' };
|
|
100
|
-
}
|
|
101
|
-
} catch {
|
|
102
|
-
return { isOrphan: false, ppid: null, reason: 'check-failed' };
|
|
103
|
-
}
|
|
19
|
+
function checkOrphan(pid) {
|
|
20
|
+
const tree = ProcessTree.build();
|
|
21
|
+
return tree.isOrphan(pid);
|
|
104
22
|
}
|
|
105
23
|
|
|
106
24
|
/**
|
|
107
|
-
* Check if a
|
|
108
|
-
*
|
|
25
|
+
* Check if a PID is inside a terminal multiplexer (tmux/screen).
|
|
26
|
+
* Builds a fresh ProcessTree — prefer tree.hasAncestorMatching() in hot paths.
|
|
109
27
|
*
|
|
110
|
-
*
|
|
28
|
+
* @param {number} pid
|
|
29
|
+
* @returns {boolean}
|
|
111
30
|
*/
|
|
112
31
|
function isInTerminalMultiplexer(pid) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
while (currentPid > 1 && !visited.has(currentPid)) {
|
|
119
|
-
visited.add(currentPid);
|
|
120
|
-
|
|
121
|
-
try {
|
|
122
|
-
const info = execSync(`ps -o ppid=,comm= -p ${currentPid}`, {
|
|
123
|
-
encoding: 'utf-8',
|
|
124
|
-
timeout: 5000,
|
|
125
|
-
}).trim();
|
|
126
|
-
|
|
127
|
-
// Parse " PPID COMMAND"
|
|
128
|
-
const parts = info.trim().split(/\s+/);
|
|
129
|
-
if (parts.length < 2) break;
|
|
130
|
-
|
|
131
|
-
const parentPid = parseInt(parts[0], 10);
|
|
132
|
-
const comm = parts.slice(1).join(' ');
|
|
133
|
-
|
|
134
|
-
// Check for tmux/screen
|
|
135
|
-
if (/^(tmux|screen)/.test(comm)) {
|
|
136
|
-
return true;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
if (isNaN(parentPid) || parentPid <= 1) break;
|
|
140
|
-
currentPid = parentPid;
|
|
141
|
-
} catch {
|
|
142
|
-
break;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
return false;
|
|
32
|
+
const tree = ProcessTree.build();
|
|
33
|
+
return tree.hasAncestorMatching(pid, (proc) => {
|
|
34
|
+
const comm = proc.cmd.split(/\s+/)[0].split('/').pop();
|
|
35
|
+
return comm === 'tmux' || comm === 'screen';
|
|
36
|
+
});
|
|
147
37
|
}
|
|
148
38
|
|
|
149
39
|
module.exports = { checkOrphan, isInTerminalMultiplexer };
|
package/src/detector/patterns.js
CHANGED
|
@@ -4,13 +4,40 @@
|
|
|
4
4
|
* Process patterns to detect AI tool zombies.
|
|
5
5
|
*
|
|
6
6
|
* Each pattern has:
|
|
7
|
-
* name
|
|
8
|
-
* match
|
|
9
|
-
* minAge
|
|
10
|
-
* maxOrphanAge
|
|
11
|
-
* memThreshold
|
|
12
|
-
* orphanOnly
|
|
7
|
+
* name — human-readable identifier
|
|
8
|
+
* match — RegExp to test against full command line
|
|
9
|
+
* minAge — minimum age (ms) before considering it a zombie (0 = any age)
|
|
10
|
+
* maxOrphanAge — if set, orphan processes older than this are zombies (duration string)
|
|
11
|
+
* memThreshold — if set, only flag if RSS exceeds this (bytes string like "500MB")
|
|
12
|
+
* orphanOnly — if true, only flag orphaned processes (default: true for most)
|
|
13
|
+
* aiPathRequired — if true, cmdline must also match AI_DIR_REGEX to be flagged
|
|
13
14
|
*/
|
|
15
|
+
|
|
16
|
+
/** Centralized list of AI tool config directories */
|
|
17
|
+
const AI_TOOL_DIRS = [
|
|
18
|
+
'.claude', '.cursor', '.windsurf', '.continue', '.cline',
|
|
19
|
+
'.roo', '.kilocode', '.augment', '.codex', '.copilot',
|
|
20
|
+
'.aider', '.gemini', '.trae', '.goose',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build a regex that matches any AI tool directory in a path.
|
|
25
|
+
* Matches "{dir}/" or "{dir}\" for each directory.
|
|
26
|
+
* @param {string[]} [customDirs] — additional directories from config
|
|
27
|
+
* @returns {RegExp}
|
|
28
|
+
*/
|
|
29
|
+
function buildAiDirRegex(customDirs) {
|
|
30
|
+
const allDirs = customDirs && customDirs.length > 0
|
|
31
|
+
? [...AI_TOOL_DIRS, ...customDirs]
|
|
32
|
+
: AI_TOOL_DIRS;
|
|
33
|
+
|
|
34
|
+
const escaped = allDirs.map((d) => d.replace(/\./g, '\\.'));
|
|
35
|
+
return new RegExp(`(?:${escaped.join('|')})[/\\\\]`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Default regex (no custom dirs) */
|
|
39
|
+
const AI_DIR_REGEX = buildAiDirRegex();
|
|
40
|
+
|
|
14
41
|
const PATTERNS = [
|
|
15
42
|
// MCP servers — any orphaned MCP server is suspect
|
|
16
43
|
{
|
|
@@ -37,6 +64,14 @@ const PATTERNS = [
|
|
|
37
64
|
orphanOnly: true,
|
|
38
65
|
},
|
|
39
66
|
|
|
67
|
+
// Claude session processes (claude --session-id)
|
|
68
|
+
{
|
|
69
|
+
name: 'claude-session',
|
|
70
|
+
match: /claude\s+--session-id/,
|
|
71
|
+
minAge: 0,
|
|
72
|
+
orphanOnly: true,
|
|
73
|
+
},
|
|
74
|
+
|
|
40
75
|
// Claude subagent processes
|
|
41
76
|
{
|
|
42
77
|
name: 'claude-subagent',
|
|
@@ -53,13 +88,38 @@ const PATTERNS = [
|
|
|
53
88
|
orphanOnly: true,
|
|
54
89
|
},
|
|
55
90
|
|
|
56
|
-
//
|
|
91
|
+
// Codex sandbox processes
|
|
92
|
+
{
|
|
93
|
+
name: 'codex-sandbox',
|
|
94
|
+
match: /codex-sandbox/,
|
|
95
|
+
minAge: 0,
|
|
96
|
+
orphanOnly: true,
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
// Aider processes
|
|
100
|
+
{
|
|
101
|
+
name: 'aider',
|
|
102
|
+
match: /python.*aider|(?:^|\s|\/)aider\b/,
|
|
103
|
+
minAge: 0,
|
|
104
|
+
orphanOnly: true,
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
// Gemini CLI processes
|
|
108
|
+
{
|
|
109
|
+
name: 'gemini-cli',
|
|
110
|
+
match: /\bgemini\b/,
|
|
111
|
+
minAge: 0,
|
|
112
|
+
orphanOnly: true,
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
// Build tools — only if orphaned for 24h+ AND in AI path
|
|
57
116
|
{
|
|
58
117
|
name: 'esbuild',
|
|
59
118
|
match: /esbuild/,
|
|
60
119
|
minAge: 0,
|
|
61
120
|
maxOrphanAge: '24h',
|
|
62
121
|
orphanOnly: true,
|
|
122
|
+
aiPathRequired: true,
|
|
63
123
|
},
|
|
64
124
|
{
|
|
65
125
|
name: 'vite',
|
|
@@ -67,6 +127,7 @@ const PATTERNS = [
|
|
|
67
127
|
minAge: 0,
|
|
68
128
|
maxOrphanAge: '24h',
|
|
69
129
|
orphanOnly: true,
|
|
130
|
+
aiPathRequired: true,
|
|
70
131
|
},
|
|
71
132
|
{
|
|
72
133
|
name: 'next-dev',
|
|
@@ -74,6 +135,7 @@ const PATTERNS = [
|
|
|
74
135
|
minAge: 0,
|
|
75
136
|
maxOrphanAge: '24h',
|
|
76
137
|
orphanOnly: true,
|
|
138
|
+
aiPathRequired: true,
|
|
77
139
|
},
|
|
78
140
|
{
|
|
79
141
|
name: 'webpack',
|
|
@@ -81,33 +143,39 @@ const PATTERNS = [
|
|
|
81
143
|
minAge: 0,
|
|
82
144
|
maxOrphanAge: '24h',
|
|
83
145
|
orphanOnly: true,
|
|
146
|
+
aiPathRequired: true,
|
|
84
147
|
},
|
|
85
148
|
|
|
86
|
-
// npx/npm exec — orphaned
|
|
149
|
+
// npx/npm exec — orphaned, AI path required
|
|
87
150
|
{
|
|
88
151
|
name: 'npm-exec',
|
|
89
152
|
match: /npm\s+exec|npx\s/,
|
|
90
153
|
minAge: 0,
|
|
91
154
|
orphanOnly: true,
|
|
155
|
+
aiPathRequired: true,
|
|
92
156
|
},
|
|
93
157
|
|
|
94
158
|
// Node processes with AI tool paths — orphan + age/memory gated
|
|
159
|
+
// match: node running from AI dirs, MCP servers, or agent paths
|
|
160
|
+
// aiPathRequired adds a second gate via AI_DIR_REGEX
|
|
95
161
|
{
|
|
96
162
|
name: 'node-ai-path',
|
|
97
|
-
match: /node\b.*(
|
|
163
|
+
match: /node\b.*(?:[/\\]mcp[/\\]|[/\\]agent[/\\]|[/\\]server[/\\])/,
|
|
98
164
|
minAge: 0,
|
|
99
165
|
maxOrphanAge: '24h',
|
|
100
166
|
memThreshold: '500MB',
|
|
101
167
|
orphanOnly: true,
|
|
168
|
+
aiPathRequired: true,
|
|
102
169
|
},
|
|
103
170
|
|
|
104
|
-
// TypeScript runners — orphan + AI
|
|
171
|
+
// TypeScript runners — orphan + AI path required
|
|
105
172
|
{
|
|
106
173
|
name: 'tsx',
|
|
107
174
|
match: /\btsx\b/,
|
|
108
175
|
minAge: 0,
|
|
109
176
|
maxOrphanAge: '24h',
|
|
110
177
|
orphanOnly: true,
|
|
178
|
+
aiPathRequired: true,
|
|
111
179
|
},
|
|
112
180
|
{
|
|
113
181
|
name: 'ts-node',
|
|
@@ -115,34 +183,49 @@ const PATTERNS = [
|
|
|
115
183
|
minAge: 0,
|
|
116
184
|
maxOrphanAge: '24h',
|
|
117
185
|
orphanOnly: true,
|
|
186
|
+
aiPathRequired: true,
|
|
118
187
|
},
|
|
119
188
|
{
|
|
120
189
|
name: 'bun',
|
|
121
|
-
match: /\bbun\b
|
|
190
|
+
match: /\bbun\b/,
|
|
122
191
|
minAge: 0,
|
|
123
192
|
maxOrphanAge: '24h',
|
|
124
193
|
orphanOnly: true,
|
|
194
|
+
aiPathRequired: true,
|
|
125
195
|
},
|
|
126
196
|
{
|
|
127
197
|
name: 'deno',
|
|
128
|
-
match: /\bdeno\b
|
|
198
|
+
match: /\bdeno\b/,
|
|
129
199
|
minAge: 0,
|
|
130
200
|
maxOrphanAge: '24h',
|
|
131
201
|
orphanOnly: true,
|
|
202
|
+
aiPathRequired: true,
|
|
132
203
|
},
|
|
133
204
|
];
|
|
134
205
|
|
|
135
206
|
/**
|
|
136
207
|
* Match a command line against known patterns.
|
|
137
208
|
* Returns the first matching pattern or null.
|
|
209
|
+
*
|
|
210
|
+
* @param {string} cmdline — process command line
|
|
211
|
+
* @param {object} [config] — optional config with customAiDirs
|
|
212
|
+
* @returns {object|null} matched pattern or null
|
|
138
213
|
*/
|
|
139
|
-
function matchPattern(cmdline) {
|
|
214
|
+
function matchPattern(cmdline, config) {
|
|
215
|
+
const aiRegex = config && config.customAiDirs && config.customAiDirs.length > 0
|
|
216
|
+
? buildAiDirRegex(config.customAiDirs)
|
|
217
|
+
: AI_DIR_REGEX;
|
|
218
|
+
|
|
140
219
|
for (const pattern of PATTERNS) {
|
|
141
220
|
if (pattern.match.test(cmdline)) {
|
|
221
|
+
// If pattern requires AI path context, check it
|
|
222
|
+
if (pattern.aiPathRequired && !aiRegex.test(cmdline)) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
142
225
|
return pattern;
|
|
143
226
|
}
|
|
144
227
|
}
|
|
145
228
|
return null;
|
|
146
229
|
}
|
|
147
230
|
|
|
148
|
-
module.exports = { PATTERNS, matchPattern };
|
|
231
|
+
module.exports = { PATTERNS, AI_TOOL_DIRS, AI_DIR_REGEX, buildAiDirRegex, matchPattern };
|