z-clean 0.1.1

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.
@@ -0,0 +1,220 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * ANSI color codes — no external dependencies.
5
+ */
6
+ const C = {
7
+ reset: '\x1b[0m',
8
+ bold: '\x1b[1m',
9
+ dim: '\x1b[2m',
10
+ red: '\x1b[31m',
11
+ green: '\x1b[32m',
12
+ yellow: '\x1b[33m',
13
+ blue: '\x1b[34m',
14
+ magenta: '\x1b[35m',
15
+ cyan: '\x1b[36m',
16
+ white: '\x1b[37m',
17
+ gray: '\x1b[90m',
18
+ };
19
+
20
+ // Disable colors if NO_COLOR env is set or not a TTY
21
+ const useColor = !process.env.NO_COLOR && process.stdout.isTTY;
22
+
23
+ function c(color, text) {
24
+ if (!useColor) return text;
25
+ return `${C[color]}${text}${C.reset}`;
26
+ }
27
+
28
+ function bold(text) {
29
+ if (!useColor) return text;
30
+ return `${C.bold}${text}${C.reset}`;
31
+ }
32
+
33
+ /**
34
+ * Format bytes into human-readable string.
35
+ */
36
+ function formatBytes(bytes) {
37
+ if (bytes === 0) return '0 B';
38
+ const units = ['B', 'KB', 'MB', 'GB'];
39
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
40
+ const val = (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0);
41
+ return `${val} ${units[i]}`;
42
+ }
43
+
44
+ /**
45
+ * Format milliseconds into human-readable duration.
46
+ */
47
+ function formatDuration(ms) {
48
+ if (ms < 60000) return `${Math.floor(ms / 1000)}s`;
49
+ if (ms < 3600000) return `${Math.floor(ms / 60000)}m`;
50
+ if (ms < 86400000) {
51
+ const h = Math.floor(ms / 3600000);
52
+ const m = Math.floor((ms % 3600000) / 60000);
53
+ return m > 0 ? `${h}h ${m}m` : `${h}h`;
54
+ }
55
+ const d = Math.floor(ms / 86400000);
56
+ const h = Math.floor((ms % 86400000) / 3600000);
57
+ return h > 0 ? `${d}d ${h}h` : `${d}d`;
58
+ }
59
+
60
+ /**
61
+ * Truncate a string to maxLen, adding ellipsis if needed.
62
+ */
63
+ function truncate(str, maxLen = 80) {
64
+ if (str.length <= maxLen) return str;
65
+ return str.substring(0, maxLen - 3) + '...';
66
+ }
67
+
68
+ /**
69
+ * Report dry-run scan results.
70
+ */
71
+ function reportDryRun(zombies) {
72
+ if (zombies.length === 0) {
73
+ console.log(c('green', ' No zombie processes found. System is clean.'));
74
+ return;
75
+ }
76
+
77
+ console.log(bold(`\n Found ${c('yellow', String(zombies.length))} zombie process${zombies.length === 1 ? '' : 'es'}:\n`));
78
+
79
+ const totalMem = zombies.reduce((sum, z) => sum + z.mem, 0);
80
+
81
+ for (const z of zombies) {
82
+ console.log(` ${c('red', 'PID')} ${c('bold', String(z.pid).padStart(6))} ${c('cyan', z.name.padEnd(16))} ${c('yellow', formatBytes(z.mem).padStart(8))} ${c('gray', formatDuration(z.age).padStart(6))}`);
83
+ console.log(` ${c('gray', ' cmd:')} ${truncate(z.cmd, 72)}`);
84
+ console.log(` ${c('gray', ' why:')} ${z.reason}`);
85
+ console.log();
86
+ }
87
+
88
+ console.log(c('yellow', ` Total memory reclaimable: ${formatBytes(totalMem)}`));
89
+ console.log(c('gray', `\n Run ${c('white', 'zclean --yes')} to clean up these processes.\n`));
90
+ }
91
+
92
+ /**
93
+ * Report kill results.
94
+ */
95
+ function reportKill(results) {
96
+ const { killed, failed, skipped } = results;
97
+
98
+ if (killed.length === 0 && failed.length === 0 && skipped.length === 0) {
99
+ console.log(c('green', ' No zombie processes to clean.'));
100
+ return;
101
+ }
102
+
103
+ console.log();
104
+
105
+ if (killed.length > 0) {
106
+ const totalMem = killed.reduce((sum, p) => sum + p.mem, 0);
107
+ console.log(c('green', ` Killed ${killed.length} zombie process${killed.length === 1 ? '' : 'es'}:`));
108
+ for (const p of killed) {
109
+ console.log(` ${c('green', 'KILLED')} PID ${String(p.pid).padStart(6)} ${p.name.padEnd(16)} ${formatBytes(p.mem).padStart(8)}`);
110
+ }
111
+ console.log(c('green', `\n Memory freed: ${formatBytes(totalMem)}`));
112
+ }
113
+
114
+ if (skipped.length > 0) {
115
+ console.log(c('yellow', `\n Skipped ${skipped.length} (re-verification failed):`));
116
+ for (const p of skipped) {
117
+ console.log(` ${c('yellow', 'SKIP')} PID ${String(p.pid).padStart(6)} ${p.name.padEnd(16)} reason: ${p.skipReason}`);
118
+ }
119
+ }
120
+
121
+ if (failed.length > 0) {
122
+ console.log(c('red', `\n Failed to kill ${failed.length}:`));
123
+ for (const p of failed) {
124
+ console.log(` ${c('red', 'FAIL')} PID ${String(p.pid).padStart(6)} ${p.name.padEnd(16)} error: ${p.error}`);
125
+ }
126
+ }
127
+
128
+ console.log();
129
+ }
130
+
131
+ /**
132
+ * Report current status (for `zclean status`).
133
+ */
134
+ function reportStatus(zombies, logs) {
135
+ console.log(bold('\n zclean status\n'));
136
+
137
+ // Current zombies
138
+ if (zombies.length === 0) {
139
+ console.log(c('green', ' Current zombies: 0'));
140
+ } else {
141
+ console.log(c('yellow', ` Current zombies: ${zombies.length}`));
142
+ const totalMem = zombies.reduce((sum, z) => sum + z.mem, 0);
143
+ console.log(c('yellow', ` Memory held: ${formatBytes(totalMem)}`));
144
+ }
145
+
146
+ // Last cleanup
147
+ const lastCleanup = logs.filter((l) => l.action === 'cleanup-summary').pop();
148
+ if (lastCleanup) {
149
+ console.log(` Last cleanup: ${c('gray', lastCleanup.timestamp)}`);
150
+ console.log(` Killed: ${lastCleanup.killed}`);
151
+ console.log(` Memory freed: ${formatBytes(lastCleanup.totalMemFreed || 0)}`);
152
+ } else {
153
+ console.log(c('gray', ' Last cleanup: never'));
154
+ }
155
+
156
+ console.log();
157
+ }
158
+
159
+ /**
160
+ * Report recent logs (for `zclean logs`).
161
+ */
162
+ function reportLogs(logs) {
163
+ if (logs.length === 0) {
164
+ console.log(c('gray', ' No cleanup history yet.\n'));
165
+ return;
166
+ }
167
+
168
+ console.log(bold('\n Recent cleanup history\n'));
169
+
170
+ for (const entry of logs.slice(-20)) {
171
+ const time = c('gray', entry.timestamp.replace('T', ' ').substring(0, 19));
172
+
173
+ switch (entry.action) {
174
+ case 'kill':
175
+ console.log(` ${time} ${c('green', 'KILL')} PID ${String(entry.pid).padStart(6)} ${(entry.name || '').padEnd(16)} ${formatBytes(entry.memFreed || 0)}`);
176
+ break;
177
+ case 'kill-failed':
178
+ console.log(` ${time} ${c('red', 'FAIL')} PID ${String(entry.pid).padStart(6)} ${(entry.name || '').padEnd(16)} ${entry.error || ''}`);
179
+ break;
180
+ case 'cleanup-summary':
181
+ console.log(` ${time} ${c('cyan', 'DONE')} killed:${entry.killed} failed:${entry.failed} skipped:${entry.skipped} freed:${formatBytes(entry.totalMemFreed || 0)}`);
182
+ break;
183
+ default:
184
+ console.log(` ${time} ${c('gray', entry.action)}`);
185
+ }
186
+ }
187
+
188
+ console.log();
189
+ }
190
+
191
+ /**
192
+ * Report current config (for `zclean config`).
193
+ */
194
+ function reportConfig(config, configPath) {
195
+ console.log(bold('\n zclean config\n'));
196
+ console.log(` Config file: ${c('gray', configPath)}`);
197
+ console.log();
198
+
199
+ for (const [key, value] of Object.entries(config)) {
200
+ const displayValue = Array.isArray(value)
201
+ ? (value.length === 0 ? '[]' : JSON.stringify(value))
202
+ : String(value);
203
+ console.log(` ${c('cyan', key.padEnd(20))} ${displayValue}`);
204
+ }
205
+
206
+ console.log();
207
+ }
208
+
209
+ module.exports = {
210
+ reportDryRun,
211
+ reportKill,
212
+ reportStatus,
213
+ reportLogs,
214
+ reportConfig,
215
+ formatBytes,
216
+ formatDuration,
217
+ C,
218
+ c,
219
+ bold,
220
+ };
package/src/scanner.js ADDED
@@ -0,0 +1,296 @@
1
+ 'use strict';
2
+
3
+ const { execSync } = require('child_process');
4
+ const os = require('os');
5
+ const { matchPattern } = require('./detector/patterns');
6
+ const { checkOrphan, isInTerminalMultiplexer } = require('./detector/orphan');
7
+ const { isWhitelisted } = require('./detector/whitelist');
8
+ const { parseDuration, parseMemory } = require('./config');
9
+
10
+ const platform = os.platform();
11
+
12
+ /**
13
+ * Scan for zombie/orphan processes left by AI coding tools.
14
+ *
15
+ * Returns an array of process objects:
16
+ * { pid, name, cmd, ppid, mem, age, startTime, reason, pattern }
17
+ *
18
+ * @param {object} config — loaded zclean config
19
+ * @param {object} opts — { sessionPid?: number } for session-aware filtering
20
+ */
21
+ function scan(config, opts = {}) {
22
+ const processes = listProcesses();
23
+ const zombies = [];
24
+
25
+ for (const proc of processes) {
26
+ // Match against known AI tool patterns
27
+ const pattern = matchPattern(proc.cmd);
28
+ if (!pattern) continue;
29
+
30
+ // Check orphan status
31
+ const orphanResult = checkOrphan(proc.pid);
32
+ proc.ppid = orphanResult.ppid;
33
+
34
+ // If pattern requires orphan status and process isn't orphaned, skip
35
+ if (pattern.orphanOnly && !orphanResult.isOrphan) continue;
36
+
37
+ // Skip processes in tmux/screen sessions (they're likely intentional)
38
+ if (isInTerminalMultiplexer(proc.pid)) continue;
39
+
40
+ // Check whitelist
41
+ const whitelistResult = isWhitelisted(proc, config);
42
+ if (whitelistResult.protected) continue;
43
+
44
+ // Check maxOrphanAge if defined on the pattern
45
+ if (pattern.maxOrphanAge) {
46
+ const maxAge = parseDuration(pattern.maxOrphanAge);
47
+ if (maxAge && proc.age < maxAge) continue;
48
+ }
49
+
50
+ // Check memory threshold for node-ai-path pattern
51
+ if (pattern.memThreshold) {
52
+ const threshold = parseMemory(pattern.memThreshold);
53
+ const configThreshold = parseMemory(config.memoryThreshold);
54
+ // Use whichever is set — pattern threshold is the gate, but either can trigger
55
+ if (threshold && proc.mem < threshold) {
56
+ // Also check if age exceeds maxOrphanAge
57
+ if (pattern.maxOrphanAge) {
58
+ const maxAge = parseDuration(pattern.maxOrphanAge);
59
+ if (maxAge && proc.age < maxAge) continue;
60
+ } else {
61
+ continue;
62
+ }
63
+ }
64
+ }
65
+
66
+ // Session affinity: if --session-pid was provided, prefer processes
67
+ // that were children of that session
68
+ if (opts.sessionPid) {
69
+ // Still include non-session orphans, but note affinity
70
+ proc.sessionRelated = isSessionRelated(proc.pid, opts.sessionPid);
71
+ }
72
+
73
+ // Build reason string
74
+ const reasons = [];
75
+ reasons.push(`pattern:${pattern.name}`);
76
+ if (orphanResult.isOrphan) reasons.push(`orphan:${orphanResult.reason}`);
77
+ if (proc.age > parseDuration(config.maxAge || '24h')) reasons.push('age-exceeded');
78
+ if (parseMemory(config.memoryThreshold) && proc.mem > parseMemory(config.memoryThreshold)) {
79
+ reasons.push('memory-exceeded');
80
+ }
81
+
82
+ zombies.push({
83
+ pid: proc.pid,
84
+ name: pattern.name,
85
+ cmd: proc.cmd,
86
+ ppid: proc.ppid,
87
+ mem: proc.mem,
88
+ age: proc.age,
89
+ startTime: proc.startTime,
90
+ reason: reasons.join(', '),
91
+ pattern: pattern.name,
92
+ });
93
+ }
94
+
95
+ return zombies;
96
+ }
97
+
98
+ /**
99
+ * List all processes with their details.
100
+ * Cross-platform: macOS/Linux use `ps`, Windows uses `wmic`.
101
+ *
102
+ * Returns array of { pid, cmd, mem, age, startTime }
103
+ */
104
+ function listProcesses() {
105
+ if (platform === 'win32') {
106
+ return listProcessesWindows();
107
+ }
108
+ return listProcessesUnix();
109
+ }
110
+
111
+ /**
112
+ * Unix process listing via `ps aux`.
113
+ */
114
+ function listProcessesUnix() {
115
+ let output;
116
+ try {
117
+ // ps aux with etime for age calculation
118
+ // Columns: PID, RSS (KB), ELAPSED, STARTED, COMMAND
119
+ output = execSync('ps -eo pid=,rss=,etime=,lstart=,command=', {
120
+ encoding: 'utf-8',
121
+ timeout: 10000,
122
+ maxBuffer: 10 * 1024 * 1024,
123
+ });
124
+ } catch {
125
+ return [];
126
+ }
127
+
128
+ const processes = [];
129
+ const lines = output.trim().split('\n');
130
+ const myPid = process.pid;
131
+
132
+ for (const line of lines) {
133
+ const trimmed = line.trim();
134
+ if (!trimmed) continue;
135
+
136
+ // Parse the fixed-width fields
137
+ // Format: " PID RSS ELAPSED LSTART COMMAND"
138
+ const match = trimmed.match(
139
+ /^\s*(\d+)\s+(\d+)\s+([\d:.-]+)\s+\w+\s+(\w+\s+\d+\s+[\d:]+\s+\d+)\s+(.+)$/
140
+ );
141
+ if (!match) continue;
142
+
143
+ const pid = parseInt(match[1], 10);
144
+ const rssKB = parseInt(match[2], 10);
145
+ const elapsed = match[3];
146
+ const lstart = match[4];
147
+ const cmd = match[5];
148
+
149
+ // Skip our own process
150
+ if (pid === myPid) continue;
151
+
152
+ // Parse elapsed time (format: [[DD-]HH:]MM:SS)
153
+ const ageMs = parseElapsed(elapsed);
154
+
155
+ // Parse start time
156
+ let startTime = null;
157
+ try {
158
+ startTime = new Date(lstart).toISOString();
159
+ } catch {
160
+ // Ignore parse errors
161
+ }
162
+
163
+ processes.push({
164
+ pid,
165
+ cmd,
166
+ mem: rssKB * 1024, // Convert KB to bytes
167
+ age: ageMs,
168
+ startTime,
169
+ });
170
+ }
171
+
172
+ return processes;
173
+ }
174
+
175
+ /**
176
+ * Windows process listing via wmic.
177
+ */
178
+ function listProcessesWindows() {
179
+ let output;
180
+ try {
181
+ output = execSync(
182
+ 'wmic process get ProcessId,CommandLine,WorkingSetSize,CreationDate /format:csv',
183
+ { encoding: 'utf-8', timeout: 15000, maxBuffer: 10 * 1024 * 1024 }
184
+ );
185
+ } catch {
186
+ return [];
187
+ }
188
+
189
+ const processes = [];
190
+ const lines = output.trim().split('\n');
191
+ const myPid = process.pid;
192
+
193
+ for (const line of lines) {
194
+ const parts = line.trim().split(',');
195
+ if (parts.length < 5) continue;
196
+
197
+ // CSV format: Node, CommandLine, CreationDate, ProcessId, WorkingSetSize
198
+ const cmd = parts[1];
199
+ const creationDate = parts[2];
200
+ const pid = parseInt(parts[3], 10);
201
+ const workingSet = parseInt(parts[4], 10);
202
+
203
+ if (isNaN(pid) || pid === myPid || !cmd) continue;
204
+
205
+ // Parse WMI datetime: YYYYMMDDHHMMSS.MMMMMM+UUU
206
+ let ageMs = 0;
207
+ let startTime = null;
208
+ if (creationDate) {
209
+ try {
210
+ const year = creationDate.substring(0, 4);
211
+ const month = creationDate.substring(4, 6);
212
+ const day = creationDate.substring(6, 8);
213
+ const hours = creationDate.substring(8, 10);
214
+ const minutes = creationDate.substring(10, 12);
215
+ const seconds = creationDate.substring(12, 14);
216
+ const dt = new Date(`${year}-${month}-${day}T${hours}:${minutes}:${seconds}`);
217
+ startTime = dt.toISOString();
218
+ ageMs = Date.now() - dt.getTime();
219
+ } catch {
220
+ // Ignore
221
+ }
222
+ }
223
+
224
+ processes.push({
225
+ pid,
226
+ cmd,
227
+ mem: workingSet || 0,
228
+ age: ageMs,
229
+ startTime,
230
+ });
231
+ }
232
+
233
+ return processes;
234
+ }
235
+
236
+ /**
237
+ * Parse ps elapsed time format: [[DD-]HH:]MM:SS or DD-HH:MM:SS
238
+ * Returns milliseconds.
239
+ */
240
+ function parseElapsed(elapsed) {
241
+ if (!elapsed) return 0;
242
+
243
+ let days = 0;
244
+ let rest = elapsed.trim();
245
+
246
+ // Check for "DD-" prefix
247
+ const dayMatch = rest.match(/^(\d+)-(.+)$/);
248
+ if (dayMatch) {
249
+ days = parseInt(dayMatch[1], 10);
250
+ rest = dayMatch[2];
251
+ }
252
+
253
+ const parts = rest.split(':').map((p) => parseInt(p, 10));
254
+
255
+ let hours = 0, minutes = 0, seconds = 0;
256
+ if (parts.length === 3) {
257
+ [hours, minutes, seconds] = parts;
258
+ } else if (parts.length === 2) {
259
+ [minutes, seconds] = parts;
260
+ } else if (parts.length === 1) {
261
+ [seconds] = parts;
262
+ }
263
+
264
+ return ((days * 24 + hours) * 3600 + minutes * 60 + seconds) * 1000;
265
+ }
266
+
267
+ /**
268
+ * Check if a process was a descendant of a given session PID.
269
+ * Used for session-affinity cleanup.
270
+ */
271
+ function isSessionRelated(pid, sessionPid) {
272
+ if (platform === 'win32') return false;
273
+
274
+ const visited = new Set();
275
+ let currentPid = pid;
276
+
277
+ while (currentPid > 1 && !visited.has(currentPid)) {
278
+ visited.add(currentPid);
279
+ if (currentPid === sessionPid) return true;
280
+
281
+ try {
282
+ const ppidStr = execSync(`ps -o ppid= -p ${currentPid}`, {
283
+ encoding: 'utf-8',
284
+ timeout: 3000,
285
+ }).trim();
286
+ currentPid = parseInt(ppidStr, 10);
287
+ if (isNaN(currentPid)) break;
288
+ } catch {
289
+ break;
290
+ }
291
+ }
292
+
293
+ return false;
294
+ }
295
+
296
+ module.exports = { scan, listProcesses, parseElapsed };