z-clean 0.3.2 → 0.3.3

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/README.ko.md CHANGED
@@ -71,10 +71,12 @@ npx --yes z-clean report
71
71
  계속 사용하려면 전역 설치하세요:
72
72
 
73
73
  ```bash
74
- npm install -g z-clean
74
+ npm install --global z-clean --foreground-scripts
75
75
  zclean report
76
76
  ```
77
77
 
78
+ `--foreground-scripts`를 사용하면 zclean 설치 워드마크가 표시됩니다. npm 7 이상은 lifecycle 출력을 기본적으로 숨기므로 `npm install -g z-clean`도 정상 설치되지만 완료 화면은 보이지 않을 수 있습니다.
79
+
78
80
  자동 실행은 선택 사항입니다:
79
81
 
80
82
  ```bash
@@ -93,12 +95,15 @@ zclean report --json # 자동화용 JSON report
93
95
  zclean cache # workspace cache dry-run scan
94
96
  zclean cache --yes # 지원되는 cache directory 삭제
95
97
  zclean cache --json # cache 후보 JSON 출력
98
+ zclean --pattern=my-agent-worker # 이번 scan에 literal orphan pattern 추가
96
99
  zclean history --json # 정리 이력 JSON
97
100
  zclean protect list # 보호 목록 확인
98
101
  zclean protect add mcp-server-keep
99
102
  zclean doctor --json # 설치/탐지/스케줄러 상태 점검
100
103
  ```
101
104
 
105
+ 패턴을 계속 사용하려면 `~/.zclean/config.json`의 `customPatterns` 배열에 문자열을 추가하세요. 패턴은 정규식이 아닌 대소문자 무시 literal 문자열이며 3–80자의 출력 가능한 문자만 허용됩니다. `node`, `node /`, `ode` 같은 일반 runtime 이름과 부분 문자열은 거부되고, orphan이면서 `maxAge`(기본 24시간)를 넘은 process만 후보가 됩니다. 잘못되거나 0인 기간은 안전한 기본값 24시간으로 돌아갑니다. whitelist, PID 재검증, dry-run, `--yes` 안전장치는 그대로 유지됩니다.
106
+
102
107
  다른 workspace cache를 확인하려면:
103
108
 
104
109
  ```bash
package/README.md CHANGED
@@ -15,7 +15,7 @@ AI coding runtime hygiene
15
15
  [![Node.js](https://img.shields.io/badge/node-%3E%3D18-339933?style=flat-square&logo=node.js&logoColor=white)](#)
16
16
  [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey?style=flat-square)](#platform-status)
17
17
  [![Zero Dependencies](https://img.shields.io/badge/dependencies-0-blue?style=flat-square)](#)
18
- [![Tests](https://img.shields.io/badge/tests-105%20passing-brightgreen?style=flat-square)](#)
18
+ [![Tests](https://img.shields.io/badge/tests-142%20passing-brightgreen?style=flat-square)](#)
19
19
  [![GitHub stars](https://img.shields.io/github/stars/TheStack-ai/zclean?style=flat-square)](https://github.com/TheStack-ai/zclean)
20
20
  [![Mentioned in Awesome Claude Code Toolkit](https://awesome.re/mentioned-badge.svg)](https://github.com/rohitg00/awesome-claude-code-toolkit)
21
21
 
@@ -67,10 +67,12 @@ npx --yes z-clean report
67
67
  Install the CLI when you are ready to keep using it:
68
68
 
69
69
  ```bash
70
- npm install -g z-clean
70
+ npm install --global z-clean --foreground-scripts
71
71
  zclean report
72
72
  ```
73
73
 
74
+ `--foreground-scripts` displays zclean's install wordmark. npm 7+ hides lifecycle output by default, so plain `npm install -g z-clean` still installs correctly but may not show the branded completion screen.
75
+
74
76
  Clean only when you decide:
75
77
 
76
78
  ```bash
@@ -111,6 +113,7 @@ When an agent crashes, a terminal closes, or a session ends abruptly, those chil
111
113
  | `zclean cache` | Dry-run scan for supported workspace cache directories |
112
114
  | `zclean cache --yes` | Remove supported workspace cache directories |
113
115
  | `zclean cache --json` | Machine-readable cache hygiene report |
116
+ | `zclean --pattern=my-agent-worker` | Add one literal orphan-process pattern for this scan |
114
117
  | `zclean history` | Recent cleanup history |
115
118
  | `zclean history --json` | Sanitized cleanup history and cumulative stats |
116
119
  | `zclean protect list` | Show protected process patterns |
@@ -259,7 +262,8 @@ zclean doctor --json
259
262
  "sigterm_timeout": 10,
260
263
  "dryRunDefault": true,
261
264
  "logRetention": "30d",
262
- "customAiDirs": []
265
+ "customAiDirs": [],
266
+ "customPatterns": []
263
267
  }
264
268
  ```
265
269
 
@@ -273,6 +277,9 @@ zclean doctor --json
273
277
  | `dryRunDefault` | `true` | Manual scans stay dry-run by default; cleanup still requires `--yes` |
274
278
  | `logRetention` | `"30d"` | Cleanup history retention |
275
279
  | `customAiDirs` | `[]` | Additional AI tool directories to detect, such as `[".mytool"]` |
280
+ | `customPatterns` | `[]` | Case-insensitive literal command fragments for additional orphan processes |
281
+
282
+ Custom patterns are deliberately restricted: values must contain 3–80 printable characters, generic runtime names and fragments such as `node`, `node /`, or `ode` are rejected, and values are treated as literal text rather than regular expressions. Candidates must be orphaned and older than `maxAge` (24 hours by default); invalid or zero durations fall back to that safe default. Whitelists, PID identity verification, batch limits, dry-run behavior, and the explicit `--yes` cleanup gate still apply.
276
283
 
277
284
  ## Supported tools
278
285
 
package/README.zh.md CHANGED
@@ -71,10 +71,12 @@ npx --yes z-clean report
71
71
  准备长期使用时再全局安装:
72
72
 
73
73
  ```bash
74
- npm install -g z-clean
74
+ npm install --global z-clean --foreground-scripts
75
75
  zclean report
76
76
  ```
77
77
 
78
+ `--foreground-scripts` 会显示 zclean 安装字标。npm 7 及以上默认隐藏 lifecycle 输出,因此普通的 `npm install -g z-clean` 仍会正常安装,但可能不显示品牌完成画面。
79
+
78
80
  自动运行是可选项:
79
81
 
80
82
  ```bash
@@ -93,12 +95,15 @@ zclean report --json # 自动化 JSON report
93
95
  zclean cache # workspace cache dry-run scan
94
96
  zclean cache --yes # 删除支持的 cache directory
95
97
  zclean cache --json # 输出 cache 候选 JSON
98
+ zclean --pattern=my-agent-worker # 为本次扫描添加 literal orphan pattern
96
99
  zclean history --json # 清理历史 JSON
97
100
  zclean protect list # 查看保护列表
98
101
  zclean protect add mcp-server-keep
99
102
  zclean doctor --json # 检查安装、调度器和进程枚举状态
