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,149 @@
1
+ 'use strict';
2
+
3
+ const { execSync } = require('child_process');
4
+ const os = require('os');
5
+
6
+ const platform = os.platform();
7
+
8
+ /**
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
15
+ *
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.
33
+ */
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
+
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
+ }
71
+
72
+ /**
73
+ * Windows orphan check — parent process doesn't exist.
74
+ */
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
+ }
104
+ }
105
+
106
+ /**
107
+ * Check if a process is inside a tmux or screen session tree.
108
+ * Walks the process tree upward looking for tmux/screen ancestor.
109
+ *
110
+ * Returns true if the process has a tmux or screen ancestor.
111
+ */
112
+ 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;
147
+ }
148
+
149
+ module.exports = { checkOrphan, isInTerminalMultiplexer };
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Process patterns to detect AI tool zombies.
5
+ *
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)
13
+ */
14
+ const PATTERNS = [
15
+ // MCP servers — any orphaned MCP server is suspect
16
+ {
17
+ name: 'mcp-server',
18
+ match: /mcp-server/,
19
+ minAge: 0,
20
+ maxOrphanAge: '1h',
21
+ orphanOnly: true,
22
+ },
23
+
24
+ // Headless browsers spawned by agents
25
+ {
26
+ name: 'agent-browser',
27
+ match: /agent-browser|chrome-headless-shell/,
28
+ minAge: 0,
29
+ orphanOnly: true,
30
+ },
31
+
32
+ // Playwright driver processes
33
+ {
34
+ name: 'playwright',
35
+ match: /playwright[/\\]driver/,
36
+ minAge: 0,
37
+ orphanOnly: true,
38
+ },
39
+
40
+ // Claude subagent processes
41
+ {
42
+ name: 'claude-subagent',
43
+ match: /claude\s+--print/,
44
+ minAge: 0,
45
+ orphanOnly: true,
46
+ },
47
+
48
+ // Codex exec processes
49
+ {
50
+ name: 'codex-exec',
51
+ match: /codex\s+exec/,
52
+ minAge: 0,
53
+ orphanOnly: true,
54
+ },
55
+
56
+ // Build tools — only if orphaned for 24h+
57
+ {
58
+ name: 'esbuild',
59
+ match: /esbuild/,
60
+ minAge: 0,
61
+ maxOrphanAge: '24h',
62
+ orphanOnly: true,
63
+ },
64
+ {
65
+ name: 'vite',
66
+ match: /vite/,
67
+ minAge: 0,
68
+ maxOrphanAge: '24h',
69
+ orphanOnly: true,
70
+ },
71
+ {
72
+ name: 'next-dev',
73
+ match: /next\s+dev/,
74
+ minAge: 0,
75
+ maxOrphanAge: '24h',
76
+ orphanOnly: true,
77
+ },
78
+ {
79
+ name: 'webpack',
80
+ match: /webpack/,
81
+ minAge: 0,
82
+ maxOrphanAge: '24h',
83
+ orphanOnly: true,
84
+ },
85
+
86
+ // npx/npm exec — orphaned
87
+ {
88
+ name: 'npm-exec',
89
+ match: /npm\s+exec|npx\s/,
90
+ minAge: 0,
91
+ orphanOnly: true,
92
+ },
93
+
94
+ // Node processes with AI tool paths — orphan + age/memory gated
95
+ {
96
+ name: 'node-ai-path',
97
+ match: /node\b.*(?:\.claude[/\\]|[/\\]mcp[/\\]|[/\\]agent[/\\])/,
98
+ minAge: 0,
99
+ maxOrphanAge: '24h',
100
+ memThreshold: '500MB',
101
+ orphanOnly: true,
102
+ },
103
+
104
+ // TypeScript runners — orphan + AI tool related
105
+ {
106
+ name: 'tsx',
107
+ match: /\btsx\b/,
108
+ minAge: 0,
109
+ maxOrphanAge: '24h',
110
+ orphanOnly: true,
111
+ },
112
+ {
113
+ name: 'ts-node',
114
+ match: /ts-node/,
115
+ minAge: 0,
116
+ maxOrphanAge: '24h',
117
+ orphanOnly: true,
118
+ },
119
+ {
120
+ name: 'bun',
121
+ match: /\bbun\b.*(?:\.claude|mcp|agent)/,
122
+ minAge: 0,
123
+ maxOrphanAge: '24h',
124
+ orphanOnly: true,
125
+ },
126
+ {
127
+ name: 'deno',
128
+ match: /\bdeno\b.*(?:\.claude|mcp|agent)/,
129
+ minAge: 0,
130
+ maxOrphanAge: '24h',
131
+ orphanOnly: true,
132
+ },
133
+ ];
134
+
135
+ /**
136
+ * Match a command line against known patterns.
137
+ * Returns the first matching pattern or null.
138
+ */
139
+ function matchPattern(cmdline) {
140
+ for (const pattern of PATTERNS) {
141
+ if (pattern.match.test(cmdline)) {
142
+ return pattern;
143
+ }
144
+ }
145
+ return null;
146
+ }
147
+
148
+ module.exports = { PATTERNS, matchPattern };
@@ -0,0 +1,178 @@
1
+ 'use strict';
2
+
3
+ const { execSync } = require('child_process');
4
+ const os = require('os');
5
+ const fs = require('fs');
6
+
7
+ const platform = os.platform();
8
+
9
+ // Daemon managers that indicate intentionally long-running processes
10
+ const DAEMON_MANAGERS = ['pm2', 'forever', 'supervisord', 'supervisor', 'nodemon'];
11
+
12
+ /**
13
+ * Check if a process should be protected from cleanup.
14
+ *
15
+ * Protection criteria:
16
+ * 1. In user's config whitelist (PID or name pattern)
17
+ * 2. Has a daemon manager ancestor (pm2, forever, supervisord)
18
+ * 3. Running inside a Docker container (Linux: different PID namespace)
19
+ * 4. Launched with nohup
20
+ * 5. VS Code child process (48h grace period)
21
+ *
22
+ * Returns { protected: boolean, reason: string }
23
+ */
24
+ function isWhitelisted(proc, config) {
25
+ // 1. Config whitelist — match by PID or name substring
26
+ if (config.whitelist && config.whitelist.length > 0) {
27
+ for (const entry of config.whitelist) {
28
+ if (typeof entry === 'number' && proc.pid === entry) {
29
+ return { protected: true, reason: `config-whitelist-pid:${entry}` };
30
+ }
31
+ if (typeof entry === 'string' && proc.cmd.includes(entry)) {
32
+ return { protected: true, reason: `config-whitelist-pattern:${entry}` };
33
+ }
34
+ }
35
+ }
36
+
37
+ // 2. Daemon manager ancestor
38
+ if (hasDaemonAncestor(proc.pid)) {
39
+ return { protected: true, reason: 'daemon-managed' };
40
+ }
41
+
42
+ // 3. Docker container (Linux only)
43
+ if (isInDocker(proc.pid)) {
44
+ return { protected: true, reason: 'docker-container' };
45
+ }
46
+
47
+ // 4. nohup-launched
48
+ if (isNohup(proc.cmd)) {
49
+ return { protected: true, reason: 'nohup-launched' };
50
+ }
51
+
52
+ // 5. VS Code child with grace period
53
+ const vscodeGrace = 48 * 60 * 60 * 1000; // 48 hours
54
+ if (isVSCodeChild(proc.pid) && proc.age < vscodeGrace) {
55
+ return { protected: true, reason: 'vscode-child-grace' };
56
+ }
57
+
58
+ return { protected: false, reason: '' };
59
+ }
60
+
61
+ /**
62
+ * Walk process tree upward looking for daemon manager ancestors.
63
+ */
64
+ function hasDaemonAncestor(pid) {
65
+ if (platform === 'win32') {
66
+ // Simplified check for Windows — just check parent command
67
+ try {
68
+ const output = execSync(
69
+ `wmic process where ProcessId=${pid} get ParentProcessId /value`,
70
+ { encoding: 'utf-8', timeout: 5000 }
71
+ ).trim();
72
+ const match = output.match(/ParentProcessId=(\d+)/);
73
+ if (!match) return false;
74
+ const ppid = parseInt(match[1], 10);
75
+ const parentCmd = execSync(
76
+ `wmic process where ProcessId=${ppid} get CommandLine /value`,
77
+ { encoding: 'utf-8', timeout: 5000 }
78
+ ).trim();
79
+ return DAEMON_MANAGERS.some((dm) => parentCmd.toLowerCase().includes(dm));
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ // Unix: walk up the tree
86
+ const visited = new Set();
87
+ let currentPid = pid;
88
+
89
+ while (currentPid > 1 && !visited.has(currentPid)) {
90
+ visited.add(currentPid);
91
+ try {
92
+ const info = execSync(`ps -o ppid=,comm= -p ${currentPid}`, {
93
+ encoding: 'utf-8',
94
+ timeout: 5000,
95
+ }).trim();
96
+
97
+ const parts = info.trim().split(/\s+/);
98
+ if (parts.length < 2) break;
99
+
100
+ const parentPid = parseInt(parts[0], 10);
101
+ const comm = parts.slice(1).join(' ').toLowerCase();
102
+
103
+ if (DAEMON_MANAGERS.some((dm) => comm.includes(dm))) {
104
+ return true;
105
+ }
106
+
107
+ if (isNaN(parentPid) || parentPid <= 1) break;
108
+ currentPid = parentPid;
109
+ } catch {
110
+ break;
111
+ }
112
+ }
113
+
114
+ return false;
115
+ }
116
+
117
+ /**
118
+ * Check if a process is running inside a Docker container.
119
+ * Linux only: compares PID namespace with init (PID 1).
120
+ */
121
+ function isInDocker(pid) {
122
+ if (platform !== 'linux') return false;
123
+
124
+ try {
125
+ const procNs = fs.readlinkSync(`/proc/${pid}/ns/pid`);
126
+ const initNs = fs.readlinkSync('/proc/1/ns/pid');
127
+ return procNs !== initNs;
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Check if the command line suggests nohup launch.
135
+ */
136
+ function isNohup(cmdline) {
137
+ return /\bnohup\b/.test(cmdline);
138
+ }
139
+
140
+ /**
141
+ * Check if a process is a child of VS Code.
142
+ */
143
+ function isVSCodeChild(pid) {
144
+ if (platform === 'win32') return false;
145
+
146
+ const visited = new Set();
147
+ let currentPid = pid;
148
+
149
+ while (currentPid > 1 && !visited.has(currentPid)) {
150
+ visited.add(currentPid);
151
+ try {
152
+ const info = execSync(`ps -o ppid=,comm= -p ${currentPid}`, {
153
+ encoding: 'utf-8',
154
+ timeout: 5000,
155
+ }).trim();
156
+
157
+ const parts = info.trim().split(/\s+/);
158
+ if (parts.length < 2) break;
159
+
160
+ const parentPid = parseInt(parts[0], 10);
161
+ const comm = parts.slice(1).join(' ').toLowerCase();
162
+
163
+ // VS Code process names: code, code-insiders, electron (Code.app)
164
+ if (/\b(code|code-insiders|electron.*code)\b/.test(comm)) {
165
+ return true;
166
+ }
167
+
168
+ if (isNaN(parentPid) || parentPid <= 1) break;
169
+ currentPid = parentPid;
170
+ } catch {
171
+ break;
172
+ }
173
+ }
174
+
175
+ return false;
176
+ }
177
+
178
+ module.exports = { isWhitelisted, hasDaemonAncestor, isInDocker, isNohup, isVSCodeChild };
@@ -0,0 +1,136 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
8
+ // Use npx to ensure zclean is found regardless of global install status
9
+ const HOOK_COMMAND = 'npx --yes @thestackai/zclean --yes --session-pid=$PPID';
10
+ const HOOK_ID = 'zclean-session-cleanup';
11
+
12
+ /**
13
+ * Install a Claude Code SessionEnd hook that runs zclean on session end.
14
+ *
15
+ * Claude Code hooks use a matcher + hooks array format:
16
+ * {
17
+ * "hooks": {
18
+ * "Stop": [
19
+ * {
20
+ * "matcher": "",
21
+ * "hooks": [
22
+ * { "type": "command", "command": "..." }
23
+ * ]
24
+ * }
25
+ * ]
26
+ * }
27
+ * }
28
+ *
29
+ * This is idempotent — won't duplicate if already registered.
30
+ *
31
+ * @returns {{ installed: boolean, message: string }}
32
+ */
33
+ function installHook() {
34
+ // Check if Claude Code settings directory exists
35
+ const claudeDir = path.dirname(CLAUDE_SETTINGS_PATH);
36
+ if (!fs.existsSync(claudeDir)) {
37
+ return {
38
+ installed: false,
39
+ message: `Claude Code config directory not found: ${claudeDir}`,
40
+ };
41
+ }
42
+
43
+ // Load or create settings
44
+ let settings = {};
45
+ if (fs.existsSync(CLAUDE_SETTINGS_PATH)) {
46
+ try {
47
+ settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf-8'));
48
+ } catch {
49
+ return {
50
+ installed: false,
51
+ message: `Failed to parse ${CLAUDE_SETTINGS_PATH}. Please fix the JSON and retry.`,
52
+ };
53
+ }
54
+ }
55
+
56
+ // Ensure hooks structure
57
+ if (!settings.hooks) settings.hooks = {};
58
+ if (!Array.isArray(settings.hooks.Stop)) settings.hooks.Stop = [];
59
+
60
+ // Check if already installed (handle both old flat format and new matcher format)
61
+ const existing = settings.hooks.Stop.find(
62
+ (h) =>
63
+ h.id === HOOK_ID ||
64
+ (h.command && h.command.includes('zclean')) ||
65
+ (Array.isArray(h.hooks) && h.hooks.some((sub) => sub.command && sub.command.includes('zclean')))
66
+ );
67
+ if (existing) {
68
+ return {
69
+ installed: true,
70
+ message: 'Hook already registered in Claude Code settings.',
71
+ };
72
+ }
73
+
74
+ // Add hook using the matcher + hooks array format required by Claude Code
75
+ settings.hooks.Stop.push({
76
+ matcher: '',
77
+ hooks: [
78
+ {
79
+ type: 'command',
80
+ command: HOOK_COMMAND,
81
+ },
82
+ ],
83
+ });
84
+
85
+ // Write back
86
+ fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
87
+
88
+ return {
89
+ installed: true,
90
+ message: `Hook registered: ${CLAUDE_SETTINGS_PATH}`,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Remove the zclean hook from Claude Code settings.
96
+ *
97
+ * @returns {{ removed: boolean, message: string }}
98
+ */
99
+ function removeHook() {
100
+ if (!fs.existsSync(CLAUDE_SETTINGS_PATH)) {
101
+ return { removed: false, message: 'Claude Code settings not found.' };
102
+ }
103
+
104
+ let settings;
105
+ try {
106
+ settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf-8'));
107
+ } catch {
108
+ return { removed: false, message: 'Failed to parse settings.' };
109
+ }
110
+
111
+ if (!settings.hooks || !Array.isArray(settings.hooks.Stop)) {
112
+ return { removed: false, message: 'No hooks found.' };
113
+ }
114
+
115
+ const before = settings.hooks.Stop.length;
116
+ settings.hooks.Stop = settings.hooks.Stop.filter(
117
+ (h) =>
118
+ h.id !== HOOK_ID &&
119
+ !(h.command && h.command.includes('zclean')) &&
120
+ !(Array.isArray(h.hooks) && h.hooks.some((sub) => sub.command && sub.command.includes('zclean')))
121
+ );
122
+
123
+ if (settings.hooks.Stop.length === before) {
124
+ return { removed: false, message: 'Hook was not registered.' };
125
+ }
126
+
127
+ // Clean up empty structures
128
+ if (settings.hooks.Stop.length === 0) delete settings.hooks.Stop;
129
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
130
+
131
+ fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
132
+
133
+ return { removed: true, message: 'Hook removed from Claude Code settings.' };
134
+ }
135
+
136
+ module.exports = { installHook, removeHook };