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 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
+ [![npm version](https://img.shields.io/npm/v/@thestackai/zclean?style=flat-square&color=blue)](https://www.npmjs.com/package/@thestackai/zclean)
14
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green?style=flat-square)](https://opensource.org/licenses/MIT)
15
+ [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey?style=flat-square)](#)
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
+ };