100
103
  ```
101
104
 
105
+ 如需长期使用,请把字符串加入 `~/.zclean/config.json` 的 `customPatterns` 数组。pattern 是忽略大小写的 literal 文本,不是正则表达式;只允许 3–80 个可打印字符,并拒绝 `node`、`node /`、`ode` 等通用 runtime 名称或片段。只有 orphan 且超过 `maxAge`(默认 24 小时)的 process 才会成为候选项;无效或为零的时长会回退到安全的 24 小时默认值。whitelist、PID 再验证、dry-run 和显式 `--yes` 安全限制仍然有效。
106
+
102
107
  扫描另一个 workspace:
103
108
 
104
109
  ```bash
package/bin/zclean.js CHANGED
@@ -6,21 +6,21 @@ const { scan, hasScanErrors } = require('../src/scanner');
6
6
  const { killZombies } = require('../src/killer');
7
7
  const {
8
8
  loadConfig,
9
- saveConfig,
10
9
  readLogs,
11
10
  pruneLogs,
12
- DEFAULT_CONFIG,
13
11
  appendLog,
14
12
  getCumulativeStats,
15
13
  getConfigFile,
16
14
  } = require('../src/config');
17
15
  const { reportDryRun, reportKill, reportStatus, reportLogs, reportConfig, c, bold } = require('../src/reporter');
18
- const { installHook, removeHook } = require('../src/installer/hook');
16
+ const { removeHook } = require('../src/installer/hook');
19
17
  const { runHistory, runProtect } = require('../src/commands/trust');
20
- const { installScheduler, uninstallScheduler } = require('../src/commands/scheduler');
18
+ const { uninstallScheduler } = require('../src/commands/scheduler');
21
19
  const { runDoctor } = require('../src/doctor');
22
20
  const { runAudit } = require('../src/audit');
23
21
  const { runCache } = require('../src/cache');
22
+ const { getCustomPatternError, normalizeCustomPattern } = require('../src/detector/patterns');
23
+ const { runInit } = require('../src/commands/init');
24
24
 
25
25
  // Platform-specific installers (lazy loaded)
26
26
  const platform = os.platform();
@@ -33,8 +33,10 @@ const positional = [];
33
33
 
