z-clean 0.1.1 → 0.3.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.
@@ -1,8 +1,7 @@
1
1
  'use strict';
2
2
 
3
- const { execSync } = require('child_process');
4
- const os = require('os');
5
3
  const fs = require('fs');
4
+ const os = require('os');
6
5
 
7
6
  const platform = os.platform();
8
7
 
@@ -12,16 +11,14 @@ const DAEMON_MANAGERS = ['pm2', 'forever', 'supervisord', 'supervisor', 'nodemon
12
11
  /**
13
12
  * Check if a process should be protected from cleanup.
14
13
  *
15
- * Protection criteria:
16
- * 1. In user's config whitelist (PID or name pattern)
17
- * 2. Has a daemon manager ancestor (pm2, forever, supervisord)
18
- * 3. Running inside a Docker container (Linux: different PID namespace)
19
- * 4. Launched with nohup
20
- * 5. VS Code child process (48h grace period)
14
+ * Now tree-aware: ancestor checks use ProcessTree instead of per-process execSync.
21
15
  *
22
- * Returns { protected: boolean, reason: string }
16
+ * @param {object} proc process info from tree
17
+ * @param {object} config — zclean config
18
+ * @param {import('../process-tree').ProcessTree} [tree] — in-memory process tree
19
+ * @returns {{ protected: boolean, reason: string }}
23
20
  */
24
- function isWhitelisted(proc, config) {
21
+ function isWhitelisted(proc, config, tree) {
25
22
  // 1. Config whitelist — match by PID or name substring
26
23
  if (config.whitelist && config.whitelist.length > 0) {
27
24
  for (const entry of config.whitelist) {
@@ -34,12 +31,12 @@ function isWhitelisted(proc, config) {
34
31
  }
35
32
  }
36
33
 
37
- // 2. Daemon manager ancestor
38
- if (hasDaemonAncestor(proc.pid)) {
34
+ // 2. Daemon manager ancestor (tree-based, no execSync)
35
+ if (tree && hasDaemonAncestorTree(proc.pid, tree)) {
39
36
  return { protected: true, reason: 'daemon-managed' };
40
37
  }
41
38
 
42
- // 3. Docker container (Linux only)
39
+ // 3. Docker container (Linux only — reads /proc, no tree needed)
43
40
  if (isInDocker(proc.pid)) {
44
41
  return { protected: true, reason: 'docker-container' };
45
42
  }
@@ -49,9 +46,9 @@ function isWhitelisted(proc, config) {
49
46
  return { protected: true, reason: 'nohup-launched' };
50
47
  }
51
48
 
52
- // 5. VS Code child with grace period
49
+ // 5. VS Code child with grace period (tree-based)
53
50
  const vscodeGrace = 48 * 60 * 60 * 1000; // 48 hours
54
- if (isVSCodeChild(proc.pid) && proc.age < vscodeGrace) {
51
+ if (tree && isVSCodeChildTree(proc.pid, tree) && proc.age < vscodeGrace) {
55
52
  return { protected: true, reason: 'vscode-child-grace' };
56
53
  }
57
54
 
@@ -59,68 +56,20 @@ function isWhitelisted(proc, config) {
59
56
  }
60
57
 
61
58
  /**
62
- * Walk process tree upward looking for daemon manager ancestors.
59
+ * Check for daemon manager ancestor via ProcessTree.
63
60
  */
64
- function hasDaemonAncestor(pid) {
65
- if (platform === 'win32') {
66
- // Simplified check for Windows — just check parent command
67
- try {
68
- const output = execSync(
69
- `wmic process where ProcessId=${pid} get ParentProcessId /value`,
70
- { encoding: 'utf-8', timeout: 5000 }
71
- ).trim();
72
- const match = output.match(/ParentProcessId=(\d+)/);
73
- if (!match) return false;
74
- const ppid = parseInt(match[1], 10);
75
- const parentCmd = execSync(
76
- `wmic process where ProcessId=${ppid} get CommandLine /value`,
77
- { encoding: 'utf-8', timeout: 5000 }
78
- ).trim();
79
- return DAEMON_MANAGERS.some((dm) => parentCmd.toLowerCase().includes(dm));
80
- } catch {
81
- return false;
82
- }
83
- }
84
-
85
- // Unix: walk up the tree
86
- const visited = new Set();
87
- let currentPid = pid;
88
-
89
- while (currentPid > 1 && !visited.has(currentPid)) {
90
- visited.add(currentPid);
91
- try {
92
- const info = execSync(`ps -o ppid=,comm= -p ${currentPid}`, {
93
- encoding: 'utf-8',
94
- timeout: 5000,
95
- }).trim();
96
-
97
- const parts = info.trim().split(/\s+/);
98
- if (parts.length < 2) break;
99
-
100
- const parentPid = parseInt(parts[0], 10);
101
- const comm = parts.slice(1).join(' ').toLowerCase();
102
-
103
- if (DAEMON_MANAGERS.some((dm) => comm.includes(dm))) {
104
- return true;
105
- }
106
-
107
- if (isNaN(parentPid) || parentPid <= 1) break;
108
- currentPid = parentPid;
109
- } catch {
110
- break;
111
- }
112
- }
113
-
114
- return false;
61
+ function hasDaemonAncestorTree(pid, tree) {
62
+ return tree.hasAncestorMatching(pid, (proc) => {
63
+ const comm = proc.cmd.split(/\s+/)[0].split('/').pop().toLowerCase();
64
+ return DAEMON_MANAGERS.some((dm) => comm.includes(dm));
65
+ });
115
66
  }
116
67
 
117
68
  /**
118
- * Check if a process is running inside a Docker container.
119
- * Linux only: compares PID namespace with init (PID 1).
69
+ * Check if running inside a Docker container (Linux only).
120
70
  */
121
71
  function isInDocker(pid) {
122
72
  if (platform !== 'linux') return false;
123
-
124
73
  try {
125
74
  const procNs = fs.readlinkSync(`/proc/${pid}/ns/pid`);
126
75
  const initNs = fs.readlinkSync('/proc/1/ns/pid');
@@ -138,41 +87,13 @@ function isNohup(cmdline) {
138
87
  }
139
88
 
140
89
  /**
141
- * Check if a process is a child of VS Code.
90
+ * Check for VS Code ancestor via ProcessTree.
142
91
  */
143
- function isVSCodeChild(pid) {
144
- if (platform === 'win32') return false;
145
-
146
- const visited = new Set();
147
- let currentPid = pid;
148
-
149
- while (currentPid > 1 && !visited.has(currentPid)) {
150
- visited.add(currentPid);
151
- try {
152
- const info = execSync(`ps -o ppid=,comm= -p ${currentPid}`, {
153
- encoding: 'utf-8',
154
- timeout: 5000,
155
- }).trim();
156
-
157
- const parts = info.trim().split(/\s+/);
158
- if (parts.length < 2) break;
159
-
160
- const parentPid = parseInt(parts[0], 10);
161
- const comm = parts.slice(1).join(' ').toLowerCase();
162
-
163
- // VS Code process names: code, code-insiders, electron (Code.app)
164
- if (/\b(code|code-insiders|electron.*code)\b/.test(comm)) {
165
- return true;
166
- }
167
-
168
- if (isNaN(parentPid) || parentPid <= 1) break;
169
- currentPid = parentPid;
170
- } catch {
171
- break;
172
- }
173
- }
174
-
175
- return false;
92
+ function isVSCodeChildTree(pid, tree) {
93
+ return tree.hasAncestorMatching(pid, (proc) => {
94
+ const comm = proc.cmd.split(/\s+/)[0].split('/').pop().toLowerCase();
95
+ return /\b(code|code-insiders|electron.*code)\b/.test(comm);
96
+ });
176
97
  }
177
98
 
178
- module.exports = { isWhitelisted, hasDaemonAncestor, isInDocker, isNohup, isVSCodeChild };
99
+ module.exports = { isWhitelisted, isInDocker, isNohup };
@@ -0,0 +1,229 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { execSync } = require('child_process');
7
+ const { scan, hasScanErrors } = require('../scanner');
8
+ const { getConfigFile, getCumulativeStats } = require('../config');
9
+
10
+ function buildDoctorReport(config, options = {}) {
11
+ const runtime = buildRuntime(options);
12
+ const generatedAt = readGeneratedAt(options);
13
+ const stats = normalizeStats(options.stats || getCumulativeStats());
14
+ const checks = [
15
+ checkConfig(),
16
+ checkProcessScan(config, options),
17
+ checkHook(runtime.homedir),
18
+ checkScheduler(runtime),
19
+ checkLastRun(stats, generatedAt),
20
+ ];
21
+
22
+ return {
23
+ schemaVersion: 1,
24
+ generatedAt,
25
+ overallStatus: getOverallStatus(checks),
26
+ issueCount: checks.filter((check) => check.status !== 'ok').length,
27
+ checks,
28
+ stats,
29
+ };
30
+ }
31
+
32
+ function buildRuntime(options) {
33
+ const runtime = options.runtime || {};
34
+ return {
35
+ platform: runtime.platform || options.platform || os.platform(),
36
+ homedir: runtime.homedir || options.homedir || os.homedir(),
37
+ execSync: runtime.execSync || options.execSync || execSync,
38
+ };
39
+ }
40
+
41
+ function readGeneratedAt(options) {
42
+ const value = typeof options.now === 'function' ? options.now() : new Date().toISOString();
43
+ if (value instanceof Date) return value.toISOString();
44
+ return String(value);
45
+ }
46
+
47
+ function normalizeStats(stats) {
48
+ return {
49
+ totalKilled: stats.totalKilled || 0,
50
+ totalMemFreed: stats.totalMemFreed || 0,
51
+ weekKilled: stats.weekKilled || 0,
52
+ weekMemFreed: stats.weekMemFreed || 0,
53
+ lastRun: stats.lastRun || null,
54
+ };
55
+ }
56
+
57
+ function checkConfig() {
58
+ if (fs.existsSync(getConfigFile())) {
59
+ return { id: 'config', status: 'ok', message: 'found' };
60
+ }
61
+ return {
62
+ id: 'config',
63
+ status: 'warning',
64
+ message: 'not found - run `zclean init`',
65
+ };
66
+ }
67
+
68
+ function checkProcessScan(config, options) {
69
+ const scanFn = options.scan || scan;
70
+ const hasScanErrorsFn = options.hasScanErrors || hasScanErrors;
71
+ let scanResult;
72
+
73
+ try {
74
+ scanResult = scanFn(config);
75
+ } catch (err) {
76
+ return {
77
+ id: 'process-scan',
78
+ status: 'error',
79
+ message: err.message || 'unable to enumerate processes',
80
+ };
81
+ }
82
+
83
+ if (hasScanErrorsFn(scanResult)) {
84
+ const errors = Array.isArray(scanResult.errors) ? scanResult.errors : [];
85
+ const first = errors[0] || {};
86
+ return {
87
+ id: 'process-scan',
88
+ status: 'error',
89
+ message: `failed - ${first.message || first.code || 'unable to enumerate processes'}`,
90
+ details: { errors },
91
+ };
92
+ }
93
+
94
+ if (Array.isArray(scanResult.warnings) && scanResult.warnings.length > 0) {
95
+ return {
96
+ id: 'process-scan',
97
+ status: 'warning',
98
+ message: `warning - ${scanResult.warnings[0].message || scanResult.warnings[0].code || 'partial diagnostic'}`,
99
+ details: { warnings: scanResult.warnings },
100
+ };
101
+ }
102
+
103
+ return { id: 'process-scan', status: 'ok', message: 'healthy' };
104
+ }
105
+
106
+ function checkHook(homeDir) {
107
+ const claudeSettings = path.join(homeDir, '.claude', 'settings.json');
108
+ let hookInstalled = false;
109
+ if (fs.existsSync(claudeSettings)) {
110
+ try {
111
+ const settings = JSON.parse(fs.readFileSync(claudeSettings, 'utf-8'));
112
+ const hooks = settings.hooks?.Stop || [];
113
+ hookInstalled = hooks.some((h) =>
114
+ (h.command && h.command.includes('zclean')) ||
115
+ (Array.isArray(h.hooks) && h.hooks.some((sub) => sub.command && sub.command.includes('zclean')))
116
+ );
117
+ } catch {}
118
+ }
119
+ if (hookInstalled) return { id: 'hook', status: 'ok', message: 'Claude Code SessionEnd registered' };
120
+ return { id: 'hook', status: 'warning', message: 'not registered - run `zclean init`' };
121
+ }
122
+
123
+ function checkScheduler(runtime) {
124
+ if (runtime.platform === 'darwin') return checkLaunchd(runtime);
125
+ if (runtime.platform === 'linux') return checkSystemd(runtime);
126
+ if (runtime.platform === 'win32') return checkTaskScheduler(runtime);
127
+ return {
128
+ id: 'scheduler',
129
+ status: 'warning',
130
+ message: `unsupported platform (${runtime.platform})`,
131
+ details: { platform: runtime.platform },
132
+ };
133
+ }
134
+
135
+ function checkLaunchd(runtime) {
136
+ const plistPath = path.join(runtime.homedir, 'Library', 'LaunchAgents', 'com.zclean.hourly.plist');
137
+ if (!fs.existsSync(plistPath)) {
138
+ return {
139
+ id: 'scheduler',
140
+ status: 'warning',
141
+ message: 'not installed - run `zclean init`',
142
+ details: { platform: 'darwin' },
143
+ };
144
+ }
145
+
146
+ try {
147
+ const out = runtime.execSync('launchctl list com.zclean.hourly 2>&1', { encoding: 'utf-8', timeout: 5000 });
148
+ if (!out.includes('Could not find')) {
149
+ return {
150
+ id: 'scheduler',
151
+ status: 'ok',
152
+ message: 'launchd agent loaded',
153
+ details: { platform: 'darwin' },
154
+ };
155
+ }
156
+ } catch {}
157
+
158
+ return {
159
+ id: 'scheduler',
160
+ status: 'warning',
161
+ message: 'plist exists but not loaded - run `zclean init`',
162
+ details: { platform: 'darwin' },
163
+ };
164
+ }
165
+
166
+ function checkSystemd(runtime) {
167
+ const timerPath = path.join(runtime.homedir, '.config', 'systemd', 'user', 'zclean.timer');
168
+ if (fs.existsSync(timerPath)) {
169
+ return {
170
+ id: 'scheduler',
171
+ status: 'ok',
172
+ message: 'systemd timer installed',
173
+ details: { platform: 'linux' },
174
+ };
175
+ }
176
+ return {
177
+ id: 'scheduler',
178
+ status: 'warning',
179
+ message: 'not installed - run `zclean init`',
180
+ details: { platform: 'linux' },
181
+ };
182
+ }
183
+
184
+ function checkTaskScheduler(runtime) {
185
+ try {
186
+ runtime.execSync('schtasks /query /TN "zclean-hourly"', { encoding: 'utf-8', timeout: 5000 });
187
+ return {
188
+ id: 'scheduler',
189
+ status: 'ok',
190
+ message: 'Task Scheduler task installed',
191
+ details: { platform: 'win32' },
192
+ };
193
+ } catch {}
194
+ return {
195
+ id: 'scheduler',
196
+ status: 'warning',
197
+ message: 'not installed - run `zclean init`',
198
+ details: { platform: 'win32' },
199
+ };
200
+ }
201
+
202
+ function checkLastRun(stats, generatedAt) {
203
+ if (!stats.lastRun) return { id: 'last-run', status: 'ok', message: 'never - run `zclean --yes` to test' };
204
+
205
+ const ago = new Date(generatedAt).getTime() - new Date(stats.lastRun).getTime();
206
+ const agoStr = ago < 3600000 ? `${Math.floor(ago / 60000)}m ago` :
207
+ ago < 86400000 ? `${Math.floor(ago / 3600000)}h ago` :
208
+ `${Math.floor(ago / 86400000)}d ago`;
209
+ if (ago > 2 * 3600000) {
210
+ return {
211
+ id: 'last-run',
212
+ status: 'warning',
213
+ message: `${agoStr} (${stats.lastRun.slice(0, 19).replace('T', ' ')}) - scheduler may not be running`,
214
+ };
215
+ }
216
+ return {
217
+ id: 'last-run',
218
+ status: 'ok',
219
+ message: `${agoStr} (${stats.lastRun.slice(0, 19).replace('T', ' ')})`,
220
+ };
221
+ }
222
+
223
+ function getOverallStatus(checks) {
224
+ if (checks.some((check) => check.status === 'error')) return 'error';
225
+ if (checks.some((check) => check.status === 'warning')) return 'warning';
226
+ return 'ok';
227
+ }
228
+
229
+ module.exports = { buildDoctorReport };
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+
3
+ const { c, bold } = require('../reporter');
4
+
5
+ function renderDoctorText(report, write) {
6
+ write(bold('\n zclean doctor\n\n'));
7
+ for (const check of report.checks) {
8
+ write(formatCheckLine(check));
9
+ }
10
+ write(c('cyan', ' Stats:') + ` ${report.stats.totalKilled} cleaned all time, ${report.stats.weekKilled} this week\n`);
11
+ write('\n');
12
+ if (report.overallStatus === 'ok') {
13
+ write(c('green', ' All checks passed.\n\n'));
14
+ return;
15
+ }
16
+ write(c('yellow', ` ${report.issueCount} issue${report.issueCount === 1 ? '' : 's'} found. Run \`zclean init\` to fix.\n\n`));
17
+ }
18
+
19
+ function formatCheckLine(check) {
20
+ const labels = {
21
+ config: 'Config',
22
+ 'process-scan': 'Process scan',
23
+ hook: 'Hook',
24
+ scheduler: 'Scheduler',
25
+ 'last-run': 'Last run',
26
+ };
27
+ const colors = {
28
+ ok: 'green',
29
+ warning: 'yellow',
30
+ error: 'red',
31
+ };
32
+ const label = `${labels[check.id] || check.id}:`;
33
+ const padding = ' '.repeat(Math.max(1, 14 - label.length));
34
+ return c(colors[check.status] || 'gray', ` ${label}`) + `${padding}${check.message}\n`;
35
+ }
36
+
37
+ module.exports = { renderDoctorText };
package/src/doctor.js ADDED
@@ -0,0 +1,22 @@
1
+ 'use strict';
2
+
3
+ const { buildDoctorReport } = require('./doctor/checks');
4
+ const { renderDoctorText } = require('./doctor/render');
5
+
6
+ function runDoctor(config, options = {}) {
7
+ const report = buildDoctorReport(config, options);
8
+ const exitCode = report.overallStatus === 'ok' ? 0 : 1;
9
+ const write = typeof options.write === 'function'
10
+ ? options.write
11
+ : (chunk) => process.stdout.write(chunk);
12
+
13
+ if (options.json) {
14
+ write(`${JSON.stringify(report, null, 2)}\n`);
15
+ } else {
16
+ renderDoctorText(report, write);
17
+ }
18
+
19
+ return { ...report, exitCode };
20
+ }
21
+
22
+ module.exports = { runDoctor };
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { execSync } = require('child_process');
7
+
8
+ const LOCAL_BIN_HINT = 'Install zclean first with `npm install -g z-clean`, then run `zclean init` again.';
9
+
10
+ function resolveZcleanBin(options = {}) {
11
+ const runtime = {
12
+ execSync: options.execSync || execSync,
13
+ existsSync: options.existsSync || fs.existsSync,
14
+ homedir: options.homedir || os.homedir(),
15
+ platform: options.platform || os.platform(),
16
+ argv: options.argv || process.argv,
17
+ };
18
+
19
+ const current = currentExecutableCandidate(runtime);
20
+ if (current) return current;
21
+
22
+ for (const candidate of npmGlobalCandidates(runtime)) {
23
+ if (runtime.existsSync(candidate)) return candidate;
24
+ }
25
+
26
+ for (const candidate of commonCandidates(runtime)) {
27
+ if (runtime.existsSync(candidate)) return candidate;
28
+ }
29
+
30
+ return null;
31
+ }
32
+
33
+ function currentExecutableCandidate(runtime) {
34
+ const current = runtime.argv && runtime.argv[1] ? path.resolve(runtime.argv[1]) : null;
35
+ if (!current || isTransientNpxPath(current) || !runtime.existsSync(current)) return null;
36
+
37
+ const base = path.basename(current).toLowerCase();
38
+ if (base === 'zclean' || base === 'zclean.cmd' || base === 'zclean.js') return current;
39
+ return null;
40
+ }
41
+
42
+ function npmGlobalCandidates(runtime) {
43
+ const names = runtime.platform === 'win32' ? ['zclean.cmd', 'zclean'] : ['zclean'];
44
+ const prefixes = [];
45
+
46
+ try {
47
+ const npmBin = runtime.execSync('npm bin -g', { encoding: 'utf-8', timeout: 5000 }).trim();
48
+ if (npmBin) prefixes.push(npmBin);
49
+ } catch {}
50
+
51
+ try {
52
+ const npmPrefix = runtime.execSync('npm prefix -g', { encoding: 'utf-8', timeout: 5000 }).trim();
53
+ if (npmPrefix) {
54
+ prefixes.push(runtime.platform === 'win32' ? npmPrefix : path.join(npmPrefix, 'bin'));
55
+ prefixes.push(path.join(npmPrefix, 'node_modules', '.bin'));
56
+ }
57
+ } catch {}
58
+
59
+ return unique(prefixes.flatMap((prefix) => names.map((name) => path.join(prefix, name))));
60
+ }
61
+
62
+ function commonCandidates(runtime) {
63
+ if (runtime.platform === 'win32') {
64
+ const appData = process.env.APPDATA || path.join(runtime.homedir, 'AppData', 'Roaming');
65
+ return [
66
+ path.join(appData, 'npm', 'zclean.cmd'),
67
+ path.join(runtime.homedir, 'AppData', 'Roaming', 'npm', 'zclean.cmd'),
68
+ ];
69
+ }
70
+
71
+ return [
72
+ path.join(runtime.homedir, '.local', 'bin', 'zclean'),
73
+ path.join(runtime.homedir, 'node_modules', '.bin', 'zclean'),
74
+ path.join(runtime.homedir, '.local', 'share', 'npm', 'bin', 'zclean'),
75
+ '/opt/homebrew/bin/zclean',
76
+ '/usr/local/bin/zclean',
77
+ '/usr/bin/zclean',
78
+ ];
79
+ }
80
+
81
+ function quoteShellArg(value) {
82
+ const text = String(value);
83
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(text)) return text;
84
+ return `'${text.replace(/'/g, "'\\''")}'`;
85
+ }
86
+
87
+ function quoteSystemdArg(value) {
88
+ const text = String(value);
89
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(text)) return text;
90
+ return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
91
+ }
92
+
93
+ function isTransientNpxPath(filePath) {
94
+ return /[\\/]_npx[\\/]/.test(filePath);
95
+ }
96
+
97
+ function unique(values) {
98
+ return [...new Set(values.filter(Boolean))];
99
+ }
100
+
101
+ module.exports = {
102
+ LOCAL_BIN_HINT,
103
+ quoteShellArg,
104
+ quoteSystemdArg,
105
+ resolveZcleanBin,
106
+ };
@@ -3,10 +3,9 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
+ const { LOCAL_BIN_HINT, quoteShellArg, resolveZcleanBin } = require('./bin-path');
6
7
 
7
8
  const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
8
- // Use npx to ensure zclean is found regardless of global install status
9
- const HOOK_COMMAND = 'npx --yes @thestackai/zclean --yes --session-pid=$PPID';
10
9
  const HOOK_ID = 'zclean-session-cleanup';
11
10
 
12
11
  /**
@@ -71,13 +70,21 @@ function installHook() {
71
70
  };
72
71
  }
73
72
 
73
+ const binPath = resolveZcleanBin();
74
+ if (!binPath) {
75
+ return {
76
+ installed: false,
77
+ message: `Local zclean executable not found. ${LOCAL_BIN_HINT}`,
78
+ };
79
+ }
80
+
74
81
  // Add hook using the matcher + hooks array format required by Claude Code
75
82
  settings.hooks.Stop.push({
76
83
  matcher: '',
77
84
  hooks: [
78
85
  {
79
86
  type: 'command',
80
- command: HOOK_COMMAND,
87
+ command: buildHookCommand(binPath),
81
88
  },
82
89
  ],
83
90
  });
@@ -133,4 +140,8 @@ function removeHook() {
133
140
  return { removed: true, message: 'Hook removed from Claude Code settings.' };
134
141
  }
135
142
 
136
- module.exports = { installHook, removeHook };
143
+ function buildHookCommand(binPath) {
144
+ return `${quoteShellArg(binPath)} --yes --session-pid=$PPID`;
145
+ }
146
+
147
+ module.exports = { installHook, removeHook, buildHookCommand };