@mauribadnights/clooks 0.4.1 → 0.5.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/dist/cli.js CHANGED
@@ -15,13 +15,14 @@ const service_js_1 = require("./service.js");
15
15
  const constants_js_1 = require("./constants.js");
16
16
  const tui_js_1 = require("./tui.js");
17
17
  const agent_js_1 = require("./agent.js");
18
+ const import_plugins_js_1 = require("./import-plugins.js");
18
19
  const fs_1 = require("fs");
19
20
  const path_1 = require("path");
20
21
  const program = new commander_1.Command();
21
22
  program
22
23
  .name('clooks')
23
24
  .description('Persistent hook runtime for Claude Code')
24
- .version('0.4.1');
25
+ .version('0.5.1');
25
26
  // --- start ---
26
27
  program
27
28
  .command('start')
@@ -31,11 +32,13 @@ program
31
32
  .action(async (opts) => {
32
33
  const noWatch = opts.watch === false;
33
34
  if (!opts.foreground) {
34
- // Background mode: check if already running and healthy
35
+ // Fix 6: Idempotent start — check if daemon is already healthy first
36
+ // This covers both normal PID file case AND orphaned daemon (no PID file)
35
37
  if ((0, server_js_1.isDaemonRunning)()) {
36
38
  const healthy = await (0, server_js_1.isDaemonHealthy)();
37
39
  if (healthy) {
38
- console.log('Daemon is already running.');
40
+ const pid = (0, fs_1.existsSync)(constants_js_1.PID_FILE) ? (0, fs_1.readFileSync)(constants_js_1.PID_FILE, 'utf-8').trim() : '?';
41
+ console.log(`Daemon is already running (pid ${pid}).`);
39
42
  process.exit(0);
40
43
  }
41
44
  // PID alive but daemon unhealthy — stale process after sleep/lid-close
@@ -44,6 +47,16 @@ program
44
47
  console.log(`Cleaned up stale daemon (pid ${stalePid}), starting fresh`);
45
48
  }
46
49
  }
50
+ else {
51
+ // No PID file or dead PID — check if an orphaned daemon is on the port
52
+ const health = await (0, server_js_1.probeHealth)();
53
+ if (health && health.pid) {
54
+ // Orphaned daemon found — re-adopt it
55
+ (0, server_js_1.writePidFile)(health.pid);
56
+ console.log(`Adopted existing daemon (pid ${health.pid}).`);
57
+ process.exit(0);
58
+ }
59
+ }
47
60
  // Ensure config dir exists
48
61
  if (!(0, fs_1.existsSync)(constants_js_1.CONFIG_DIR)) {
49
62
  (0, fs_1.mkdirSync)(constants_js_1.CONFIG_DIR, { recursive: true });
@@ -62,7 +75,15 @@ program
62
75
  console.log(`Daemon started (pid ${pid}), listening on 127.0.0.1:${constants_js_1.DEFAULT_PORT}`);
63
76
  }
64
77
  else {
65
- console.log('Daemon started. Check ~/.clooks/daemon.log if issues arise.');
78
+ // Fix 1: After spawn, if PID file missing, check if port responded (orphan recovery)
79
+ const health = await (0, server_js_1.probeHealth)();
80
+ if (health && health.pid) {
81
+ (0, server_js_1.writePidFile)(health.pid);
82
+ console.log(`Adopted existing daemon (pid ${health.pid}).`);
83
+ }
84
+ else {
85
+ console.log('Daemon started. Check ~/.clooks/daemon.log if issues arise.');
86
+ }
66
87
  }
67
88
  process.exit(0);
68
89
  }
@@ -85,12 +106,24 @@ program
85
106
  program
86
107
  .command('stop')
87
108
  .description('Stop the clooks daemon')
88
- .action(() => {
109
+ .action(async () => {
110
+ // Try sync stop first (fast path with PID file)
89
111
  if ((0, server_js_1.stopDaemon)()) {
90
112
  console.log('Daemon stopped.');
113
+ return;
114
+ }
115
+ // Fix 4: No PID file — try async recovery via /health
116
+ const result = await (0, server_js_1.stopDaemonAsync)();
117
+ if (result.stopped) {
118
+ if (result.recovered) {
119
+ console.log(`Daemon stopped (recovered orphan, pid ${result.pid}).`);
120
+ }
121
+ else {
122
+ console.log('Daemon stopped.');
123
+ }
91
124
  }
92
125
  else {
93
- console.log('Daemon is not running (no PID file or process not found).');
126
+ console.log('Daemon is not running.');
94
127
  }
95
128
  });
96
129
  // --- status ---
@@ -99,18 +132,28 @@ program
99
132
  .description('Show daemon status')
100
133
  .action(async () => {
101
134
  const running = (0, server_js_1.isDaemonRunning)();
135
+ const serviceStatus = (0, service_js_1.getServiceStatus)();
102
136
  if (!running) {
137
+ // Fix 2: No PID file or dead PID — check if orphaned daemon is on the port
138
+ const health = await (0, server_js_1.probeHealth)();
139
+ if (health && health.pid) {
140
+ // Orphaned daemon found — recover PID file
141
+ (0, server_js_1.writePidFile)(health.pid);
142
+ console.log(`Status: running (recovered, pid ${health.pid})`);
143
+ console.log(`Port: ${constants_js_1.DEFAULT_PORT}`);
144
+ console.log(`Service: ${serviceStatus}`);
145
+ console.log('Note: PID file was missing. Re-adopted orphaned daemon.');
146
+ return;
147
+ }
103
148
  console.log('Status: stopped');
104
149
  return;
105
150
  }
106
151
  const pid = (0, fs_1.existsSync)(constants_js_1.PID_FILE) ? (0, fs_1.readFileSync)(constants_js_1.PID_FILE, 'utf-8').trim() : '?';
107
- // Try to hit health endpoint
108
- // Service status
109
- const serviceStatus = (0, service_js_1.getServiceStatus)();
152
+ // Try to hit health endpoint for detailed info
110
153
  try {
111
154
  const { get } = await import('http');
112
155
  const data = await new Promise((resolve, reject) => {
113
- const req = get(`http://127.0.0.1:${constants_js_1.DEFAULT_PORT}/health`, (res) => {
156
+ const req = get(`http://127.0.0.1:${constants_js_1.DEFAULT_PORT}/health/detail`, (res) => {
114
157
  let body = '';
115
158
  res.on('data', (chunk) => { body += chunk.toString(); });
116
159
  res.on('end', () => resolve(body));
@@ -247,6 +290,65 @@ program
247
290
  }
248
291
  }
249
292
  });
293
+ // --- import-plugins ---
294
+ program
295
+ .command('import-plugins')
296
+ .description('Import hooks from installed Claude Code plugins')
297
+ .action(async () => {
298
+ try {
299
+ const { plugins, handlers } = (0, import_plugins_js_1.importPlugins)();
300
+ if (plugins.length === 0) {
301
+ console.log('No Claude Code plugins with hooks found.');
302
+ return;
303
+ }
304
+ // Load current manifest
305
+ const manifest = (0, manifest_js_1.loadCompositeManifest)();
306
+ // Remove previously imported plugin handlers (those with "/" matching discovered plugin names)
307
+ const pluginNames = new Set(plugins.map(p => p.name));
308
+ for (const [event, eventHandlers] of Object.entries(manifest.handlers)) {
309
+ if (!eventHandlers)
310
+ continue;
311
+ manifest.handlers[event] = eventHandlers.filter(h => {
312
+ const slashIdx = h.id.indexOf('/');
313
+ if (slashIdx === -1)
314
+ return true; // user handler, keep
315
+ const prefix = h.id.substring(0, slashIdx);
316
+ return !pluginNames.has(prefix); // remove if prefix matches a discovered plugin
317
+ });
318
+ }
319
+ // Add new imported handlers
320
+ for (const [event, eventHandlers] of Object.entries(handlers)) {
321
+ const hookEvent = event;
322
+ if (!manifest.handlers[hookEvent])
323
+ manifest.handlers[hookEvent] = [];
324
+ manifest.handlers[hookEvent].push(...eventHandlers);
325
+ }
326
+ // Write updated manifest
327
+ const { stringify: stringifyYaml } = await import('yaml');
328
+ const { writeFileSync } = await import('fs');
329
+ const yamlStr = '# clooks manifest — updated by import-plugins\n' +
330
+ `# Date: ${new Date().toISOString()}\n\n` +
331
+ stringifyYaml(manifest);
332
+ writeFileSync(constants_js_1.MANIFEST_PATH, yamlStr, 'utf-8');
333
+ // Sync settings.json
334
+ const syncAdded = (0, sync_js_1.syncSettings)();
335
+ // Report
336
+ const totalHandlers = Object.values(handlers).reduce((sum, arr) => sum + arr.length, 0);
337
+ console.log(`Imported ${totalHandlers} handler(s) from ${plugins.length} CC plugin(s):`);
338
+ for (const p of plugins) {
339
+ const count = Object.values(p.hooks).reduce((sum, arr) => sum + arr.filter(h => h.type === 'command').length, 0);
340
+ const enhanced = p.clooksEnhancements ? ' (with clooks.yaml)' : '';
341
+ console.log(` ${p.name} v${p.version}: ${count} handler(s)${enhanced}`);
342
+ }
343
+ if (syncAdded.length > 0) {
344
+ console.log(`Synced HTTP hooks for: ${syncAdded.join(', ')}`);
345
+ }
346
+ }
347
+ catch (err) {
348
+ console.error('Import failed:', err instanceof Error ? err.message : err);
349
+ process.exit(1);
350
+ }
351
+ });
250
352
  // --- ensure-running ---
251
353
  program
252
354
  .command('ensure-running')
@@ -273,6 +375,15 @@ program
273
375
  }
274
376
  }
275
377
  }
378
+ else {
379
+ // No PID file — check for orphaned daemon on the port
380
+ const health = await (0, server_js_1.probeHealth)();
381
+ if (health && health.pid) {
382
+ (0, server_js_1.writePidFile)(health.pid);
383
+ (0, sync_js_1.syncSettings)();
384
+ process.exit(0);
385
+ }
386
+ }
276
387
  // Ensure config dir exists
277
388
  if (!(0, fs_1.existsSync)(constants_js_1.CONFIG_DIR)) {
278
389
  (0, fs_1.mkdirSync)(constants_js_1.CONFIG_DIR, { recursive: true });
@@ -0,0 +1,50 @@
1
+ import type { HandlerConfig, HookEvent } from './types.js';
2
+ /** A discovered Claude Code plugin */
3
+ export interface CCPlugin {
4
+ name: string;
5
+ version: string;
6
+ path: string;
7
+ hooks: Partial<Record<HookEvent, CCHookEntry[]>>;
8
+ clooksEnhancements?: Partial<Record<string, ClooksEnhancement>>;
9
+ }
10
+ interface CCHookEntry {
11
+ type: 'command' | 'http' | 'prompt' | 'agent';
12
+ command?: string;
13
+ url?: string;
14
+ matcher?: string;
15
+ timeout?: number;
16
+ }
17
+ /** clooks.yaml enhancement overlay per handler */
18
+ interface ClooksEnhancement {
19
+ filter?: string;
20
+ project?: string;
21
+ agent?: string;
22
+ async?: boolean;
23
+ depends?: string[];
24
+ sessionIsolation?: boolean;
25
+ batchGroup?: string;
26
+ type?: 'llm';
27
+ model?: string;
28
+ prompt?: string;
29
+ maxTokens?: number;
30
+ temperature?: number;
31
+ }
32
+ /**
33
+ * Discover all installed Claude Code plugins that have hooks.
34
+ */
35
+ export declare function discoverCCPlugins(ccPluginsDir?: string): CCPlugin[];
36
+ /**
37
+ * Convert discovered CC plugins into clooks manifest handler entries.
38
+ * Applies clooks.yaml enhancements if present.
39
+ * Handler IDs namespaced as "pluginName/derivedId".
40
+ */
41
+ export declare function convertPluginsToHandlers(plugins: CCPlugin[]): Partial<Record<HookEvent, HandlerConfig[]>>;
42
+ /**
43
+ * Import all Claude Code plugins into clooks.
44
+ * Returns the discovered plugins and generated handlers.
45
+ */
46
+ export declare function importPlugins(ccPluginsDir?: string): {
47
+ plugins: CCPlugin[];
48
+ handlers: Partial<Record<HookEvent, HandlerConfig[]>>;
49
+ };
50
+ export {};
@@ -0,0 +1,196 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.discoverCCPlugins = discoverCCPlugins;
4
+ exports.convertPluginsToHandlers = convertPluginsToHandlers;
5
+ exports.importPlugins = importPlugins;
6
+ const fs_1 = require("fs");
7
+ const path_1 = require("path");
8
+ const os_1 = require("os");
9
+ const yaml_1 = require("yaml");
10
+ const constants_js_1 = require("./constants.js");
11
+ /** Path to Claude Code's plugin cache */
12
+ const CC_PLUGINS_DIR = (0, path_1.join)((0, os_1.homedir)(), '.claude', 'plugins', 'cache');
13
+ /**
14
+ * Discover all installed Claude Code plugins that have hooks.
15
+ */
16
+ function discoverCCPlugins(ccPluginsDir) {
17
+ const dir = ccPluginsDir ?? CC_PLUGINS_DIR;
18
+ if (!(0, fs_1.existsSync)(dir))
19
+ return [];
20
+ const results = [];
21
+ for (const entry of (0, fs_1.readdirSync)(dir, { withFileTypes: true })) {
22
+ if (!entry.isDirectory())
23
+ continue;
24
+ const pluginDir = (0, path_1.join)(dir, entry.name);
25
+ // Read plugin.json
26
+ const pluginJsonPath = (0, path_1.join)(pluginDir, '.claude-plugin', 'plugin.json');
27
+ if (!(0, fs_1.existsSync)(pluginJsonPath))
28
+ continue;
29
+ let pluginJson;
30
+ try {
31
+ pluginJson = JSON.parse((0, fs_1.readFileSync)(pluginJsonPath, 'utf-8'));
32
+ }
33
+ catch {
34
+ continue;
35
+ }
36
+ // Read hooks
37
+ const hooksJsonPath = (0, path_1.join)(pluginDir, 'hooks', 'hooks.json');
38
+ let hooks = {};
39
+ if ((0, fs_1.existsSync)(hooksJsonPath)) {
40
+ try {
41
+ hooks = JSON.parse((0, fs_1.readFileSync)(hooksJsonPath, 'utf-8'));
42
+ }
43
+ catch { /* skip */ }
44
+ }
45
+ // Also check if hooks are inline in plugin.json
46
+ if (pluginJson.hooks && typeof pluginJson.hooks === 'string') {
47
+ const inlineHooksPath = (0, path_1.join)(pluginDir, pluginJson.hooks);
48
+ if ((0, fs_1.existsSync)(inlineHooksPath)) {
49
+ try {
50
+ hooks = JSON.parse((0, fs_1.readFileSync)(inlineHooksPath, 'utf-8'));
51
+ }
52
+ catch { /* skip */ }
53
+ }
54
+ }
55
+ if (!hooks || Object.keys(hooks).length === 0)
56
+ continue;
57
+ // Read optional clooks.yaml enhancement
58
+ let enhancements = undefined;
59
+ const clooksYamlPath = (0, path_1.join)(pluginDir, 'clooks.yaml');
60
+ if ((0, fs_1.existsSync)(clooksYamlPath)) {
61
+ try {
62
+ enhancements = (0, yaml_1.parse)((0, fs_1.readFileSync)(clooksYamlPath, 'utf-8'));
63
+ }
64
+ catch { /* skip */ }
65
+ }
66
+ results.push({
67
+ name: pluginJson.name || entry.name,
68
+ version: pluginJson.version || '0.0.0',
69
+ path: pluginDir,
70
+ hooks: parseHooks(hooks),
71
+ clooksEnhancements: enhancements?.handlers,
72
+ });
73
+ }
74
+ return results;
75
+ }
76
+ /**
77
+ * Parse Claude Code hooks.json format into flat event->hooks map.
78
+ * CC format: { "PostToolUse": [{ "matcher": "...", "hooks": [{ "type": "command", ... }] }] }
79
+ */
80
+ function parseHooks(hooks) {
81
+ const result = {};
82
+ for (const [event, ruleGroups] of Object.entries(hooks)) {
83
+ if (!constants_js_1.HOOK_EVENTS.includes(event) || !Array.isArray(ruleGroups))
84
+ continue;
85
+ const entries = [];
86
+ for (const rule of ruleGroups) {
87
+ if (Array.isArray(rule.hooks)) {
88
+ for (const hook of rule.hooks) {
89
+ entries.push({ ...hook, matcher: rule.matcher });
90
+ }
91
+ }
92
+ }
93
+ if (entries.length > 0) {
94
+ result[event] = entries;
95
+ }
96
+ }
97
+ return result;
98
+ }
99
+ /**
100
+ * Convert discovered CC plugins into clooks manifest handler entries.
101
+ * Applies clooks.yaml enhancements if present.
102
+ * Handler IDs namespaced as "pluginName/derivedId".
103
+ */
104
+ function convertPluginsToHandlers(plugins) {
105
+ const handlers = {};
106
+ for (const plugin of plugins) {
107
+ for (const [event, hookEntries] of Object.entries(plugin.hooks)) {
108
+ const hookEvent = event;
109
+ if (!handlers[hookEvent])
110
+ handlers[hookEvent] = [];
111
+ for (let i = 0; i < hookEntries.length; i++) {
112
+ const hook = hookEntries[i];
113
+ if (hook.type !== 'command' || !hook.command)
114
+ continue; // Only import command hooks
115
+ // Derive handler ID from command
116
+ const baseId = deriveId(hook.command, hookEvent, i);
117
+ const id = `${plugin.name}/${baseId}`;
118
+ // Build base handler config
119
+ const handler = {
120
+ id,
121
+ type: 'script',
122
+ command: resolvePluginVars(hook.command, plugin.path),
123
+ timeout: hook.timeout ? hook.timeout * 1000 : 5000,
124
+ enabled: true,
125
+ };
126
+ // Apply clooks.yaml enhancements if present
127
+ const enhancement = plugin.clooksEnhancements?.[baseId];
128
+ if (enhancement) {
129
+ applyEnhancement(handler, enhancement, plugin.name);
130
+ }
131
+ handlers[hookEvent].push(handler);
132
+ }
133
+ }
134
+ }
135
+ return handlers;
136
+ }
137
+ /** Apply clooks.yaml enhancement to a handler */
138
+ function applyEnhancement(handler, enhancement, pluginName) {
139
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic property assignment from enhancement overlay
140
+ const h = handler;
141
+ if (enhancement.filter)
142
+ h.filter = enhancement.filter;
143
+ if (enhancement.project)
144
+ h.project = enhancement.project;
145
+ if (enhancement.agent)
146
+ h.agent = enhancement.agent;
147
+ if (enhancement.async !== undefined)
148
+ h.async = enhancement.async;
149
+ if (enhancement.depends) {
150
+ h.depends = enhancement.depends.map(d => d.includes('/') ? d : `${pluginName}/${d}`);
151
+ }
152
+ if (enhancement.sessionIsolation)
153
+ h.sessionIsolation = enhancement.sessionIsolation;
154
+ // Allow converting to LLM handler
155
+ if (enhancement.type === 'llm' && enhancement.model && enhancement.prompt) {
156
+ h.type = 'llm';
157
+ h.model = enhancement.model;
158
+ h.prompt = enhancement.prompt;
159
+ delete h.command;
160
+ if (enhancement.batchGroup)
161
+ h.batchGroup = enhancement.batchGroup;
162
+ if (enhancement.maxTokens)
163
+ h.maxTokens = enhancement.maxTokens;
164
+ if (enhancement.temperature)
165
+ h.temperature = enhancement.temperature;
166
+ }
167
+ }
168
+ /** Resolve ${CLAUDE_PLUGIN_ROOT} and similar vars in commands */
169
+ function resolvePluginVars(command, pluginPath) {
170
+ return command
171
+ .replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, pluginPath)
172
+ .replace(/\$CLAUDE_PLUGIN_ROOT/g, pluginPath);
173
+ }
174
+ /** Derive a handler ID from command */
175
+ function deriveId(command, event, index) {
176
+ // Extract script filename
177
+ const jsMatch = command.match(/[\\/]([^\\/]+)\.(?:js|ts|py|sh)(?:\s|"|'|$)/);
178
+ if (jsMatch) {
179
+ return jsMatch[1].toLowerCase().replace(/[^a-z0-9-]/g, '-');
180
+ }
181
+ const moduleMatch = command.match(/python3?\s+-m\s+([\w.]+)/);
182
+ if (moduleMatch) {
183
+ const parts = moduleMatch[1].split('.');
184
+ return parts.slice(-2).join('-').toLowerCase();
185
+ }
186
+ return `${event.toLowerCase()}-${index}`;
187
+ }
188
+ /**
189
+ * Import all Claude Code plugins into clooks.
190
+ * Returns the discovered plugins and generated handlers.
191
+ */
192
+ function importPlugins(ccPluginsDir) {
193
+ const plugins = discoverCCPlugins(ccPluginsDir);
194
+ const handlers = convertPluginsToHandlers(plugins);
195
+ return { plugins, handlers };
196
+ }
package/dist/migrate.js CHANGED
@@ -10,6 +10,7 @@ const path_1 = require("path");
10
10
  const os_1 = require("os");
11
11
  const constants_js_1 = require("./constants.js");
12
12
  const builtin_hooks_js_1 = require("./builtin-hooks.js");
13
+ const import_plugins_js_1 = require("./import-plugins.js");
13
14
  const yaml_1 = require("yaml");
14
15
  /**
15
16
  * Find the Claude Code settings.json path.
@@ -154,6 +155,14 @@ function migrate(options) {
154
155
  timeout: 6000,
155
156
  enabled: true,
156
157
  });
158
+ // Import Claude Code plugin hooks
159
+ const { handlers: ccHandlers } = (0, import_plugins_js_1.importPlugins)();
160
+ for (const [event, eventHandlers] of Object.entries(ccHandlers)) {
161
+ const hookEvent = event;
162
+ if (!manifestHandlers[hookEvent])
163
+ manifestHandlers[hookEvent] = [];
164
+ manifestHandlers[hookEvent].push(...eventHandlers);
165
+ }
157
166
  // Write manifest.yaml
158
167
  const manifest = {
159
168
  handlers: manifestHandlers,
package/dist/server.d.ts CHANGED
@@ -33,8 +33,18 @@ export declare function startDaemon(manifest: Manifest, metrics: MetricsCollecto
33
33
  }): Promise<ServerContext>;
34
34
  /**
35
35
  * Stop a running daemon by reading PID file and sending SIGTERM.
36
+ * If no PID file exists, tries to recover PID from the health endpoint.
36
37
  */
37
38
  export declare function stopDaemon(): boolean;
39
+ /**
40
+ * Async version of stopDaemon that can recover orphaned daemons via /health.
41
+ * Returns { stopped: boolean, pid?: number, recovered?: boolean }.
42
+ */
43
+ export declare function stopDaemonAsync(): Promise<{
44
+ stopped: boolean;
45
+ pid?: number;
46
+ recovered?: boolean;
47
+ }>;
38
48
  /**
39
49
  * Check if daemon is currently running (PID check only).
40
50
  * Use for stop/status where a quick check is fine.
@@ -46,6 +56,18 @@ export declare function isDaemonRunning(): boolean;
46
56
  * Use for ensure-running and start where correctness matters.
47
57
  */
48
58
  export declare function isDaemonHealthy(): Promise<boolean>;
59
+ /**
60
+ * Probe the health endpoint on the daemon port.
61
+ * Returns the parsed health response (including pid) or null if unreachable.
62
+ */
63
+ export declare function probeHealth(port?: number): Promise<{
64
+ status: string;
65
+ pid: number;
66
+ } | null>;
67
+ /**
68
+ * Write a PID to the daemon PID file, creating the config dir if needed.
69
+ */
70
+ export declare function writePidFile(pid: number): void;
49
71
  /**
50
72
  * Clean up a stale daemon: remove PID file and attempt to kill the process.
51
73
  * Returns the stale PID for logging purposes.
package/dist/server.js CHANGED
@@ -5,8 +5,11 @@ exports.sessionAgents = void 0;
5
5
  exports.createServer = createServer;
6
6
  exports.startDaemon = startDaemon;
7
7
  exports.stopDaemon = stopDaemon;
8
+ exports.stopDaemonAsync = stopDaemonAsync;
8
9
  exports.isDaemonRunning = isDaemonRunning;
9
10
  exports.isDaemonHealthy = isDaemonHealthy;
11
+ exports.probeHealth = probeHealth;
12
+ exports.writePidFile = writePidFile;
10
13
  exports.cleanupStaleDaemon = cleanupStaleDaemon;
11
14
  exports.startDaemonBackground = startDaemonBackground;
12
15
  const http_1 = require("http");
@@ -123,9 +126,9 @@ function createServer(manifest, metrics) {
123
126
  const server = (0, http_1.createServer)(async (req, res) => {
124
127
  const url = req.url ?? '/';
125
128
  const method = req.method ?? 'GET';
126
- // Public health endpoint — minimal, no auth
129
+ // Public health endpoint — includes PID for orphan recovery
127
130
  if (method === 'GET' && url === '/health') {
128
- sendJson(res, 200, { status: 'ok' });
131
+ sendJson(res, 200, { status: 'ok', pid: process.pid });
129
132
  return;
130
133
  }
131
134
  // Detailed health endpoint — authenticated if authToken configured
@@ -310,10 +313,23 @@ function startDaemon(manifest, metrics, options) {
310
313
  return new Promise((resolve, reject) => {
311
314
  const ctx = createServer(manifest, metrics);
312
315
  const port = manifest.settings?.port ?? constants_js_1.DEFAULT_PORT;
313
- ctx.server.on('error', (err) => {
316
+ ctx.server.on('error', async (err) => {
314
317
  if (err.code === 'EADDRINUSE') {
315
- log(`Port ${port} already in use`);
316
- reject(new Error(`Port ${port} is already in use. Is another clooks instance running?`));
318
+ log(`Port ${port} already in use — attempting orphan recovery`);
319
+ // Try to recover: if a clooks daemon is already on this port, re-adopt it
320
+ try {
321
+ const health = await probeHealth(port);
322
+ if (health && health.pid) {
323
+ writePidFile(health.pid);
324
+ log(`Re-adopted orphaned daemon (pid ${health.pid})`);
325
+ resolve(ctx);
326
+ return;
327
+ }
328
+ }
329
+ catch {
330
+ // Probe failed — port is in use by something else
331
+ }
332
+ reject(new Error(`Port ${port} is already in use. Run 'clooks stop' to stop the existing daemon, or 'clooks status' to check.`));
317
333
  }
318
334
  else {
319
335
  log(`Server error: ${err.message}`);
@@ -425,15 +441,21 @@ function startDaemon(manifest, metrics, options) {
425
441
  }
426
442
  /**
427
443
  * Stop a running daemon by reading PID file and sending SIGTERM.
444
+ * If no PID file exists, tries to recover PID from the health endpoint.
428
445
  */
429
446
  function stopDaemon() {
430
- if (!(0, fs_1.existsSync)(constants_js_1.PID_FILE)) {
431
- return false;
447
+ let pid = null;
448
+ if ((0, fs_1.existsSync)(constants_js_1.PID_FILE)) {
449
+ const pidStr = (0, fs_1.readFileSync)(constants_js_1.PID_FILE, 'utf-8').trim();
450
+ pid = parseInt(pidStr, 10);
451
+ if (isNaN(pid)) {
452
+ (0, fs_1.unlinkSync)(constants_js_1.PID_FILE);
453
+ pid = null;
454
+ }
432
455
  }
433
- const pidStr = (0, fs_1.readFileSync)(constants_js_1.PID_FILE, 'utf-8').trim();
434
- const pid = parseInt(pidStr, 10);
435
- if (isNaN(pid)) {
436
- (0, fs_1.unlinkSync)(constants_js_1.PID_FILE);
456
+ // If no PID from file, try synchronous recovery via health endpoint
457
+ // We can't await here (sync function), so use stopDaemonAsync for full recovery
458
+ if (pid === null) {
437
459
  return false;
438
460
  }
439
461
  try {
@@ -462,6 +484,57 @@ function stopDaemon() {
462
484
  }, 2000);
463
485
  return true;
464
486
  }
487
+ /**
488
+ * Async version of stopDaemon that can recover orphaned daemons via /health.
489
+ * Returns { stopped: boolean, pid?: number, recovered?: boolean }.
490
+ */
491
+ async function stopDaemonAsync() {
492
+ let pid = null;
493
+ if ((0, fs_1.existsSync)(constants_js_1.PID_FILE)) {
494
+ const pidStr = (0, fs_1.readFileSync)(constants_js_1.PID_FILE, 'utf-8').trim();
495
+ pid = parseInt(pidStr, 10);
496
+ if (isNaN(pid)) {
497
+ (0, fs_1.unlinkSync)(constants_js_1.PID_FILE);
498
+ pid = null;
499
+ }
500
+ }
501
+ // No PID file — try health endpoint recovery
502
+ if (pid === null) {
503
+ const health = await probeHealth();
504
+ if (health && health.pid) {
505
+ pid = health.pid;
506
+ log(`Recovered orphaned daemon PID ${pid} from /health for stop`);
507
+ }
508
+ }
509
+ if (pid === null) {
510
+ return { stopped: false };
511
+ }
512
+ const recovered = !(0, fs_1.existsSync)(constants_js_1.PID_FILE);
513
+ try {
514
+ process.kill(pid, 'SIGTERM');
515
+ }
516
+ catch {
517
+ try {
518
+ if ((0, fs_1.existsSync)(constants_js_1.PID_FILE))
519
+ (0, fs_1.unlinkSync)(constants_js_1.PID_FILE);
520
+ }
521
+ catch {
522
+ // ignore
523
+ }
524
+ return { stopped: false };
525
+ }
526
+ // Clean up PID file after a moment
527
+ setTimeout(() => {
528
+ try {
529
+ if ((0, fs_1.existsSync)(constants_js_1.PID_FILE))
530
+ (0, fs_1.unlinkSync)(constants_js_1.PID_FILE);
531
+ }
532
+ catch {
533
+ // ignore
534
+ }
535
+ }, 2000);
536
+ return { stopped: true, pid, recovered };
537
+ }
465
538
  /**
466
539
  * Check if daemon is currently running (PID check only).
467
540
  * Use for stop/status where a quick check is fine.
@@ -521,6 +594,42 @@ async function isDaemonHealthy() {
521
594
  return false;
522
595
  }
523
596
  }
597
+ /**
598
+ * Probe the health endpoint on the daemon port.
599
+ * Returns the parsed health response (including pid) or null if unreachable.
600
+ */
601
+ async function probeHealth(port) {
602
+ const p = port ?? constants_js_1.DEFAULT_PORT;
603
+ try {
604
+ const { get } = await import('http');
605
+ const data = await new Promise((resolve, reject) => {
606
+ const req = get(`http://127.0.0.1:${p}/health`, (res) => {
607
+ let body = '';
608
+ res.on('data', (chunk) => { body += chunk.toString(); });
609
+ res.on('end', () => resolve(body));
610
+ });
611
+ req.on('error', reject);
612
+ req.setTimeout(2000, () => { req.destroy(); reject(new Error('timeout')); });
613
+ });
614
+ const health = JSON.parse(data);
615
+ if (health.status === 'ok' && typeof health.pid === 'number') {
616
+ return health;
617
+ }
618
+ return null;
619
+ }
620
+ catch {
621
+ return null;
622
+ }
623
+ }
624
+ /**
625
+ * Write a PID to the daemon PID file, creating the config dir if needed.
626
+ */
627
+ function writePidFile(pid) {
628
+ if (!(0, fs_1.existsSync)(constants_js_1.CONFIG_DIR)) {
629
+ (0, fs_1.mkdirSync)(constants_js_1.CONFIG_DIR, { recursive: true });
630
+ }
631
+ (0, fs_1.writeFileSync)(constants_js_1.PID_FILE, String(pid), 'utf-8');
632
+ }
524
633
  /**
525
634
  * Clean up a stale daemon: remove PID file and attempt to kill the process.
526
635
  * Returns the stale PID for logging purposes.
package/dist/sync.js CHANGED
@@ -6,6 +6,7 @@ const fs_1 = require("fs");
6
6
  const path_1 = require("path");
7
7
  const os_1 = require("os");
8
8
  const manifest_js_1 = require("./manifest.js");
9
+ const import_plugins_js_1 = require("./import-plugins.js");
9
10
  const constants_js_1 = require("./constants.js");
10
11
  /**
11
12
  * Find the Claude Code settings.json path.
@@ -42,6 +43,20 @@ function syncSettings(options) {
42
43
  }
43
44
  else {
44
45
  manifest = (0, manifest_js_1.loadCompositeManifest)();
46
+ // Also merge in Claude Code plugin hooks
47
+ const { handlers: ccHandlers } = (0, import_plugins_js_1.importPlugins)();
48
+ for (const [event, eventHandlers] of Object.entries(ccHandlers)) {
49
+ const hookEvent = event;
50
+ if (!manifest.handlers[hookEvent])
51
+ manifest.handlers[hookEvent] = [];
52
+ // Avoid duplicates: only add handlers whose IDs are not already present
53
+ const existingIds = new Set(manifest.handlers[hookEvent].map(h => h.id));
54
+ for (const handler of eventHandlers) {
55
+ if (!existingIds.has(handler.id)) {
56
+ manifest.handlers[hookEvent].push(handler);
57
+ }
58
+ }
59
+ }
45
60
  }
46
61
  const port = manifest.settings?.port ?? constants_js_1.DEFAULT_PORT;
47
62
  const authToken = manifest.settings?.authToken;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mauribadnights/clooks",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Persistent hook runtime for Claude Code — eliminates process spawning overhead and gives you observability",
5
5
  "bin": {
6
6
  "clooks": "./dist/cli.js"