34
34
  for (const arg of args) {
35
35
  if (arg.startsWith('--')) {
36
- const [key, value] = arg.substring(2).split('=');
37
- flags[key] = value !== undefined ? value : true;
36
+ const body = arg.substring(2);
37
+ const separator = body.indexOf('=');
38
+ const key = separator === -1 ? body : body.slice(0, separator);
39
+ flags[key] = separator === -1 ? true : body.slice(separator + 1);
38
40
  } else if (arg.startsWith('-') && arg.length === 2) {
39
41
  flags[arg.substring(1)] = true;
40
42
  } else {
@@ -64,7 +66,7 @@ async function main() {
64
66
 
65
67
  switch (command) {
66
68
  case 'init':
67
- return cmdInit(config);
69
+ return runInit({ config, platform });
68
70
 
69
71
  case 'status':
70
72
  return cmdStatus(config);
@@ -114,17 +116,19 @@ async function main() {
114
116
  * Dry-run unless --yes is passed.
115
117
  */
116
118
  function cmdScan(config) {
119
+ const scanConfig = withPatternFlag(config);
120
+ if (!scanConfig) return;
117
121
  const sessionPid = parseSessionPidFlag();
118
122
  const force = flags.yes || flags.y;
119
123
 
120
124
  console.log(bold('\n zclean') + c('gray', ' — scanning for zombie processes...\n'));
121
125
 
122
- const zombies = scan(config, { sessionPid });
126
+ const zombies = scan(scanConfig, { sessionPid });
123
127
 
124
128
  if (hasScanErrors(zombies)) {
125
129
  reportDryRun(zombies);
126
130
  appendLog({ action: 'scan-failed', errors: zombies.errors.length });
127
- pruneLogs(config);
131
+ pruneLogs(scanConfig);
128
132
  process.exitCode = 1;
129
133
  return;
130
134
  }
@@ -137,12 +141,12 @@ function cmdScan(config) {
137
141
  return;
138
142
  }
139
143
  appendLog({ action: 'scan', found: zombies.length });
140
- const results = killZombies(zombies, config);
144
+ const results = killZombies(zombies, scanConfig);
141
145
  results.cumulative = getCumulativeStats();
142
146
  reportKill(results);
143
147
  } else {
144
148
  // Dry-run mode
145
- if (config.dryRunDefault === false) {
149
+ if (scanConfig.dryRunDefault === false) {
146
150
  console.log(c('yellow', ' dryRunDefault=false is not an auto-kill switch; pass --yes to clean.\n'));
147
151
  }
148
152
  reportDryRun(zombies);
@@ -152,40 +156,16 @@ function cmdScan(config) {
152
156
  }
153
157
 
154
158
  // Prune old logs
155
- pruneLogs(config);
156
- }
157
-
158
- /**
159
- * init: Install hooks + scheduler.
160
- */
161
- function cmdInit(config) {
162
- console.log(bold('\n zclean init') + c('gray', ' — installing hooks and scheduler...\n'));
163
-
164
- // 1. Save default config if none exists
165
- const existingConfig = loadConfig();
166
- if (JSON.stringify(existingConfig) === JSON.stringify(DEFAULT_CONFIG)) {
167
- saveConfig(DEFAULT_CONFIG);
168
- console.log(c('green', ' Config created:') + ` ${getConfigFile()}`);
169
- } else {
170
- console.log(c('gray', ' Config exists:') + ` ${getConfigFile()}`);
171
- }
172
-
173
- // 2. Install Claude Code hook
174
- const hookResult = installHook();
175
- const hookIcon = hookResult.installed ? c('green', ' Hook:') : c('yellow', ' Hook:');
176
- console.log(`${hookIcon} ${hookResult.message}`);
177
-
178
- // 3. Install platform-specific scheduler
179
- installScheduler(platform);
180
-
181
- console.log();
159
+ pruneLogs(scanConfig);
182
160
  }
183
161
 
184
162
  /**
185
163
  * status: Show current zombies and last cleanup info.
186
164
  */
187
165
  function cmdStatus(config) {
188
- const zombies = scan(config);
166
+ const scanConfig = withPatternFlag(config);
167
+ if (!scanConfig) return;
168
+ const zombies = scan(scanConfig);
189
169
  const logs = readLogs(100);
190
170
  reportStatus(zombies, logs);
191
171
  if (hasScanErrors(zombies)) process.exitCode = 1;
@@ -232,8 +212,10 @@ function cmdDoctor(config) {
232
212
  }
233
213
 
234
214
  function cmdAudit(config, commandName = 'audit') {
215
+ const scanConfig = withPatternFlag(config);
216
+ if (!scanConfig) return;
235
217
  const sessionPid = parseSessionPidFlag();
236
- const report = runAudit(config, { json: Boolean(flags.json), sessionPid, commandName });
218
+ const report = runAudit(scanConfig, { json: Boolean(flags.json), sessionPid, commandName });
237
219
  if (!report.risk.enumerationComplete) process.exitCode = 1;
238
220
  }
239
221
 
@@ -257,6 +239,19 @@ function parseSessionPidFlag() {
257
239
  process.exit(1);
258
240
  }
259
241
 
242
+ function withPatternFlag(config) {
243
+ if (flags.pattern === undefined) return config;
244
+ const error = getCustomPatternError(flags.pattern);
245
+ if (error) {
246
+ console.error(c('red', ` ${error}`));
247
+ process.exitCode = 1;
248
+ return null;
249
+ }
250
+ const literal = normalizeCustomPattern(flags.pattern);
251
+ const configured = Array.isArray(config.customPatterns) ? config.customPatterns : [];
252
+ return { ...config, customPatterns: [...configured, literal] };
253
+ }
254
+
260
255
  // ─── Help ───────────────────────────────────────────────────────────────────
261
256
 
262
257
  function printHelp() {
@@ -285,6 +280,7 @@ function printHelp() {
285
280
  ${bold('Options:')}
286
281
  --yes, -y Kill found zombies (default: dry-run)
287
282
  --session-pid=PID Filter by parent session PID
283
+ --pattern=TEXT Add a literal orphan-process pattern
288
284
  --path=DIR Workspace path for zclean cache
289
285
  --json Print machine-readable output for supported commands
290
286
  --version, -v Show version
package/package.json CHANGED
@@ -1,15 +1,16 @@
1
1
  {
2
2
  "name": "z-clean",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "AI coding runtime hygiene reports, zombie cleanup, and safe workspace cache cleanup",
5
5
  "bin": {
6
6
  "zclean": "bin/zclean.js"
7
7
  },
8
8
  "scripts": {
9
- "test": "node --test test/patterns.test.js test/scanner.test.js test/process-tree.test.js test/killer.test.js test/config.test.js test/installer-fallback.test.js test/cli.test.js test/cli-history-protect.test.js test/cli-doctor.test.js test/audit.test.js test/cache.test.js",
9
+ "test": "node --test test/patterns.test.js test/scanner.test.js test/process-tree.test.js test/killer.test.js test/config.test.js test/installer-fallback.test.js test/install-experience.test.js test/reporter.test.js test/cli.test.js test/cli-history-protect.test.js test/cli-doctor.test.js test/audit.test.js test/cache.test.js",
10
10
  "syntax": "node -e \"const fs=require('node:fs');const path=require('node:path');const {execFileSync}=require('node:child_process');const files=['bin/zclean.js'];function walk(dir){if(!fs.existsSync(dir))return;for(const entry of fs.readdirSync(dir,{withFileTypes:true})){const file=path.join(dir,entry.name);if(entry.isDirectory())walk(file);else if(entry.name.endsWith('.js'))files.push(file);}}for(const dir of ['src','test','scripts'])walk(dir);for(const file of files)execFileSync(process.execPath,['-c',file],{stdio:'inherit'});\"",
11
11
  "smoke": "node scripts/smoke.js",
12
- "pack:check": "npm pack --dry-run --json"
12
+ "pack:check": "npm pack --dry-run --json",
13
+ "postinstall": "node scripts/postinstall.js"
13
14
  },
14
15
  "keywords": [
15
16
  "claude-code",
@@ -40,6 +41,7 @@
40
41
  "files": [
41
42
  "bin/",
42
43
  "src/",
44
+ "scripts/postinstall.js",
43
45
  "assets/zclean-hero.png",
44
46
  "LICENSE",
45
47
  "README.md",
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { version } = require('../package.json');
5
+ const { renderPostinstall } = require('../src/cli-brand');
6
+
7
+ process.stdout.write(renderPostinstall({ version }));
@@ -0,0 +1,129 @@
1
+ 'use strict';
2
+
3
+ const { c, bold } = require('./reporter');
4
+ const { sanitizeTerminalText } = require('./terminal-text');
5
+
6
+ const WORDMARK = [
7
+ ' ███████ ██████ ██ ███████ █████ ███ ██',
8
+ ' ██ ██ ██ ██ ██ ██ ████ ██',
9
+ ' ███ ██ ██ █████ ███████ ██ ██ ██',
10
+ ' ██ ██ ██ ██ ██ ██ ██ ██ ██',
11
+ ' ███████ ██████ ███████ ███████ ██ ██ ██ ████',
12
+ ];
13
+
14
+ const RULE = ' --------------------------------------------------------';
15
+ const FRAME_WIDTH = 55;
16
+
17
+ function renderPostinstall({ version }) {
18
+ return [
19
+ '',
20
+ c('gray', ' . + .'),
21
+ c('gray', ' + . :'),
22
+ ...WORDMARK.map((line) => c('cyan', line)),
23
+ '',
24
+ ` ${bold('Z / CLEAN')} ${c('gray', `v${version}`)}`,
25
+ c('gray', ' AI CODING RUNTIME HYGIENE'),
26
+ c('gray', RULE),
27
+ '',
28
+ ` ${c('green', 'INSTALLED')} z-clean`,
29
+ ` ${c('gray', 'COMMAND')} zclean`,
30
+ ` ${c('gray', 'SAFETY')} dry-run by default`,
31
+ '',
32
+ c('gray', ' Continue setup'),
33
+ ` ${c('cyan', '$ zclean init')}`,
34
+ '',
35
+ c('gray', ' Inspect first. Clean only when you decide.'),
36
+ '',
37
+ ].join('\n');
38
+ }
39
+
40
+ function renderInit({ version, steps, warningCount }) {
41
+ const lines = [
42
+ '',
43
+ c('gray', ' . + .'),
44
+ c('gray', ' + . :'),
45
+ ...WORDMARK.map((line) => c('cyan', line)),
46
+ '',
47
+ ` ${bold('Z / CLEAN')} ${c('gray', `v${version}`)}`,
48
+ c('gray', ' RUNTIME HYGIENE SYSTEM'),
49
+ '',
50
+ c('gray', ` +${'-- SETUP '.padEnd(FRAME_WIDTH, '-')}+`),
51
+ c('gray', ` |${''.padEnd(FRAME_WIDTH)}|`),
52
+ ];
53
+
54
+ for (const step of steps) {
55
+ lines.push(renderStep(step));
56
+ }
57
+
58
+ const ready = warningCount === 0;
59
+ lines.push(
60
+ c('gray', ` |${''.padEnd(FRAME_WIDTH)}|`),
61
+ c('gray', ` +${''.padEnd(FRAME_WIDTH, '-')}+`),
62
+ '',
63
+ ready
64
+ ? c('green', ' SYSTEM READY')
65
+ : c('yellow', ` SETUP COMPLETED WITH ${warningCount} WARNING${warningCount === 1 ? '' : 'S'}`),
66
+ '',
67
+ ` ${c('gray', 'Inspect runtime leftovers')} ${c('cyan', 'zclean audit')}`,
68
+ ` ${c('gray', 'Verify this installation')} ${c('cyan', 'zclean doctor')}`,
69
+ '',
70
+ c('gray', ' Cleanup remains locked until you pass --yes.'),
71
+ ''
72
+ );
73
+
74
+ return lines.join('\n');
75
+ }
76
+
77
+ function renderStep(step) {
78
+ const state = String(step.state).padEnd(10);
79
+ const label = String(step.label).padEnd(14);
80
+ const tone = step.state === 'WARNING' ? 'yellow' : step.state === 'EXISTS' ? 'gray' : 'green';
81
+ const statusPrefix = ` ${step.index} ${label} `;
82
+ const statusSpacing = ''.padEnd(FRAME_WIDTH - statusPrefix.length - state.length);
83
+ const detail = padToWidth(` ${fitDetail(step.detail)}`, FRAME_WIDTH);
84
+ return [
85
+ ` |${c('gray', statusPrefix)}${c(tone, state)}${statusSpacing}|`,
86
+ ` |${c('gray', detail)}|`,
87
+ ].join('\n');
88
+ }
89
+
90
+ function fitDetail(value) {
91
+ const text = sanitizeTerminalText(value).replace(/\s+/g, ' ').trim();
92
+ if (displayWidth(text) <= 45) return text;
93
+ let output = '';
94
+ let width = 0;
95
+ for (const char of text) {
96
+ const next = charWidth(char);
97
+ if (width + next > 42) break;
98
+ output += char;
99
+ width += next;
100
+ }
101
+ return `${output}...`;
102
+ }
103
+
104
+ function padToWidth(value, width) {
105
+ return `${value}${''.padEnd(Math.max(0, width - displayWidth(value)))}`;
106
+ }
107
+
108
+ function displayWidth(value) {
109
+ return [...value].reduce((width, char) => width + charWidth(char), 0);
110
+ }
111
+
112
+ function charWidth(char) {
113
+ if (/\p{Mark}/u.test(char)) return 0;
114
+ const code = char.codePointAt(0);
115
+ const wide = code >= 0x1100 && (
116
+ code <= 0x115f || code === 0x2329 || code === 0x232a ||
117
+ (code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) ||
118
+ (code >= 0xac00 && code <= 0xd7a3) ||
119
+ (code >= 0xf900 && code <= 0xfaff) ||
120
+ (code >= 0xfe10 && code <= 0xfe6f) ||
121
+ (code >= 0xff00 && code <= 0xff60) ||
122
+ (code >= 0xffe0 && code <= 0xffe6) ||
123
+ (code >= 0x1f300 && code <= 0x1f9ff) ||
124
+ (code >= 0x20000 && code <= 0x3fffd)
125
+ );
126
+ return wide ? 2 : 1;
127
+ }
128
+
129
+ module.exports = { renderPostinstall, renderInit };
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const { saveConfig, getConfigFile } = require('../config');
6
+ const { installHook } = require('../installer/hook');
7
+ const { installScheduler, getSchedulerState } = require('./scheduler');
8
+ const { renderInit } = require('../cli-brand');
9
+ const { version } = require('../../package.json');
10
+
11
+ function runInit({ config, platform }) {
12
+ const configFile = getConfigFile();
13
+ const configExists = fs.existsSync(configFile);
14
+ if (!configExists) saveConfig(config);
15
+
16
+ const hookResult = installHook();
17
+ const schedulerResult = installScheduler(platform);
18
+ const schedulerState = getSchedulerState(schedulerResult);
19
+ const steps = [
20
+ {
21
+ index: '01',
22
+ label: 'CONFIG',
23
+ state: configExists ? 'EXISTS' : 'READY',
24
+ detail: configFile.replace(os.homedir(), '~'),
25
+ },
26
+ {
27
+ index: '02',
28
+ label: 'CLAUDE HOOK',
29
+ state: hookResult.installed ? 'INSTALLED' : 'WARNING',
30
+ detail: hookResult.installed ? 'SessionEnd cleanup hook registered' : hookResult.message,
31
+ },
32
+ {
33
+ index: '03',
34
+ label: 'SCHEDULER',
35
+ state: schedulerState,
36
+ detail: schedulerState === 'ACTIVE' ? schedulerLabel(platform) : schedulerResult.message,
37
+ },
38
+ ];
39
+ const warningCount = steps.filter((step) => step.state === 'WARNING').length;
40
+ console.log(renderInit({ version, steps, warningCount }));
41
+ }
42
+
43
+ function schedulerLabel(platform) {
44
+ if (platform === 'darwin') return 'Hourly launchd hygiene check';
45
+ if (platform === 'linux') return 'Hourly systemd hygiene timer';
46
+ if (platform === 'win32') return 'Hourly Task Scheduler check';
47
+ return 'Runtime hygiene scheduler';
48
+ }
49
+
50
+ module.exports = { runInit };
@@ -6,53 +6,57 @@ function installScheduler(platform) {
6
6
  switch (platform) {
7
7
  case 'darwin': {
8
8
  const { installLaunchd } = require('../installer/launchd');
9
- const result = installLaunchd();
10
- const icon = result.installed ? c('green', ' Scheduler:') : c('yellow', ' Scheduler:');
11
- console.log(`${icon} ${result.message}`);
12
- break;
9
+ return installLaunchd();
13
10
  }
14
11
  case 'linux': {
15
12
  const { installSystemd } = require('../installer/systemd');
16
- const result = installSystemd();
17
- const icon = result.installed ? c('green', ' Scheduler:') : c('yellow', ' Scheduler:');
18
- console.log(`${icon} ${result.message}`);
19
- break;
13
+ return installSystemd();
20
14
  }
21
15
  case 'win32': {
22
16
  const { installTaskScheduler } = require('../installer/taskscheduler');
23
- const result = installTaskScheduler();
24
- const icon = result.installed ? c('green', ' Scheduler:') : c('yellow', ' Scheduler:');
25
- console.log(`${icon} ${result.message}`);
26
- break;
17
+ return installTaskScheduler();
27
18
  }
28
19
  default:
29
- console.log(c('yellow', ` Scheduler: Unsupported platform (${platform}). Install a cron job manually.`));
20
+ return {
21
+ installed: false,
22
+ message: `Unsupported platform (${platform}). Install a cron job manually.`,
23
+ };
30
24
  }
31
25
  }
32
26
 
33
- function uninstallScheduler(platform) {
27
+ function uninstallScheduler(platform, options = {}) {
28
+ const log = options.log || console.log;
29
+ let result;
34
30
  switch (platform) {
35
31
  case 'darwin': {
36
32
  const { removeLaunchd } = require('../installer/launchd');
37
- const result = removeLaunchd();
38
- console.log(` Scheduler: ${result.message}`);
33
+ result = (options.remove || removeLaunchd)();
34
+ log(` Scheduler: ${result.message}`);
39
35
  break;
40
36
  }
41
37
  case 'linux': {
42
38
  const { removeSystemd } = require('../installer/systemd');
43
- const result = removeSystemd();
44
- console.log(` Scheduler: ${result.message}`);
39
+ result = (options.remove || removeSystemd)();
40
+ log(` Scheduler: ${result.message}`);
45
41
  break;
46
42
  }
47
43
  case 'win32': {
48
44
  const { removeTaskScheduler } = require('../installer/taskscheduler');
49
- const result = removeTaskScheduler();
50
- console.log(` Scheduler: ${result.message}`);
45
+ result = (options.remove || removeTaskScheduler)();
46
+ log(` Scheduler: ${result.message}`);
51
47
  break;
52
48
  }
53
- default:
54
- console.log(c('yellow', ` Scheduler: Remove manually for ${platform}.`));
49
+ default: {
50
+ result = { removed: false, failed: false, message: `Remove manually for ${platform}.` };
51
+ log(c('yellow', ` Scheduler: ${result.message}`));
52
+ }
55
53
  }
54
+ if (result.failed) process.exitCode = 1;
55
+ return result;
56
+ }
57
+
58
+ function getSchedulerState(result) {
59
+ return result.installed && result.active !== false ? 'ACTIVE' : 'WARNING';
56
60
  }
57
61
 
58
- module.exports = { installScheduler, uninstallScheduler };
62
+ module.exports = { installScheduler, uninstallScheduler, getSchedulerState };
package/src/config.js CHANGED
@@ -40,6 +40,7 @@ const DEFAULT_CONFIG = {
40
40
  logRetention: '30d',
41
41
  maxKillBatch: 20,
42
42
  customAiDirs: [],
43
+ customPatterns: [],
43
44
  };
44
45
 
45
46
  /**
@@ -91,7 +92,9 @@ function loadConfig() {
91
92
  try {
92
93
  const raw = fs.readFileSync(configFile, 'utf-8');
93
94
  const userConfig = JSON.parse(raw);
94
- return { ...DEFAULT_CONFIG, ...userConfig };
95
+ const config = { ...DEFAULT_CONFIG, ...userConfig };
96
+ if (!(parseDuration(config.maxAge) > 0)) config.maxAge = DEFAULT_CONFIG.maxAge;
97
+ return config;
95
98
  } catch {
96
99
  // Corrupted config — use defaults
97
100
  return { ...DEFAULT_CONFIG };
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+
3
+ const { parseDuration } = require('../config');
4
+
5
+ const CUSTOM_PATTERN_MIN = 3;
6
+ const CUSTOM_PATTERN_MAX = 80;
7
+ const SAFE_CUSTOM_MAX_AGE = '24h';
8
+ const GENERIC_CUSTOM_PATTERNS = new Set([
9
+ 'bash', 'bun', 'bunx', 'cargo', 'chrome', 'chromium', 'cmd', 'corepack', 'cscript', 'deno', 'dotnet', 'esbuild', 'fish',
10
+ 'go', 'java', 'javaw', 'node', 'nodejs', 'npm', 'npx', 'perl', 'php', 'pip', 'pip3', 'pipx', 'pnpm',
11
+ 'powershell', 'powershell_ise', 'pwsh', 'py', 'pyw', 'python', 'python3', 'pythonw', 'ruby', 'server', 'sh', 'sleep', 'ts-node', 'tsx',
12
+ 'uv', 'vite', 'worker', 'wscript', 'yarn', 'zsh',
13
+ ]);
14
+
15
+ function getCustomPatternError(value) {
16
+ if (typeof value !== 'string') return '--pattern must be a literal between 3 and 80 characters.';
17
+ const literal = value.trim();
18
+ if (literal.length < CUSTOM_PATTERN_MIN || literal.length > CUSTOM_PATTERN_MAX) {
19
+ return '--pattern must be a literal between 3 and 80 characters.';
20
+ }
21
+ if (/[\p{Cc}\p{Cf}]/u.test(literal)) {
22
+ return '--pattern must contain printable characters only.';
23
+ }
24
+ const lower = literal.toLowerCase();
25
+ const genericFragment = [...GENERIC_CUSTOM_PATTERNS].some((name) => name.includes(lower));
26
+ const genericExecutable = lower.split(/[\s/\\'"`]+/).some(isGenericExecutableToken);
27
+ if (genericFragment || genericExecutable) {
28
+ return '--pattern must be specific; generic runtime names are not allowed.';
29
+ }
30
+ return null;
31
+ }
32
+
33
+ function isGenericExecutableToken(token) {
34
+ const basename = token
35
+ .replace(/^[^a-z0-9]+|[^a-z0-9.]+$/g, '')
36
+ .replace(/\.(?:exe|cmd|bat|com)$/i, '');
37
+ const executableName = basename
38
+ .replace(/\.(?:cjs|mjs|js)$/i, '')
39
+ .replace(/-cli$/i, '');
40
+ return GENERIC_CUSTOM_PATTERNS.has(executableName)
41
+ || isVersionedGenericRuntime(executableName);
42
+ }
43
+
44
+ function isVersionedGenericRuntime(value) {
45
+ const match = value.match(/^([a-z][a-z_-]*?)(?:\d[\d.]*(?:[a-z][a-z0-9]*)?)$/);
46
+ return Boolean(match && GENERIC_CUSTOM_PATTERNS.has(match[1]));
47
+ }
48
+
49
+ function normalizeCustomPattern(value) {
50
+ if (getCustomPatternError(value)) return null;
51
+ return value.trim();
52
+ }
53
+
54
+ function getCustomPatterns(config) {
55
+ if (!Array.isArray(config?.customPatterns)) return [];
56
+ return [...new Set(config.customPatterns.map(normalizeCustomPattern).filter(Boolean))];
57
+ }
58
+
59
+ function createCustomPattern(literal, config) {
60
+ const configuredAge = config?.maxAge;
61
+ const maxOrphanAge = parseDuration(configuredAge) > 0
62
+ ? configuredAge
63
+ : SAFE_CUSTOM_MAX_AGE;
64
+ return {
65
+ name: `custom:${literal}`,
66
+ literal,
67
+ minAge: 0,
68
+ maxOrphanAge,
69
+ orphanOnly: true,
70
+ strictOrphan: true,
71
+ };
72
+ }
73
+
74
+ module.exports = {
75
+ createCustomPattern,
76
+ getCustomPatternError,
77
+ getCustomPatterns,
78
+ normalizeCustomPattern,
79
+ };
@@ -1,5 +1,12 @@
1
1
  'use strict';
2
2
 
3
+ const {
4
+ createCustomPattern,
5
+ getCustomPatternError,
6
+ getCustomPatterns,
7
+ normalizeCustomPattern,
8
+ } = require('./custom-patterns');
9
+
3
10
  /**
4
11
  * Process patterns to detect AI tool zombies.
5
12
  *
@@ -211,7 +218,7 @@ const PATTERNS = [
211
218
  * @param {object} [config] — optional config with customAiDirs
212
219
  * @returns {object|null} matched pattern or null
213
220
  */
214
- function matchPattern(cmdline, config) {
221
+ function matchPattern(cmdline, config, customPatterns = getCustomPatterns(config)) {
215
222
  const aiRegex = config && config.customAiDirs && config.customAiDirs.length > 0
216
223
  ? buildAiDirRegex(config.customAiDirs)
217
224
  : AI_DIR_REGEX;
@@ -225,7 +232,28 @@ function matchPattern(cmdline, config) {
225
232
  return pattern;
226
233
  }
227
234
  }
235
+
236
+ const command = String(cmdline).toLowerCase();
237
+ for (const literal of customPatterns) {
238
+ if (command.includes(literal.toLowerCase())) {
239
+ return createCustomPattern(literal, config);
240
+ }
241
+ }
228
242
  return null;
229
243
  }
230
244
 
231
- module.exports = { PATTERNS, AI_TOOL_DIRS, AI_DIR_REGEX, buildAiDirRegex, matchPattern };
245
+ function createPatternMatcher(config) {
246
+ const customPatterns = getCustomPatterns(config);
247
+ return (cmdline) => matchPattern(cmdline, config, customPatterns);
248
+ }
249
+
250
+ module.exports = {
251
+ PATTERNS,
252
+ AI_TOOL_DIRS,
253
+ AI_DIR_REGEX,
254
+ buildAiDirRegex,
255
+ createPatternMatcher,
256
+ getCustomPatternError,
257
+ matchPattern,
258
+ normalizeCustomPattern,
259
+ };
@@ -3,7 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
- const { execSync } = require('child_process');
6
+ const { execFileSync, execSync } = require('child_process');
7
7
  const { LOCAL_BIN_HINT, resolveZcleanBin } = require('./bin-path');
8
8
 
9
9
  const PLIST_NAME = 'com.zclean.hourly';
@@ -136,12 +136,14 @@ function installLaunchd() {
136
136
  } catch (err) {
137
137
  return {
138
138
  installed: true,
139
+ active: false,
139
140
  message: `Plist written to ${PLIST_PATH} but launchctl load failed: ${err.message}. Try: launchctl bootstrap gui/$(id -u) ${PLIST_PATH}`,
140
141
  };
141
142
  }
142
143
 
143
144
  return {
144
145
  installed: true,
146
+ active: true,
145
147
  message: `Hourly launchd agent installed: ${PLIST_PATH}`,
146
148
  };
147
149
  }
@@ -151,32 +153,79 @@ function installLaunchd() {
151
153
  *
152
154
  * @returns {{ removed: boolean, message: string }}
153
155
  */
154
- function removeLaunchd() {
155
- if (os.platform() !== 'darwin') {
156
- return { removed: false, message: 'launchd is macOS only.' };
157
- }
158
-
159
- if (!fs.existsSync(PLIST_PATH)) {
160
- return { removed: false, message: 'Plist not found. Already uninstalled.' };
156
+ function removeLaunchd(options = {}) {
157
+ const platform = options.platform || os.platform();
158
+ const plistPath = options.plistPath || PLIST_PATH;
159
+ const uid = options.uid ?? process.getuid?.();
160
+ const run = options.execFileSync || execFileSync;
161
+
162
+ if (platform !== 'darwin') {
163
+ return { removed: false, failed: false, message: 'launchd is macOS only.' };
161
164
  }
162
165
 
166
+ const hasPlist = fs.existsSync(plistPath);
167
+ let unloaded = false;
168
+ let serviceRemoved = false;
169
+ let stateUnknown = false;
163
170
  try {
164
- execSync(`launchctl bootout gui/${process.getuid()} ${PLIST_PATH}`, {
165
- encoding: 'utf-8',
166
- timeout: 5000,
167
- });
171
+ run('launchctl', ['bootout', `gui/${uid}/${PLIST_NAME}`], launchctlOptions());
172
+ unloaded = true;
173
+ serviceRemoved = true;
168
174
  } catch {
169
- // Might not be loaded
175
+ if (hasPlist) {
176
+ try {
177
+ run('launchctl', ['bootout', `gui/${uid}`, plistPath], launchctlOptions());
178
+ unloaded = true;
179
+ serviceRemoved = true;
180
+ } catch {}
181
+ }
182
+ }
183
+
184
+ if (!unloaded) {
185
+ try {
186
+ run('launchctl', ['print', `gui/${uid}/${PLIST_NAME}`], launchctlOptions());
187
+ } catch (err) {
188
+ if (isLaunchdServiceMissing(err)) {
189
+ unloaded = true;
190
+ } else {
191
+ stateUnknown = true;
192
+ }
193
+ }
194
+ }
195
+
196
+ if (!unloaded) {
197
+ return {
198
+ removed: false,
199
+ failed: true,
200
+ message: stateUnknown
201
+ ? 'Could not verify launchd service removal; plist preserved when present.'
202
+ : 'launchd agent is still loaded; plist preserved for a later retry.',
203
+ };
170
204
  }
171
205
 
172
- fs.unlinkSync(PLIST_PATH);
206
+ if (hasPlist) fs.unlinkSync(plistPath);
207
+ if (!hasPlist && !serviceRemoved) {
208
+ return { removed: false, failed: false, message: 'Plist and launchd service not found. Already uninstalled.' };
209
+ }
173
210
 
174
211
  return {
175
212
  removed: true,
176
- message: `launchd agent removed: ${PLIST_PATH}`,
213
+ failed: false,
214
+ message: hasPlist
215
+ ? `launchd agent removed: ${plistPath}`
216
+ : 'launchd service removed; plist was already absent.',
177
217
  };
178
218
  }
179
219
 
220
+ function launchctlOptions() {
221
+ return { encoding: 'utf-8', timeout: 5000 };
222
+ }
223
+
224
+ function isLaunchdServiceMissing(err) {
225
+ const output = `${err?.message || ''}\n${String(err?.stdout || '')}\n${String(err?.stderr || '')}`;
226
+ return /could not find service|service not found|no such process/i.test(output);
227
+ }
228
+
180
229
  function escapeXml(value) {
181
230
  return String(value)
182
231
  .replace(/&/g, '&amp;')
@@ -3,7 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
- const { execSync } = require('child_process');
6
+ const { execFileSync, execSync } = require('child_process');
7
7
  const { LOCAL_BIN_HINT, quoteSystemdArg, resolveZcleanBin } = require('./bin-path');
8
8
 
9
9
  const SYSTEMD_USER_DIR = path.join(os.homedir(), '.config', 'systemd', 'user');
@@ -72,6 +72,7 @@ function installSystemd() {
72
72
 
73
73
  // Reload and enable
74
74
  const messages = [];
75
+ let active = true;
75
76
  try {
76
77
  execSync('systemctl --user daemon-reload', { encoding: 'utf-8', timeout: 5000 });
77
78
  execSync(`systemctl --user enable --now ${SERVICE_NAME}.timer`, {
@@ -80,6 +81,7 @@ function installSystemd() {
80
81
  });
81
82
  messages.push(`Timer installed and enabled: ${TIMER_PATH}`);
82
83
  } catch (err) {
84
+ active = false;
83
85
  messages.push(`Files written but systemctl failed: ${err.message}`);
84
86
  messages.push(`Try manually: systemctl --user enable --now ${SERVICE_NAME}.timer`);
85
87
  }
@@ -96,6 +98,7 @@ function installSystemd() {
96
98
 
97
99
  return {
98
100
  installed: true,
101
+ active,
99
102
  message: messages.join('\n'),
100
103
  };
101
104
  }
@@ -105,41 +108,57 @@ function installSystemd() {
105
108
  *
106
109
  * @returns {{ removed: boolean, message: string }}
107
110
  */
108
- function removeSystemd() {
109
- if (os.platform() !== 'linux') {
110
- return { removed: false, message: 'systemd is Linux only.' };
111
+ function removeSystemd(options = {}) {
112
+ const platform = options.platform || os.platform();
113
+ const servicePath = options.servicePath || SERVICE_PATH;
114
+ const timerPath = options.timerPath || TIMER_PATH;
115
+ const run = options.execFileSync || execFileSync;
116
+ if (platform !== 'linux') {
117
+ return { removed: false, failed: false, message: 'systemd is Linux only.' };
111
118
  }
112
119
 
113
- // Disable timer
120
+ const hasFiles = fs.existsSync(servicePath) || fs.existsSync(timerPath);
114
121
  try {
115
- execSync(`systemctl --user disable --now ${SERVICE_NAME}.timer`, {
116
- encoding: 'utf-8',
117
- timeout: 5000,
118
- });
119
- } catch {
120
- // Might not be running
122
+ run('systemctl', ['--user', 'disable', '--now', `${SERVICE_NAME}.timer`], systemctlOptions());
123
+ } catch (err) {
124
+ if (!hasFiles && isSystemdUnitMissing(err)) {
125
+ return { removed: false, failed: false, message: 'Files and timer not found. Already uninstalled.' };
126
+ }
127
+ return {
128
+ removed: false,
129
+ failed: true,
130
+ message: 'Could not disable systemd timer; unit files preserved for a later retry.',
131
+ };
121
132
  }
122
133
 
123
- // Remove files
124
- let removed = false;
125
- for (const filepath of [SERVICE_PATH, TIMER_PATH]) {
134
+ for (const filepath of [servicePath, timerPath]) {
126
135
  if (fs.existsSync(filepath)) {
127
136
  fs.unlinkSync(filepath);
128
- removed = true;
129
137
  }
130
138
  }
131
139
 
132
- // Reload daemon
133
140
  try {
134
- execSync('systemctl --user daemon-reload', { encoding: 'utf-8', timeout: 5000 });
135
- } catch { /* ignore */ }
141
+ run('systemctl', ['--user', 'daemon-reload'], systemctlOptions());
142
+ } catch {
143
+ return { removed: true, failed: true, message: 'Timer disabled, but systemd daemon-reload failed.' };
144
+ }
136
145
 
137
146
  return {
138
- removed,
139
- message: removed ? 'systemd timer and service removed.' : 'Files not found. Already uninstalled.',
147
+ removed: true,
148
+ failed: false,
149
+ message: 'systemd timer and service removed.',
140
150
  };
141
151
  }
142
152
 
153
+ function systemctlOptions() {
154
+ return { encoding: 'utf-8', timeout: 5000 };
155
+ }
156
+
157
+ function isSystemdUnitMissing(err) {
158
+ const output = `${err?.message || ''}\n${String(err?.stdout || '')}\n${String(err?.stderr || '')}`;
159
+ return /unit .* (?:does not exist|not found|could not be found)/i.test(output);
160
+ }
161
+
143
162
  module.exports = {
144
163
  installSystemd,
145
164
  removeSystemd,
@@ -45,11 +45,13 @@ function installTaskScheduler() {
45
45
  execFileSync('schtasks', args, { encoding: 'utf-8', timeout: 10000 });
46
46
  return {
47
47
  installed: true,
48
+ active: true,
48
49
  message: `Task Scheduler task created: ${TASK_NAME} (hourly)`,
49
50
  };
50
51
  } catch (err) {
51
52
  return {
52
53
  installed: false,
54
+ active: false,
53
55
  message: `Failed to create scheduled task: ${err.message}\nTry running as administrator.`,
54
56
  };
55
57
  }
@@ -60,31 +62,40 @@ function installTaskScheduler() {
60
62
  *
61
63
  * @returns {{ removed: boolean, message: string }}
62
64
  */
63
- function removeTaskScheduler() {
64
- if (os.platform() !== 'win32') {
65
- return { removed: false, message: 'Task Scheduler is Windows only.' };
65
+ function removeTaskScheduler(options = {}) {
66
+ const platform = options.platform || os.platform();
67
+ const run = options.execFileSync || execFileSync;
68
+ if (platform !== 'win32') {
69
+ return { removed: false, failed: false, message: 'Task Scheduler is Windows only.' };
66
70
  }
67
71
 
68
72
  try {
69
- execFileSync('schtasks', ['/delete', '/TN', TASK_NAME, '/F'], {
73
+ run('schtasks', ['/delete', '/TN', TASK_NAME, '/F'], {
70
74
  encoding: 'utf-8',
71
75
  timeout: 10000,
72
76
  });
73
77
  return {
74
78
  removed: true,
79
+ failed: false,
75
80
  message: `Task Scheduler task removed: ${TASK_NAME}`,
76
81
  };
77
82
  } catch (err) {
78
- if (err.message.includes('does not exist') || err.message.includes('ERROR')) {
79
- return { removed: false, message: 'Task not found. Already uninstalled.' };
83
+ if (isTaskMissing(err)) {
84
+ return { removed: false, failed: false, message: 'Task not found. Already uninstalled.' };
80
85
  }
81
86
  return {
82
87
  removed: false,
88
+ failed: true,
83
89
  message: `Failed to remove task: ${err.message}`,
84
90
  };
85
91
  }
86
92
  }
87
93
 
94
+ function isTaskMissing(err) {
95
+ const output = `${err?.message || ''}\n${String(err?.stdout || '')}\n${String(err?.stderr || '')}`;
96
+ return /task name(?: .*?)? does not exist|cannot find the (?:file|task)|task .* not found/i.test(output);
97
+ }
98
+
88
99
  module.exports = {
89
100
  installTaskScheduler,
90
101
  removeTaskScheduler,
package/src/killer.js CHANGED
@@ -118,6 +118,9 @@ function verifyProcessUnix(proc, runtime = runtimeOptions()) {
118
118
  if (scanPrefix !== currentPrefix) {
119
119
  return { valid: false, reason: 'cmd-mismatch' };
120
120
  }
121
+ if (!matchesCustomLiteral(proc, cmd)) {
122
+ return { valid: false, reason: 'pattern-mismatch' };
123
+ }
121
124
 
122
125
  // Verify start time if available
123
126
  if (proc.startTime) {
@@ -154,6 +157,9 @@ function verifyProcessWindows(proc, runtime = runtimeOptions({ platform: 'win32'
154
157
  if (scanPrefix !== currentPrefix) {
155
158
  return { valid: false, reason: 'cmd-mismatch' };
156
159
  }
160
+ if (!matchesCustomLiteral(proc, currentCmd)) {
161
+ return { valid: false, reason: 'pattern-mismatch' };
162
+ }
157
163
 
158
164
  if (proc.startTime && current.startTime && current.startTime !== proc.startTime) {
159
165
  return { valid: false, reason: 'start-time-mismatch' };
@@ -166,6 +172,11 @@ function verifyProcessWindows(proc, runtime = runtimeOptions({ platform: 'win32'
166
172
  return { valid: true, reason: 'verified' };
167
173
  }
168
174
 
175
+ function matchesCustomLiteral(proc, currentCmd) {
176
+ if (!proc.matchLiteral) return true;
177
+ return String(currentCmd).toLowerCase().includes(String(proc.matchLiteral).toLowerCase());
178
+ }
179
+
169
180
  /**
170
181
  * Kill a process with graceful shutdown sequence.
171
182
  *
package/src/reporter.js CHANGED
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { sanitizeTerminalText } = require('./terminal-text');
4
+
3
5
  /**
4
6
  * ANSI color codes — no external dependencies.
5
7
  */
@@ -78,7 +80,7 @@ function formatDiagnostic(item) {
78
80
  const providers = Array.isArray(item.providers) && item.providers.length > 0
79
81
  ? ` (providers: ${item.providers.join(', ')})`
80
82
  : '';
81
- return `${code}${item.message || 'process scan diagnostic'}${providers}`;
83
+ return sanitizeTerminalText(`${code}${item.message || 'process scan diagnostic'}${providers}`);
82
84
  }
83
85
 
84
86
  function reportScanDiagnostics(result) {
@@ -123,8 +125,8 @@ function reportDryRun(zombies) {
123
125
  const totalMem = zombies.reduce((sum, z) => sum + z.mem, 0);
124
126
 
125
127
  for (const z of zombies) {
126
- 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))}`);
127
- console.log(` ${c('gray', ' cmd:')} ${truncate(z.cmd, 72)}`);
128
+ console.log(` ${c('red', 'PID')} ${c('bold', String(z.pid).padStart(6))} ${c('cyan', truncate(z.name, 16).padEnd(16))} ${c('yellow', formatBytes(z.mem).padStart(8))} ${c('gray', formatDuration(z.age).padStart(6))}`);
129
+ console.log(` ${c('gray', ' cmd:')} ${truncate(sanitizeTerminalText(z.cmd), 72)}`);
128
130
  console.log(` ${c('gray', ' why:')} ${z.reason}`);
129
131
  console.log();
130
132
  }
@@ -155,7 +157,7 @@ function reportKill(results) {
155
157
  const totalMem = killed.reduce((sum, p) => sum + p.mem, 0);
156
158
  console.log(c('green', ` Killed ${killed.length} zombie process${killed.length === 1 ? '' : 'es'}:`));
157
159
  for (const p of killed) {
158
- console.log(` ${c('green', 'KILLED')} PID ${String(p.pid).padStart(6)} ${p.name.padEnd(16)} ${formatBytes(p.mem).padStart(8)}`);
160
+ console.log(` ${c('green', 'KILLED')} PID ${String(p.pid).padStart(6)} ${truncate(p.name, 16).padEnd(16)} ${formatBytes(p.mem).padStart(8)}`);
159
161
  }
160
162
  console.log(c('green', `\n Memory freed: ${formatBytes(totalMem)}`));
161
163
 
@@ -170,14 +172,14 @@ function reportKill(results) {
170
172
  if (skipped.length > 0) {
171
173
  console.log(c('yellow', `\n Skipped ${skipped.length} (re-verification failed):`));
172
174
  for (const p of skipped) {
173
- console.log(` ${c('yellow', 'SKIP')} PID ${String(p.pid).padStart(6)} ${p.name.padEnd(16)} reason: ${p.skipReason}`);
175
+ console.log(` ${c('yellow', 'SKIP')} PID ${String(p.pid).padStart(6)} ${truncate(p.name, 16).padEnd(16)} reason: ${sanitizeTerminalText(p.skipReason)}`);
174
176
  }
175
177
  }
176
178
 
177
179
  if (failed.length > 0) {
178
180
  console.log(c('red', `\n Failed to kill ${failed.length}:`));
179
181
  for (const p of failed) {
180
- console.log(` ${c('red', 'FAIL')} PID ${String(p.pid).padStart(6)} ${p.name.padEnd(16)} error: ${p.error}`);
182
+ console.log(` ${c('red', 'FAIL')} PID ${String(p.pid).padStart(6)} ${truncate(p.name, 16).padEnd(16)} error: ${sanitizeTerminalText(p.error)}`);
181
183
  }
182
184
  }
183
185
 
@@ -233,7 +235,7 @@ function reportLogs(logs) {
233
235
  console.log(` ${time} ${c('green', 'KILL')} PID ${String(entry.pid).padStart(6)} ${(entry.name || '').padEnd(16)} ${formatBytes(entry.memFreed || 0)}`);
234
236
  break;
235
237
  case 'kill-failed':
236
- console.log(` ${time} ${c('red', 'FAIL')} PID ${String(entry.pid).padStart(6)} ${(entry.name || '').padEnd(16)} ${entry.error || ''}`);
238
+ console.log(` ${time} ${c('red', 'FAIL')} PID ${String(entry.pid).padStart(6)} ${(entry.name || '').padEnd(16)} ${sanitizeTerminalText(entry.error || '')}`);
237
239
  break;
238
240
  case 'cleanup-summary':
239
241
  console.log(` ${time} ${c('cyan', 'DONE')} killed:${entry.killed} failed:${entry.failed} skipped:${entry.skipped} freed:${formatBytes(entry.totalMemFreed || 0)}`);
@@ -258,7 +260,7 @@ function reportConfig(config, configPath) {
258
260
  const displayValue = Array.isArray(value)
259
261
  ? (value.length === 0 ? '[]' : JSON.stringify(value))
260
262
  : String(value);
261
- console.log(` ${c('cyan', key.padEnd(20))} ${displayValue}`);
263
+ console.log(` ${c('cyan', key.padEnd(20))} ${sanitizeTerminalText(displayValue)}`);
262
264
  }
263
265
 
264
266
  console.log();
package/src/scanner.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { ProcessTree } = require('./process-tree');
4
- const { matchPattern, AI_DIR_REGEX, buildAiDirRegex } = require('./detector/patterns');
4
+ const { createPatternMatcher, AI_DIR_REGEX, buildAiDirRegex } = require('./detector/patterns');
5
5
  const { isWhitelisted } = require('./detector/whitelist');
6
6
  const { parseDuration, parseMemory } = require('./config');
7
7
 
@@ -18,6 +18,7 @@ const { parseDuration, parseMemory } = require('./config');
18
18
  function scan(config, opts = {}) {
19
19
  const tree = ProcessTree.build();
20
20
  const aiDirRegex = buildAiDirRegex(config.customAiDirs);
21
+ const matchConfiguredPattern = createPatternMatcher(config);
21
22
  const zombies = [];
22
23
  const warnings = Array.isArray(tree.warnings) ? [...tree.warnings] : [];
23
24
  const errors = Array.isArray(tree.errors) ? [...tree.errors] : [];
@@ -35,7 +36,7 @@ function scan(config, opts = {}) {
35
36
  // Iterate all processes in the tree
36
37
  for (const [, proc] of tree.byPid) {
37
38
  // Match against known AI tool patterns
38
- const pattern = matchPattern(proc.cmd, config);
39
+ const pattern = matchConfiguredPattern(proc.cmd);
39
40
  if (!pattern) continue;
40
41
 
41
42
  // AI path filter: skip generic patterns if command isn't in an AI tool directory
@@ -47,6 +48,8 @@ function scan(config, opts = {}) {
47
48
  ? tree.hasAncestorMatching(proc.pid, (p) => p.pid === sessionPid)
48
49
  : false;
49
50
 
51
+ if (pattern.strictOrphan && !orphanResult.isOrphan) continue;
52
+
50
53
  // If pattern requires orphan status and process isn't orphaned, skip
51
54
  if (pattern.orphanOnly && !orphanResult.isOrphan && !sessionRelated) continue;
52
55
 
@@ -90,6 +93,7 @@ function scan(config, opts = {}) {
90
93
  startTime: proc.startTime,
91
94
  reason: reasons.join(', '),
92
95
  pattern: pattern.name,
96
+ matchLiteral: pattern.literal || null,
93
97
  });
94
98
  }
95
99
 
@@ -0,0 +1,11 @@
1
+ 'use strict';
2
+
3
+ function sanitizeTerminalText(value) {
4
+ return String(value ?? '')
5
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
6
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
7
+ .replace(/[\r\n]+/g, ' ')
8
+ .replace(/[\p{Cc}\p{Cf}]/gu, '');
9
+ }
10
+
11
+ module.exports = { sanitizeTerminalText };