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/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
- // Config directory: ~/.zclean/
8
- const CONFIG_DIR = path.join(os.homedir(), '.zclean');
9
- const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
10
- const LOG_FILE = path.join(CONFIG_DIR, 'history.jsonl');
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
- if (!fs.existsSync(CONFIG_DIR)) {
57
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
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
- if (fs.existsSync(CONFIG_FILE)) {
82
+ const configFile = getConfigFile();
83
+ if (fs.existsSync(configFile)) {
67
84
  try {
68
- const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
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(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n', 'utf-8');
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(LOG_FILE, line, 'utf-8');
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
- if (!fs.existsSync(LOG_FILE)) return [];
118
+ const logFile = getLogFile();
119
+ if (!fs.existsSync(logFile)) return [];
102
120
  try {
103
- const lines = fs.readFileSync(LOG_FILE, 'utf-8').trim().split('\n').filter(Boolean);
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
- if (!retentionMs || !fs.existsSync(LOG_FILE)) return;
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(LOG_FILE, 'utf-8').trim().split('\n').filter(Boolean);
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(LOG_FILE, kept.join('\n') + (kept.length ? '\n' : ''), 'utf-8');
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
- module.exports = {
136
- CONFIG_DIR,
137
- CONFIG_FILE,
138
- LOG_FILE,
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;
@@ -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
- * Check if a PID is an orphan process.
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
- * Returns { isOrphan: boolean, ppid: number|null, reason: string }
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
- // Linux: PPID 1 means reparented to init/systemd
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
- * Windows orphan check parent process doesn't exist.
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 checkOrphanWindows(pid) {
76
- try {
77
- // Get parent PID via wmic
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 process is inside a tmux or screen session tree.
108
- * Walks the process tree upward looking for tmux/screen ancestor.
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
- * Returns true if the process has a tmux or screen ancestor.
28
+ * @param {number} pid
29
+ * @returns {boolean}
111
30
  */
112
31
  function isInTerminalMultiplexer(pid) {
113
- if (platform === 'win32') return false;
114
-
115
- const visited = new Set();
116
- let currentPid = pid;
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 };
@@ -4,13 +4,40 @@
4
4
  * Process patterns to detect AI tool zombies.
5
5
  *
6
6
  * Each pattern has:
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)
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
- // Build tools — only if orphaned for 24h+
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.*(?:\.claude[/\\]|[/\\]mcp[/\\]|[/\\]agent[/\\])/,
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 tool related
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.*(?:\.claude|mcp|agent)/,
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.*(?:\.claude|mcp|agent)/,
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 };