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.
- package/LICENSE +21 -0
- package/README.md +186 -0
- package/bin/zclean.js +272 -0
- package/package.json +12 -0
- package/src/config.js +148 -0
- package/src/detector/orphan.js +149 -0
- package/src/detector/patterns.js +148 -0
- package/src/detector/whitelist.js +178 -0
- package/src/installer/hook.js +136 -0
- package/src/installer/launchd.js +166 -0
- package/src/installer/systemd.js +162 -0
- package/src/installer/taskscheduler.js +102 -0
- package/src/killer.js +248 -0
- package/src/reporter.js +220 -0
- package/src/scanner.js +296 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 whynowlab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
<pre>
|
|
4
|
+
███████ ██████ ██ ███████ █████ ███ ██
|
|
5
|
+
╚══███ ██ ██ ██ ██ ██ ████ ██
|
|
6
|
+
███ ██ ██ █████ ███████ ██ ██ ██
|
|
7
|
+
███ ██ ██ ██ ██ ██ ██ ██ ██
|
|
8
|
+
███████ ██████ ███████ ███████ ██ ██ ██ ████
|
|
9
|
+
</pre>
|
|
10
|
+
|
|
11
|
+
**Stop AI coding tools from eating your RAM.**
|
|
12
|
+
|
|
13
|
+
[](https://www.npmjs.com/package/@thestackai/zclean)
|
|
14
|
+
[](https://opensource.org/licenses/MIT)
|
|
15
|
+
[](#)
|
|
16
|
+
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<p align="center">
|
|
20
|
+
<img src="assets/demo.gif" alt="zclean demo" width="600">
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Quick Demo
|
|
26
|
+
|
|
27
|
+
AI coding tools spawn child processes — MCP servers, sub-agents, headless browsers, build watchers. When the session ends or crashes, those children don't always exit. They pile up silently, draining RAM until your machine grinds to a halt.
|
|
28
|
+
|
|
29
|
+
**Before**
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
$ zclean
|
|
33
|
+
|
|
34
|
+
zclean — scanning for zombie processes...
|
|
35
|
+
|
|
36
|
+
Found 12 zombie processes:
|
|
37
|
+
|
|
38
|
+
PID 26413 node 367 MB (orphan, 18h) was: claude mcp-server
|
|
39
|
+
PID 62830 chrome 200 MB (orphan, 3h) was: agent-browser
|
|
40
|
+
PID 26221 npm 142 MB (orphan, 2d) was: npm exec task-master-ai
|
|
41
|
+
PID 23096 node 355 MB (orphan, 6h) was: claude sub-agent
|
|
42
|
+
... 8 more
|
|
43
|
+
|
|
44
|
+
Total: 12 zombies, ~2.4 GB reclaimable
|
|
45
|
+
|
|
46
|
+
Run `zclean --yes` to clean.
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**After**
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
$ zclean --yes
|
|
53
|
+
|
|
54
|
+
zclean — scanning for zombie processes...
|
|
55
|
+
|
|
56
|
+
Cleaned 12 zombie processes. Reclaimed ~2.4 GB.
|
|
57
|
+
|
|
58
|
+
zclean status:
|
|
59
|
+
Protection: active
|
|
60
|
+
SessionEnd hook: registered
|
|
61
|
+
Hourly scheduler: running
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Why zclean?
|
|
65
|
+
|
|
66
|
+
Claude Code, Codex, and other AI coding tools spawn dozens of child processes per session: MCP servers, sub-agents, headless Chromium instances, esbuild watchers, and more. When the parent session exits — especially on crash or force-quit — these children become orphans.
|
|
67
|
+
|
|
68
|
+
They keep running. They keep consuming RAM. Your machine gets slower day by day, and you blame the AI tool when the real culprit is zombie processes nobody cleaned up.
|
|
69
|
+
|
|
70
|
+
`zclean` fixes this automatically. Install once, forget about it.
|
|
71
|
+
|
|
72
|
+
## Install
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npx zclean init
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
That's it. This command:
|
|
79
|
+
1. Detects your OS (macOS / Linux / Windows)
|
|
80
|
+
2. Registers a Claude Code `SessionEnd` hook for instant cleanup
|
|
81
|
+
3. Sets up an hourly background scan as a safety net
|
|
82
|
+
4. Creates your config at `~/.zclean/config.json`
|
|
83
|
+
|
|
84
|
+
## How it works
|
|
85
|
+
|
|
86
|
+
**Layer 1 — SessionEnd Hook**
|
|
87
|
+
When a Claude Code session ends, `zclean` immediately cleans up that session's orphaned children. Fast and targeted.
|
|
88
|
+
|
|
89
|
+
**Layer 2 — Hourly Scheduler**
|
|
90
|
+
A lightweight background scan catches anything the hook missed: crash leftovers, Codex orphans, stale browser daemons, and processes from tools that don't support hooks.
|
|
91
|
+
|
|
92
|
+
Together, these two layers keep your system clean without you ever thinking about it.
|
|
93
|
+
|
|
94
|
+
## Safety
|
|
95
|
+
|
|
96
|
+
`zclean` follows one rule: **if the parent is alive, don't touch it.**
|
|
97
|
+
|
|
98
|
+
- Scans are **dry-run by default** — you see what would be cleaned before anything happens
|
|
99
|
+
- Only targets **known AI tool process patterns** (MCP servers, agent browsers, sub-agents, build zombies)
|
|
100
|
+
- **Whitelist support** — protect any process you want to keep
|
|
101
|
+
- **Skips** tmux/screen sessions, PM2/Forever daemons, Docker containers, VS Code children
|
|
102
|
+
- **Re-verifies** PID identity before every kill (prevents PID recycling accidents)
|
|
103
|
+
- Logs every action with full command line for manual recovery
|
|
104
|
+
|
|
105
|
+
Your `node server.js` running in a terminal tab? Untouched. Your `vite dev` in tmux? Untouched. Only true orphans from dead AI sessions get cleaned.
|
|
106
|
+
|
|
107
|
+
## Commands
|
|
108
|
+
|
|
109
|
+
| Command | Description |
|
|
110
|
+
|---------|-------------|
|
|
111
|
+
| `zclean` | Scan for zombies (dry-run, shows what would be cleaned) |
|
|
112
|
+
| `zclean --yes` | Scan and clean zombie processes |
|
|
113
|
+
| `zclean init` | Install SessionEnd hook + hourly scheduler |
|
|
114
|
+
| `zclean status` | Show protection status and cleanup history |
|
|
115
|
+
| `zclean logs` | View detailed cleanup log |
|
|
116
|
+
| `zclean config` | Show current configuration |
|
|
117
|
+
| `zclean uninstall` | Remove all hooks and schedulers |
|
|
118
|
+
|
|
119
|
+
## Configuration
|
|
120
|
+
|
|
121
|
+
`~/.zclean/config.json`:
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"whitelist": [],
|
|
126
|
+
"maxAge": "24h",
|
|
127
|
+
"memoryThreshold": "500MB",
|
|
128
|
+
"schedule": "hourly",
|
|
129
|
+
"sigterm_timeout": 10,
|
|
130
|
+
"dryRunDefault": true,
|
|
131
|
+
"logRetention": "30d"
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
| Option | Default | Description |
|
|
136
|
+
|--------|---------|-------------|
|
|
137
|
+
| `whitelist` | `[]` | Process name patterns to never kill |
|
|
138
|
+
| `maxAge` | `"24h"` | Kill orphan `node`/`esbuild` only after this age |
|
|
139
|
+
| `memoryThreshold` | `"500MB"` | Flag orphans above this RAM usage regardless of age |
|
|
140
|
+
| `sigterm_timeout` | `10` | Seconds to wait after SIGTERM before SIGKILL |
|
|
141
|
+
| `dryRunDefault` | `true` | Manual `zclean` runs in dry-run mode |
|
|
142
|
+
|
|
143
|
+
## FAQ
|
|
144
|
+
|
|
145
|
+
### Will this kill my running Claude Code session?
|
|
146
|
+
No. `zclean` checks if the parent process is alive. Active sessions and their children are always protected.
|
|
147
|
+
|
|
148
|
+
### What about my `vite dev` / `next dev` server?
|
|
149
|
+
If you started it in a terminal, tmux, or VS Code — it has a living parent and won't be touched. Only orphaned dev servers (parent process dead for 24h+) are candidates.
|
|
150
|
+
|
|
151
|
+
### Does the hourly scheduler slow my machine?
|
|
152
|
+
No. It runs a single process scan (~100ms), cleans if needed, and exits. No persistent daemon.
|
|
153
|
+
|
|
154
|
+
### How do I stop zclean completely?
|
|
155
|
+
```bash
|
|
156
|
+
zclean uninstall
|
|
157
|
+
npm uninstall -g zclean
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Supported Tools
|
|
161
|
+
|
|
162
|
+
| Tool | Cleanup Coverage |
|
|
163
|
+
|------|-----------------|
|
|
164
|
+
| Claude Code | MCP servers, sub-agents, agent-browser, playwright |
|
|
165
|
+
| Codex | codex exec, background node workers |
|
|
166
|
+
| Build tools | esbuild, vite, webpack, next dev (orphaned only) |
|
|
167
|
+
| MCP servers | Any `mcp-server-*` pattern |
|
|
168
|
+
| Runtimes | node, tsx, ts-node, bun, deno, python (AI tool paths only) |
|
|
169
|
+
|
|
170
|
+
## Contributing
|
|
171
|
+
|
|
172
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
|
173
|
+
|
|
174
|
+
Adding a new process pattern? Edit `src/detector/patterns.js` and open a PR.
|
|
175
|
+
|
|
176
|
+
## License
|
|
177
|
+
|
|
178
|
+
MIT — see [LICENSE](LICENSE).
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
<div align="center">
|
|
183
|
+
|
|
184
|
+
Built by [whynowlab](https://github.com/whynowlab) — the team behind [Swing](https://github.com/whynowlab/swing-skills).
|
|
185
|
+
|
|
186
|
+
</div>
|
package/bin/zclean.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const { scan } = require('../src/scanner');
|
|
6
|
+
const { killZombies } = require('../src/killer');
|
|
7
|
+
const { loadConfig, saveConfig, readLogs, pruneLogs, CONFIG_FILE, DEFAULT_CONFIG, appendLog } = require('../src/config');
|
|
8
|
+
const { reportDryRun, reportKill, reportStatus, reportLogs, reportConfig, c, bold } = require('../src/reporter');
|
|
9
|
+
const { installHook, removeHook } = require('../src/installer/hook');
|
|
10
|
+
|
|
11
|
+
// Platform-specific installers (lazy loaded)
|
|
12
|
+
const platform = os.platform();
|
|
13
|
+
|
|
14
|
+
// ─── CLI Argument Parsing ───────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
const args = process.argv.slice(2);
|
|
17
|
+
const flags = {};
|
|
18
|
+
const positional = [];
|
|
19
|
+
|
|
20
|
+
for (const arg of args) {
|
|
21
|
+
if (arg.startsWith('--')) {
|
|
22
|
+
const [key, value] = arg.substring(2).split('=');
|
|
23
|
+
flags[key] = value !== undefined ? value : true;
|
|
24
|
+
} else if (arg.startsWith('-') && arg.length === 2) {
|
|
25
|
+
flags[arg.substring(1)] = true;
|
|
26
|
+
} else {
|
|
27
|
+
positional.push(arg);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const command = positional[0] || null;
|
|
32
|
+
|
|
33
|
+
// ─── Version / Help ─────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
if (flags.version || flags.v) {
|
|
36
|
+
const pkg = require('../package.json');
|
|
37
|
+
console.log(`zclean v${pkg.version}`);
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (flags.help || flags.h) {
|
|
42
|
+
printHelp();
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── Command Dispatch ───────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
async function main() {
|
|
49
|
+
const config = loadConfig();
|
|
50
|
+
|
|
51
|
+
switch (command) {
|
|
52
|
+
case 'init':
|
|
53
|
+
return cmdInit(config);
|
|
54
|
+
|
|
55
|
+
case 'status':
|
|
56
|
+
return cmdStatus(config);
|
|
57
|
+
|
|
58
|
+
case 'logs':
|
|
59
|
+
return cmdLogs(config);
|
|
60
|
+
|
|
61
|
+
case 'uninstall':
|
|
62
|
+
return cmdUninstall();
|
|
63
|
+
|
|
64
|
+
case 'config':
|
|
65
|
+
return cmdConfig(config);
|
|
66
|
+
|
|
67
|
+
case null:
|
|
68
|
+
// Default: scan (dry-run unless --yes)
|
|
69
|
+
return cmdScan(config);
|
|
70
|
+
|
|
71
|
+
default:
|
|
72
|
+
console.error(c('red', ` Unknown command: ${command}`));
|
|
73
|
+
printHelp();
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── Commands ───────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Default command: scan for zombies.
|
|
82
|
+
* Dry-run unless --yes is passed.
|
|
83
|
+
*/
|
|
84
|
+
function cmdScan(config) {
|
|
85
|
+
const sessionPid = flags['session-pid'] ? parseInt(flags['session-pid'], 10) : null;
|
|
86
|
+
const force = flags.yes || flags.y;
|
|
87
|
+
|
|
88
|
+
console.log(bold('\n zclean') + c('gray', ' — scanning for zombie processes...\n'));
|
|
89
|
+
|
|
90
|
+
const zombies = scan(config, { sessionPid });
|
|
91
|
+
|
|
92
|
+
if (force) {
|
|
93
|
+
// Kill mode
|
|
94
|
+
if (zombies.length === 0) {
|
|
95
|
+
console.log(c('green', ' No zombie processes found. System is clean.\n'));
|
|
96
|
+
appendLog({ action: 'scan', found: 0 });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
appendLog({ action: 'scan', found: zombies.length });
|
|
100
|
+
const results = killZombies(zombies, config);
|
|
101
|
+
reportKill(results);
|
|
102
|
+
} else {
|
|
103
|
+
// Dry-run mode
|
|
104
|
+
reportDryRun(zombies);
|
|
105
|
+
if (zombies.length > 0) {
|
|
106
|
+
appendLog({ action: 'dry-run', found: zombies.length });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Prune old logs
|
|
111
|
+
pruneLogs(config);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* init: Install hooks + scheduler.
|
|
116
|
+
*/
|
|
117
|
+
function cmdInit(config) {
|
|
118
|
+
console.log(bold('\n zclean init') + c('gray', ' — installing hooks and scheduler...\n'));
|
|
119
|
+
|
|
120
|
+
// 1. Save default config if none exists
|
|
121
|
+
const existingConfig = loadConfig();
|
|
122
|
+
if (JSON.stringify(existingConfig) === JSON.stringify(DEFAULT_CONFIG)) {
|
|
123
|
+
saveConfig(DEFAULT_CONFIG);
|
|
124
|
+
console.log(c('green', ' Config created:') + ` ${CONFIG_FILE}`);
|
|
125
|
+
} else {
|
|
126
|
+
console.log(c('gray', ' Config exists:') + ` ${CONFIG_FILE}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 2. Install Claude Code hook
|
|
130
|
+
const hookResult = installHook();
|
|
131
|
+
const hookIcon = hookResult.installed ? c('green', ' Hook:') : c('yellow', ' Hook:');
|
|
132
|
+
console.log(`${hookIcon} ${hookResult.message}`);
|
|
133
|
+
|
|
134
|
+
// 3. Install platform-specific scheduler
|
|
135
|
+
installScheduler();
|
|
136
|
+
|
|
137
|
+
console.log();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* status: Show current zombies and last cleanup info.
|
|
142
|
+
*/
|
|
143
|
+
function cmdStatus(config) {
|
|
144
|
+
const zombies = scan(config);
|
|
145
|
+
const logs = readLogs(100);
|
|
146
|
+
reportStatus(zombies, logs);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* logs: Show recent cleanup history.
|
|
151
|
+
*/
|
|
152
|
+
function cmdLogs(config) {
|
|
153
|
+
const logs = readLogs(50);
|
|
154
|
+
reportLogs(logs);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* uninstall: Remove hooks + scheduler.
|
|
159
|
+
*/
|
|
160
|
+
function cmdUninstall() {
|
|
161
|
+
console.log(bold('\n zclean uninstall') + c('gray', ' — removing hooks and scheduler...\n'));
|
|
162
|
+
|
|
163
|
+
// Remove hook
|
|
164
|
+
const hookResult = removeHook();
|
|
165
|
+
console.log(` Hook: ${hookResult.message}`);
|
|
166
|
+
|
|
167
|
+
// Remove scheduler
|
|
168
|
+
uninstallScheduler();
|
|
169
|
+
|
|
170
|
+
console.log(c('gray', `\n Config and logs preserved at ~/.zclean/`));
|
|
171
|
+
console.log(c('gray', ` To fully remove: rm -rf ~/.zclean\n`));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* config: Show current config.
|
|
176
|
+
*/
|
|
177
|
+
function cmdConfig(config) {
|
|
178
|
+
reportConfig(config, CONFIG_FILE);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─── Platform Scheduler Install/Uninstall ───────────────────────────────────
|
|
182
|
+
|
|
183
|
+
function installScheduler() {
|
|
184
|
+
switch (platform) {
|
|
185
|
+
case 'darwin': {
|
|
186
|
+
const { installLaunchd } = require('../src/installer/launchd');
|
|
187
|
+
const result = installLaunchd();
|
|
188
|
+
const icon = result.installed ? c('green', ' Scheduler:') : c('yellow', ' Scheduler:');
|
|
189
|
+
console.log(`${icon} ${result.message}`);
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
case 'linux': {
|
|
193
|
+
const { installSystemd } = require('../src/installer/systemd');
|
|
194
|
+
const result = installSystemd();
|
|
195
|
+
const icon = result.installed ? c('green', ' Scheduler:') : c('yellow', ' Scheduler:');
|
|
196
|
+
console.log(`${icon} ${result.message}`);
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
case 'win32': {
|
|
200
|
+
const { installTaskScheduler } = require('../src/installer/taskscheduler');
|
|
201
|
+
const result = installTaskScheduler();
|
|
202
|
+
const icon = result.installed ? c('green', ' Scheduler:') : c('yellow', ' Scheduler:');
|
|
203
|
+
console.log(`${icon} ${result.message}`);
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
default:
|
|
207
|
+
console.log(c('yellow', ` Scheduler: Unsupported platform (${platform}). Install a cron job manually.`));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function uninstallScheduler() {
|
|
212
|
+
switch (platform) {
|
|
213
|
+
case 'darwin': {
|
|
214
|
+
const { removeLaunchd } = require('../src/installer/launchd');
|
|
215
|
+
const result = removeLaunchd();
|
|
216
|
+
console.log(` Scheduler: ${result.message}`);
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
case 'linux': {
|
|
220
|
+
const { removeSystemd } = require('../src/installer/systemd');
|
|
221
|
+
const result = removeSystemd();
|
|
222
|
+
console.log(` Scheduler: ${result.message}`);
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
case 'win32': {
|
|
226
|
+
const { removeTaskScheduler } = require('../src/installer/taskscheduler');
|
|
227
|
+
const result = removeTaskScheduler();
|
|
228
|
+
console.log(` Scheduler: ${result.message}`);
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
default:
|
|
232
|
+
console.log(c('yellow', ` Scheduler: Remove manually for ${platform}.`));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ─── Help ───────────────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
function printHelp() {
|
|
239
|
+
console.log(`
|
|
240
|
+
${bold('zclean')} — Automatic zombie process cleaner for AI coding tools
|
|
241
|
+
|
|
242
|
+
${bold('Usage:')}
|
|
243
|
+
zclean Scan for zombies (dry-run)
|
|
244
|
+
zclean --yes Scan and kill zombies
|
|
245
|
+
zclean init Install hooks + scheduler
|
|
246
|
+
zclean status Show current zombies and last cleanup
|
|
247
|
+
zclean logs Show recent cleanup history
|
|
248
|
+
zclean uninstall Remove hooks + scheduler
|
|
249
|
+
zclean config Show current configuration
|
|
250
|
+
|
|
251
|
+
${bold('Options:')}
|
|
252
|
+
--yes, -y Kill found zombies (default: dry-run)
|
|
253
|
+
--session-pid=PID Filter by parent session PID
|
|
254
|
+
--version, -v Show version
|
|
255
|
+
--help, -h Show this help
|
|
256
|
+
|
|
257
|
+
${bold('Config:')} ~/.zclean/config.json
|
|
258
|
+
${bold('Logs:')} ~/.zclean/history.jsonl
|
|
259
|
+
|
|
260
|
+
${bold('Docs:')} https://github.com/whynowlab/zclean
|
|
261
|
+
`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ─── Run ────────────────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
main().catch((err) => {
|
|
267
|
+
console.error(c('red', `\n Error: ${err.message}\n`));
|
|
268
|
+
if (flags.verbose) {
|
|
269
|
+
console.error(err.stack);
|
|
270
|
+
}
|
|
271
|
+
process.exit(1);
|
|
272
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "z-clean",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Automatic zombie process cleaner for AI coding tools (Claude Code, Codex)",
|
|
5
|
+
"bin": { "zclean": "./bin/zclean.js" },
|
|
6
|
+
"keywords": ["claude-code", "codex", "cleanup", "zombie", "orphan", "process", "ai-tools", "mcp"],
|
|
7
|
+
"author": "whynowlab",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"repository": { "type": "git", "url": "https://github.com/whynowlab/zclean.git" },
|
|
10
|
+
"engines": { "node": ">=18" },
|
|
11
|
+
"files": ["bin/", "src/", "LICENSE", "README.md"]
|
|
12
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
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');
|
|
11
|
+
|
|
12
|
+
const DEFAULT_CONFIG = {
|
|
13
|
+
whitelist: [],
|
|
14
|
+
maxAge: '24h',
|
|
15
|
+
memoryThreshold: '500MB',
|
|
16
|
+
schedule: 'hourly',
|
|
17
|
+
sigterm_timeout: 10,
|
|
18
|
+
dryRunDefault: true,
|
|
19
|
+
logRetention: '30d',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse a duration string like "24h", "30d", "1h" into milliseconds.
|
|
24
|
+
*/
|
|
25
|
+
function parseDuration(str) {
|
|
26
|
+
const match = String(str).match(/^(\d+)\s*(ms|s|m|h|d)$/i);
|
|
27
|
+
if (!match) return null;
|
|
28
|
+
const value = parseInt(match[1], 10);
|
|
29
|
+
const unit = match[2].toLowerCase();
|
|
30
|
+
const multipliers = {
|
|
31
|
+
ms: 1,
|
|
32
|
+
s: 1000,
|
|
33
|
+
m: 60 * 1000,
|
|
34
|
+
h: 60 * 60 * 1000,
|
|
35
|
+
d: 24 * 60 * 60 * 1000,
|
|
36
|
+
};
|
|
37
|
+
return value * multipliers[unit];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse a memory string like "500MB", "1GB" into bytes.
|
|
42
|
+
*/
|
|
43
|
+
function parseMemory(str) {
|
|
44
|
+
const match = String(str).match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)$/i);
|
|
45
|
+
if (!match) return null;
|
|
46
|
+
const value = parseFloat(match[1]);
|
|
47
|
+
const unit = match[2].toUpperCase();
|
|
48
|
+
const multipliers = { B: 1, KB: 1024, MB: 1024 * 1024, GB: 1024 * 1024 * 1024 };
|
|
49
|
+
return Math.floor(value * multipliers[unit]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Ensure the config directory exists.
|
|
54
|
+
*/
|
|
55
|
+
function ensureConfigDir() {
|
|
56
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
57
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Load config from disk, merging with defaults.
|
|
63
|
+
*/
|
|
64
|
+
function loadConfig() {
|
|
65
|
+
ensureConfigDir();
|
|
66
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
67
|
+
try {
|
|
68
|
+
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
|
69
|
+
const userConfig = JSON.parse(raw);
|
|
70
|
+
return { ...DEFAULT_CONFIG, ...userConfig };
|
|
71
|
+
} catch {
|
|
72
|
+
// Corrupted config — use defaults
|
|
73
|
+
return { ...DEFAULT_CONFIG };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { ...DEFAULT_CONFIG };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Save config to disk.
|
|
81
|
+
*/
|
|
82
|
+
function saveConfig(config) {
|
|
83
|
+
ensureConfigDir();
|
|
84
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Append a log entry to the history file.
|
|
89
|
+
* Each entry is a JSON line with timestamp, action, and details.
|
|
90
|
+
*/
|
|
91
|
+
function appendLog(entry) {
|
|
92
|
+
ensureConfigDir();
|
|
93
|
+
const line = JSON.stringify({ timestamp: new Date().toISOString(), ...entry }) + '\n';
|
|
94
|
+
fs.appendFileSync(LOG_FILE, line, 'utf-8');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Read recent log entries (up to `limit`).
|
|
99
|
+
*/
|
|
100
|
+
function readLogs(limit = 50) {
|
|
101
|
+
if (!fs.existsSync(LOG_FILE)) return [];
|
|
102
|
+
try {
|
|
103
|
+
const lines = fs.readFileSync(LOG_FILE, 'utf-8').trim().split('\n').filter(Boolean);
|
|
104
|
+
return lines
|
|
105
|
+
.slice(-limit)
|
|
106
|
+
.map((line) => {
|
|
107
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
108
|
+
})
|
|
109
|
+
.filter(Boolean);
|
|
110
|
+
} catch {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Prune logs older than logRetention.
|
|
117
|
+
*/
|
|
118
|
+
function pruneLogs(config) {
|
|
119
|
+
const retentionMs = parseDuration(config.logRetention || '30d');
|
|
120
|
+
if (!retentionMs || !fs.existsSync(LOG_FILE)) return;
|
|
121
|
+
|
|
122
|
+
const cutoff = Date.now() - retentionMs;
|
|
123
|
+
const lines = fs.readFileSync(LOG_FILE, 'utf-8').trim().split('\n').filter(Boolean);
|
|
124
|
+
const kept = lines.filter((line) => {
|
|
125
|
+
try {
|
|
126
|
+
const entry = JSON.parse(line);
|
|
127
|
+
return new Date(entry.timestamp).getTime() >= cutoff;
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
fs.writeFileSync(LOG_FILE, kept.join('\n') + (kept.length ? '\n' : ''), 'utf-8');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = {
|
|
136
|
+
CONFIG_DIR,
|
|
137
|
+
CONFIG_FILE,
|
|
138
|
+
LOG_FILE,
|
|
139
|
+
DEFAULT_CONFIG,
|
|
140
|
+
parseDuration,
|
|
141
|
+
parseMemory,
|
|
142
|
+
loadConfig,
|
|
143
|
+
saveConfig,
|
|
144
|
+
appendLog,
|
|
145
|
+
readLogs,
|
|
146
|
+
pruneLogs,
|
|
147
|
+
ensureConfigDir,
|
|
148
|
+
};
|