@mauribadnights/clooks 0.5.0 → 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
@@ -22,7 +22,7 @@ const program = new commander_1.Command();
22
22
  program
23
23
  .name('clooks')
24
24
  .description('Persistent hook runtime for Claude Code')
25
- .version('0.5.0');
25
+ .version('0.5.1');
26
26
  // --- start ---
27
27
  program
28
28
  .command('start')
@@ -32,11 +32,13 @@ program
32
32
  .action(async (opts) => {
33
33
  const noWatch = opts.watch === false;
34
34
  if (!opts.foreground) {
35
- // 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)
36
37
  if ((0, server_js_1.isDaemonRunning)()) {
37
38
  const healthy = await (0, server_js_1.isDaemonHealthy)();
38
39
  if (healthy) {
39
- 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}).`);
40
42
  process.exit(0);
41
43
  }
42
44
  // PID alive but daemon unhealthy — stale process after sleep/lid-close
@@ -45,6 +47,16 @@ program
45
47
  console.log(`Cleaned up stale daemon (pid ${stalePid}), starting fresh`);
46
48
  }
47
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
+ }
48
60
  // Ensure config dir exists
49
61
  if (!(0, fs_1.existsSync)(constants_js_1.CONFIG_DIR)) {
50
62
  (0, fs_1.mkdirSync)(constants_js_1.CONFIG_DIR, { recursive: true });
@@ -63,7 +75,15 @@ program
63
75
  console.log(`Daemon started (pid ${pid}), listening on 127.0.0.1:${constants_js_1.DEFAULT_PORT}`);
64
76
  }
65
77
  else {
66
- 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
+ }
67
87
  }
68
88
  process.exit(0);
69
89
  }
@@ -86,12 +106,24 @@ program
86
106
  program
87
107
  .command('stop')
88
108
  .description('Stop the clooks daemon')
89
- .action(() => {
109
+ .action(async () => {
110
+ // Try sync stop first (fast path with PID file)
90
111
  if ((0, server_js_1.stopDaemon)()) {
91
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
+ }
92
124
  }
93
125
  else {
94
- console.log('Daemon is not running (no PID file or process not found).');
126
+ console.log('Daemon is not running.');
95
127
  }
96
128
  });
97
129
  // --- status ---
@@ -100,18 +132,28 @@ program
100
132
  .description('Show daemon status')
101
133
  .action(async () => {
102
134
  const running = (0, server_js_1.isDaemonRunning)();
135
+ const serviceStatus = (0, service_js_1.getServiceStatus)();
103
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
+ }
104
148
  console.log('Status: stopped');
105
149
  return;
106
150
  }
107
151
  const pid = (0, fs_1.existsSync)(constants_js_1.PID_FILE) ? (0, fs_1.readFileSync)(constants_js_1.PID_FILE, 'utf-8').trim() : '?';
108
- // Try to hit health endpoint
109
- // Service status
110
- const serviceStatus = (0, service_js_1.getServiceStatus)();
152
+ // Try to hit health endpoint for detailed info
111
153
  try {
112
154
  const { get } = await import('http');
113
155
  const data = await new Promise((resolve, reject) => {
114
- 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) => {
115
157
  let body = '';
116
158
  res.on('data', (chunk) => { body += chunk.toString(); });
117
159
  res.on('end', () => resolve(body));
@@ -333,6 +375,15 @@ program
333
375
  }
334
376
  }
335
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
+ }
336
387
  // Ensure config dir exists
337
388
  if (!(0, fs_1.existsSync)(constants_js_1.CONFIG_DIR)) {
338
389
  (0, fs_1.mkdirSync)(constants_js_1.CONFIG_DIR, { recursive: true });
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mauribadnights/clooks",
3
- "version": "0.5.0",
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"