@mauribadnights/clooks 0.3.1 → 0.4.0
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/agents/clooks.md +146 -0
- package/dist/agent.d.ts +9 -0
- package/dist/agent.js +43 -0
- package/dist/cli.js +156 -16
- package/dist/doctor.js +20 -0
- package/dist/handlers.d.ts +7 -1
- package/dist/handlers.js +105 -3
- package/dist/index.d.ts +5 -0
- package/dist/index.js +12 -2
- package/dist/manifest.js +33 -0
- package/dist/ratelimit.d.ts +7 -2
- package/dist/ratelimit.js +25 -4
- package/dist/server.d.ts +21 -1
- package/dist/server.js +136 -12
- package/dist/service.d.ts +27 -0
- package/dist/service.js +242 -0
- package/dist/sync.d.ts +13 -0
- package/dist/sync.js +153 -0
- package/dist/tui.d.ts +1 -0
- package/dist/tui.js +560 -0
- package/dist/types.d.ts +10 -0
- package/package.json +4 -1
package/dist/index.d.ts
CHANGED
|
@@ -11,7 +11,12 @@ export { DenyCache } from './shortcircuit.js';
|
|
|
11
11
|
export { RateLimiter } from './ratelimit.js';
|
|
12
12
|
export { startWatcher, stopWatcher } from './watcher.js';
|
|
13
13
|
export { generateAuthToken, validateAuth, rotateToken } from './auth.js';
|
|
14
|
+
export { syncSettings } from './sync.js';
|
|
15
|
+
export { installService, uninstallService, isServiceInstalled, getServiceStatus } from './service.js';
|
|
16
|
+
export type { ServiceStatus } from './service.js';
|
|
17
|
+
export type { SyncOptions } from './sync.js';
|
|
14
18
|
export type { RotateTokenOptions } from './auth.js';
|
|
19
|
+
export { installAgent, isAgentInstalled } from './agent.js';
|
|
15
20
|
export { evaluateFilter } from './filter.js';
|
|
16
21
|
export { executeLLMHandler, executeLLMHandlersBatched, calculateCost, resetClient } from './llm.js';
|
|
17
22
|
export { prefetchContext, renderPromptTemplate } from './prefetch.js';
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
// clooks — public API exports
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
-
exports.
|
|
5
|
-
exports.PLUGIN_MANIFEST_NAME = exports.PLUGIN_REGISTRY = void 0;
|
|
4
|
+
exports.PID_FILE = exports.MANIFEST_PATH = exports.CONFIG_DIR = exports.DEFAULT_PORT = exports.renderPromptTemplate = exports.prefetchContext = exports.resetClient = exports.calculateCost = exports.executeLLMHandlersBatched = exports.executeLLMHandler = exports.evaluateFilter = exports.isAgentInstalled = exports.installAgent = exports.getServiceStatus = exports.isServiceInstalled = exports.uninstallService = exports.installService = exports.syncSettings = exports.rotateToken = exports.validateAuth = exports.generateAuthToken = exports.stopWatcher = exports.startWatcher = exports.RateLimiter = exports.DenyCache = exports.resolveExecutionOrder = exports.cleanupHandlerState = exports.resetSessionIsolatedHandlers = exports.executeHandlers = exports.runDoctor = exports.getSettingsPath = exports.restore = exports.migrate = exports.MetricsCollector = exports.listPlugins = exports.uninstallPlugin = exports.installPlugin = exports.saveRegistry = exports.loadRegistry = exports.validatePluginManifest = exports.mergeManifests = exports.loadPlugins = exports.createDefaultManifest = exports.validateManifest = exports.loadCompositeManifest = exports.loadManifest = exports.isDaemonRunning = exports.stopDaemon = exports.startDaemon = exports.createServer = void 0;
|
|
5
|
+
exports.PLUGIN_MANIFEST_NAME = exports.PLUGIN_REGISTRY = exports.PLUGINS_DIR = exports.LLM_PRICING = exports.DEFAULT_LLM_MAX_TOKENS = exports.DEFAULT_LLM_TIMEOUT = exports.COSTS_FILE = exports.LOG_FILE = exports.METRICS_FILE = void 0;
|
|
6
6
|
var server_js_1 = require("./server.js");
|
|
7
7
|
Object.defineProperty(exports, "createServer", { enumerable: true, get: function () { return server_js_1.createServer; } });
|
|
8
8
|
Object.defineProperty(exports, "startDaemon", { enumerable: true, get: function () { return server_js_1.startDaemon; } });
|
|
@@ -47,6 +47,16 @@ var auth_js_1 = require("./auth.js");
|
|
|
47
47
|
Object.defineProperty(exports, "generateAuthToken", { enumerable: true, get: function () { return auth_js_1.generateAuthToken; } });
|
|
48
48
|
Object.defineProperty(exports, "validateAuth", { enumerable: true, get: function () { return auth_js_1.validateAuth; } });
|
|
49
49
|
Object.defineProperty(exports, "rotateToken", { enumerable: true, get: function () { return auth_js_1.rotateToken; } });
|
|
50
|
+
var sync_js_1 = require("./sync.js");
|
|
51
|
+
Object.defineProperty(exports, "syncSettings", { enumerable: true, get: function () { return sync_js_1.syncSettings; } });
|
|
52
|
+
var service_js_1 = require("./service.js");
|
|
53
|
+
Object.defineProperty(exports, "installService", { enumerable: true, get: function () { return service_js_1.installService; } });
|
|
54
|
+
Object.defineProperty(exports, "uninstallService", { enumerable: true, get: function () { return service_js_1.uninstallService; } });
|
|
55
|
+
Object.defineProperty(exports, "isServiceInstalled", { enumerable: true, get: function () { return service_js_1.isServiceInstalled; } });
|
|
56
|
+
Object.defineProperty(exports, "getServiceStatus", { enumerable: true, get: function () { return service_js_1.getServiceStatus; } });
|
|
57
|
+
var agent_js_1 = require("./agent.js");
|
|
58
|
+
Object.defineProperty(exports, "installAgent", { enumerable: true, get: function () { return agent_js_1.installAgent; } });
|
|
59
|
+
Object.defineProperty(exports, "isAgentInstalled", { enumerable: true, get: function () { return agent_js_1.isAgentInstalled; } });
|
|
50
60
|
var filter_js_1 = require("./filter.js");
|
|
51
61
|
Object.defineProperty(exports, "evaluateFilter", { enumerable: true, get: function () { return filter_js_1.evaluateFilter; } });
|
|
52
62
|
var llm_js_1 = require("./llm.js");
|
package/dist/manifest.js
CHANGED
|
@@ -74,6 +74,39 @@ function validateManifest(manifest) {
|
|
|
74
74
|
throw new Error(`LLM handler "${handler.id}" model must be one of: ${validModels.join(', ')}`);
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
|
+
// Validate async field type
|
|
78
|
+
if ('async' in handler && typeof handler.async !== 'boolean') {
|
|
79
|
+
throw new Error(`Handler "${handler.id}" async field must be a boolean`);
|
|
80
|
+
}
|
|
81
|
+
// Validate agent field type
|
|
82
|
+
if ('agent' in handler && typeof handler.agent !== 'string') {
|
|
83
|
+
throw new Error(`Handler "${handler.id}" agent field must be a string`);
|
|
84
|
+
}
|
|
85
|
+
// Validate project field type
|
|
86
|
+
if ('project' in handler && typeof handler.project !== 'string') {
|
|
87
|
+
throw new Error(`Handler "${handler.id}" project field must be a string`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Warn about async handlers with dependency relationships
|
|
91
|
+
const eventHandlerIds = new Set(handlers.map(h => h.id));
|
|
92
|
+
const dependedUponIds = new Set();
|
|
93
|
+
for (const h of handlers) {
|
|
94
|
+
if (h.depends) {
|
|
95
|
+
for (const dep of h.depends) {
|
|
96
|
+
if (eventHandlerIds.has(dep))
|
|
97
|
+
dependedUponIds.add(dep);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (const h of handlers) {
|
|
102
|
+
if (h.async) {
|
|
103
|
+
if (dependedUponIds.has(h.id)) {
|
|
104
|
+
console.warn(`[clooks] Warning: async handler "${h.id}" has dependents — will run synchronously at runtime`);
|
|
105
|
+
}
|
|
106
|
+
if (h.depends?.some(d => eventHandlerIds.has(d))) {
|
|
107
|
+
console.warn(`[clooks] Warning: async handler "${h.id}" has dependencies — will run synchronously at runtime`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
77
110
|
}
|
|
78
111
|
}
|
|
79
112
|
// Validate prefetch if present
|
package/dist/ratelimit.d.ts
CHANGED
|
@@ -5,8 +5,13 @@ export declare class RateLimiter {
|
|
|
5
5
|
constructor(maxAttempts?: number, windowMs?: number);
|
|
6
6
|
/** Check if source is rate-limited. Returns true if allowed. */
|
|
7
7
|
check(source: string): boolean;
|
|
8
|
-
/** Record an
|
|
9
|
-
|
|
8
|
+
/** Record an auth failure from source. */
|
|
9
|
+
recordFailure(source: string): void;
|
|
10
|
+
/**
|
|
11
|
+
* How many seconds until the rate limit resets for a given source.
|
|
12
|
+
* Returns 0 if the source is not rate-limited.
|
|
13
|
+
*/
|
|
14
|
+
retryAfter(source: string): number;
|
|
10
15
|
/** Clean up old entries. */
|
|
11
16
|
cleanup(): void;
|
|
12
17
|
}
|
package/dist/ratelimit.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
// clooks rate limiting — protect against auth brute-force
|
|
3
|
+
//
|
|
4
|
+
// IMPORTANT: This rate limiter should ONLY be used when auth is configured.
|
|
5
|
+
// It tracks auth failures per source IP. When the limit is exceeded, requests
|
|
6
|
+
// from that source are blocked with 429 until the window expires.
|
|
3
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
8
|
exports.RateLimiter = void 0;
|
|
5
9
|
class RateLimiter {
|
|
6
|
-
attempts = new Map(); // source → timestamps
|
|
10
|
+
attempts = new Map(); // source → auth failure timestamps
|
|
7
11
|
maxAttempts;
|
|
8
12
|
windowMs;
|
|
9
13
|
constructor(maxAttempts = 10, windowMs = 60_000) {
|
|
@@ -16,17 +20,34 @@ class RateLimiter {
|
|
|
16
20
|
const timestamps = this.attempts.get(source);
|
|
17
21
|
if (!timestamps)
|
|
18
22
|
return true;
|
|
19
|
-
// Count recent
|
|
23
|
+
// Count recent auth failures within window
|
|
20
24
|
const recent = timestamps.filter(t => now - t <= this.windowMs);
|
|
21
25
|
return recent.length < this.maxAttempts;
|
|
22
26
|
}
|
|
23
|
-
/** Record an
|
|
24
|
-
|
|
27
|
+
/** Record an auth failure from source. */
|
|
28
|
+
recordFailure(source) {
|
|
25
29
|
const now = Date.now();
|
|
26
30
|
const timestamps = this.attempts.get(source) ?? [];
|
|
27
31
|
timestamps.push(now);
|
|
28
32
|
this.attempts.set(source, timestamps);
|
|
29
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* How many seconds until the rate limit resets for a given source.
|
|
36
|
+
* Returns 0 if the source is not rate-limited.
|
|
37
|
+
*/
|
|
38
|
+
retryAfter(source) {
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
const timestamps = this.attempts.get(source);
|
|
41
|
+
if (!timestamps)
|
|
42
|
+
return 0;
|
|
43
|
+
const recent = timestamps.filter(t => now - t <= this.windowMs);
|
|
44
|
+
if (recent.length < this.maxAttempts)
|
|
45
|
+
return 0;
|
|
46
|
+
// The oldest recent attempt determines when the window expires
|
|
47
|
+
const oldest = Math.min(...recent);
|
|
48
|
+
const expiresAt = oldest + this.windowMs;
|
|
49
|
+
return Math.ceil((expiresAt - now) / 1000);
|
|
50
|
+
}
|
|
30
51
|
/** Clean up old entries. */
|
|
31
52
|
cleanup() {
|
|
32
53
|
const now = Date.now();
|
package/dist/server.d.ts
CHANGED
|
@@ -4,6 +4,13 @@ import { MetricsCollector } from './metrics.js';
|
|
|
4
4
|
import { DenyCache } from './shortcircuit.js';
|
|
5
5
|
import { RateLimiter } from './ratelimit.js';
|
|
6
6
|
import type { Manifest } from './types.js';
|
|
7
|
+
/** Session agent cache: session_id → { agent_type, timestamp } */
|
|
8
|
+
declare const sessionAgents: Map<string, {
|
|
9
|
+
agent: string;
|
|
10
|
+
ts: number;
|
|
11
|
+
}>;
|
|
12
|
+
/** Exported for testing */
|
|
13
|
+
export { sessionAgents };
|
|
7
14
|
export interface ServerContext {
|
|
8
15
|
server: Server;
|
|
9
16
|
metrics: MetricsCollector;
|
|
@@ -29,11 +36,24 @@ export declare function startDaemon(manifest: Manifest, metrics: MetricsCollecto
|
|
|
29
36
|
*/
|
|
30
37
|
export declare function stopDaemon(): boolean;
|
|
31
38
|
/**
|
|
32
|
-
* Check if daemon is currently running.
|
|
39
|
+
* Check if daemon is currently running (PID check only).
|
|
40
|
+
* Use for stop/status where a quick check is fine.
|
|
33
41
|
*/
|
|
34
42
|
export declare function isDaemonRunning(): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Check if daemon is running AND healthy (PID + health endpoint).
|
|
45
|
+
* Defends against stale PIDs reused by macOS after sleep/lid-close.
|
|
46
|
+
* Use for ensure-running and start where correctness matters.
|
|
47
|
+
*/
|
|
48
|
+
export declare function isDaemonHealthy(): Promise<boolean>;
|
|
49
|
+
/**
|
|
50
|
+
* Clean up a stale daemon: remove PID file and attempt to kill the process.
|
|
51
|
+
* Returns the stale PID for logging purposes.
|
|
52
|
+
*/
|
|
53
|
+
export declare function cleanupStaleDaemon(): number | null;
|
|
35
54
|
/**
|
|
36
55
|
* Start daemon as a detached background process.
|
|
56
|
+
* Always removes any existing PID file first — the new daemon writes its own.
|
|
37
57
|
*/
|
|
38
58
|
export declare function startDaemonBackground(options?: {
|
|
39
59
|
noWatch?: boolean;
|
package/dist/server.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
// clooks HTTP server — persistent hook daemon
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.sessionAgents = void 0;
|
|
4
5
|
exports.createServer = createServer;
|
|
5
6
|
exports.startDaemon = startDaemon;
|
|
6
7
|
exports.stopDaemon = stopDaemon;
|
|
7
8
|
exports.isDaemonRunning = isDaemonRunning;
|
|
9
|
+
exports.isDaemonHealthy = isDaemonHealthy;
|
|
10
|
+
exports.cleanupStaleDaemon = cleanupStaleDaemon;
|
|
8
11
|
exports.startDaemonBackground = startDaemonBackground;
|
|
9
12
|
const http_1 = require("http");
|
|
10
13
|
const fs_1 = require("fs");
|
|
@@ -17,6 +20,18 @@ const shortcircuit_js_1 = require("./shortcircuit.js");
|
|
|
17
20
|
const ratelimit_js_1 = require("./ratelimit.js");
|
|
18
21
|
const constants_js_1 = require("./constants.js");
|
|
19
22
|
const manifest_js_1 = require("./manifest.js");
|
|
23
|
+
/** Session agent cache: session_id → { agent_type, timestamp } */
|
|
24
|
+
const sessionAgents = new Map();
|
|
25
|
+
exports.sessionAgents = sessionAgents;
|
|
26
|
+
const SESSION_AGENT_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
|
27
|
+
function cleanupSessionAgents() {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
for (const [id, entry] of sessionAgents) {
|
|
30
|
+
if (now - entry.ts > SESSION_AGENT_TTL) {
|
|
31
|
+
sessionAgents.delete(id);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
20
35
|
function log(msg) {
|
|
21
36
|
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
|
22
37
|
try {
|
|
@@ -99,6 +114,7 @@ function createServer(manifest, metrics) {
|
|
|
99
114
|
ctx.cleanupInterval = setInterval(() => {
|
|
100
115
|
denyCache.cleanup();
|
|
101
116
|
rateLimiter.cleanup();
|
|
117
|
+
cleanupSessionAgents();
|
|
102
118
|
}, 60_000);
|
|
103
119
|
// Unref so it doesn't keep the process alive
|
|
104
120
|
if (ctx.cleanupInterval && typeof ctx.cleanupInterval === 'object' && 'unref' in ctx.cleanupInterval) {
|
|
@@ -131,17 +147,24 @@ function createServer(manifest, metrics) {
|
|
|
131
147
|
});
|
|
132
148
|
return;
|
|
133
149
|
}
|
|
134
|
-
// Auth check for all POST requests
|
|
150
|
+
// Auth check for all POST requests — only when auth token is configured
|
|
135
151
|
if (method === 'POST' && authToken) {
|
|
136
152
|
const source = req.socket.remoteAddress ?? 'unknown';
|
|
137
|
-
// Rate limiting check
|
|
153
|
+
// Rate limiting: check if this source has too many auth failures
|
|
138
154
|
if (!rateLimiter.check(source)) {
|
|
139
|
-
|
|
155
|
+
const retryAfter = rateLimiter.retryAfter(source);
|
|
156
|
+
const body = JSON.stringify({ error: 'Too many auth failures' });
|
|
157
|
+
res.writeHead(429, {
|
|
158
|
+
'Content-Type': 'application/json',
|
|
159
|
+
'Content-Length': Buffer.byteLength(body),
|
|
160
|
+
'Retry-After': String(retryAfter),
|
|
161
|
+
});
|
|
162
|
+
res.end(body);
|
|
140
163
|
return;
|
|
141
164
|
}
|
|
142
165
|
const authHeader = req.headers['authorization'];
|
|
143
166
|
if (!(0, auth_js_1.validateAuth)(authHeader, authToken)) {
|
|
144
|
-
rateLimiter.
|
|
167
|
+
rateLimiter.recordFailure(source);
|
|
145
168
|
log(`Auth failure from ${source}`);
|
|
146
169
|
sendJson(res, 401, { error: 'Unauthorized' });
|
|
147
170
|
return;
|
|
@@ -156,7 +179,7 @@ function createServer(manifest, metrics) {
|
|
|
156
179
|
return;
|
|
157
180
|
}
|
|
158
181
|
const event = eventName;
|
|
159
|
-
// On SessionStart, reset session-isolated handlers
|
|
182
|
+
// On SessionStart, cache agent and reset session-isolated handlers
|
|
160
183
|
if (event === 'SessionStart') {
|
|
161
184
|
const allHandlers = Object.values(ctx.manifest.handlers)
|
|
162
185
|
.flat()
|
|
@@ -178,6 +201,12 @@ function createServer(manifest, metrics) {
|
|
|
178
201
|
sendJson(res, 400, { error: 'Invalid JSON body' });
|
|
179
202
|
return;
|
|
180
203
|
}
|
|
204
|
+
// Cache agent_type on SessionStart
|
|
205
|
+
if (event === 'SessionStart' && input.agent_type && input.session_id) {
|
|
206
|
+
sessionAgents.set(input.session_id, { agent: input.agent_type, ts: Date.now() });
|
|
207
|
+
}
|
|
208
|
+
// Resolve current agent for this session
|
|
209
|
+
const currentAgent = input.session_id ? sessionAgents.get(input.session_id)?.agent : undefined;
|
|
181
210
|
// Short-circuit: skip PostToolUse if PreToolUse denied this tool
|
|
182
211
|
if (event === 'PostToolUse' && input.tool_name && input.session_id) {
|
|
183
212
|
if (denyCache.isDenied(input.session_id, input.tool_name)) {
|
|
@@ -186,16 +215,23 @@ function createServer(manifest, metrics) {
|
|
|
186
215
|
return;
|
|
187
216
|
}
|
|
188
217
|
}
|
|
189
|
-
|
|
218
|
+
const allHandlerConfigs = handlers;
|
|
219
|
+
const syncCount = allHandlerConfigs.filter(h => !h.async).length;
|
|
220
|
+
const asyncCount = allHandlerConfigs.filter(h => h.async).length;
|
|
221
|
+
if (asyncCount > 0) {
|
|
222
|
+
log(`Hook: ${eventName} (${syncCount} sync, ${asyncCount} async handler${syncCount + asyncCount > 1 ? 's' : ''})`);
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
log(`Hook: ${eventName} (${handlers.length} handler${handlers.length > 1 ? 's' : ''})`);
|
|
226
|
+
}
|
|
190
227
|
try {
|
|
191
228
|
// Pre-fetch shared context if configured
|
|
192
229
|
let context;
|
|
193
230
|
if (ctx.manifest.prefetch && ctx.manifest.prefetch.length > 0) {
|
|
194
231
|
context = await (0, prefetch_js_1.prefetchContext)(ctx.manifest.prefetch, input);
|
|
195
232
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
for (const result of results) {
|
|
233
|
+
// Callback for recording async handler metrics when they complete
|
|
234
|
+
const recordResult = (result) => {
|
|
199
235
|
metrics.record({
|
|
200
236
|
ts: new Date().toISOString(),
|
|
201
237
|
event,
|
|
@@ -208,9 +244,8 @@ function createServer(manifest, metrics) {
|
|
|
208
244
|
cost_usd: result.cost_usd,
|
|
209
245
|
session_id: input.session_id,
|
|
210
246
|
});
|
|
211
|
-
// Track cost for LLM handlers
|
|
212
247
|
if (result.usage && result.cost_usd !== undefined && result.cost_usd > 0) {
|
|
213
|
-
const handlerConfig =
|
|
248
|
+
const handlerConfig = allHandlerConfigs.find(h => h.id === result.id);
|
|
214
249
|
if (handlerConfig && handlerConfig.type === 'llm') {
|
|
215
250
|
const llmConfig = handlerConfig;
|
|
216
251
|
metrics.trackCost({
|
|
@@ -224,6 +259,11 @@ function createServer(manifest, metrics) {
|
|
|
224
259
|
});
|
|
225
260
|
}
|
|
226
261
|
}
|
|
262
|
+
};
|
|
263
|
+
const results = await (0, handlers_js_1.executeHandlers)(event, input, allHandlerConfigs, context, recordResult, currentAgent);
|
|
264
|
+
// Record metrics and costs for sync results
|
|
265
|
+
for (const result of results) {
|
|
266
|
+
recordResult(result);
|
|
227
267
|
}
|
|
228
268
|
// Short-circuit: if PreToolUse had a deny, record it in the cache
|
|
229
269
|
if (event === 'PreToolUse' && input.tool_name && input.session_id) {
|
|
@@ -373,6 +413,13 @@ function startDaemon(manifest, metrics, options) {
|
|
|
373
413
|
};
|
|
374
414
|
process.on('SIGTERM', shutdown);
|
|
375
415
|
process.on('SIGINT', shutdown);
|
|
416
|
+
// Visibility into macOS sleep/wake cycles
|
|
417
|
+
process.on('SIGTSTP', () => {
|
|
418
|
+
log('Daemon suspended (system sleep)');
|
|
419
|
+
});
|
|
420
|
+
process.on('SIGCONT', () => {
|
|
421
|
+
log('Daemon resumed (system wake)');
|
|
422
|
+
});
|
|
376
423
|
});
|
|
377
424
|
}
|
|
378
425
|
/**
|
|
@@ -415,7 +462,8 @@ function stopDaemon() {
|
|
|
415
462
|
return true;
|
|
416
463
|
}
|
|
417
464
|
/**
|
|
418
|
-
* Check if daemon is currently running.
|
|
465
|
+
* Check if daemon is currently running (PID check only).
|
|
466
|
+
* Use for stop/status where a quick check is fine.
|
|
419
467
|
*/
|
|
420
468
|
function isDaemonRunning() {
|
|
421
469
|
if (!(0, fs_1.existsSync)(constants_js_1.PID_FILE))
|
|
@@ -433,10 +481,86 @@ function isDaemonRunning() {
|
|
|
433
481
|
return false;
|
|
434
482
|
}
|
|
435
483
|
}
|
|
484
|
+
/**
|
|
485
|
+
* Check if daemon is running AND healthy (PID + health endpoint).
|
|
486
|
+
* Defends against stale PIDs reused by macOS after sleep/lid-close.
|
|
487
|
+
* Use for ensure-running and start where correctness matters.
|
|
488
|
+
*/
|
|
489
|
+
async function isDaemonHealthy() {
|
|
490
|
+
if (!(0, fs_1.existsSync)(constants_js_1.PID_FILE))
|
|
491
|
+
return false;
|
|
492
|
+
const pidStr = (0, fs_1.readFileSync)(constants_js_1.PID_FILE, 'utf-8').trim();
|
|
493
|
+
const pid = parseInt(pidStr, 10);
|
|
494
|
+
if (isNaN(pid))
|
|
495
|
+
return false;
|
|
496
|
+
// Step 1: PID alive?
|
|
497
|
+
try {
|
|
498
|
+
process.kill(pid, 0);
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
// Step 2: Health endpoint responds?
|
|
504
|
+
const port = constants_js_1.DEFAULT_PORT; // health check always on default port
|
|
505
|
+
try {
|
|
506
|
+
const { get } = await import('http');
|
|
507
|
+
const data = await new Promise((resolve, reject) => {
|
|
508
|
+
const req = get(`http://127.0.0.1:${port}/health`, (res) => {
|
|
509
|
+
let body = '';
|
|
510
|
+
res.on('data', (chunk) => { body += chunk.toString(); });
|
|
511
|
+
res.on('end', () => resolve(body));
|
|
512
|
+
});
|
|
513
|
+
req.on('error', reject);
|
|
514
|
+
req.setTimeout(2000, () => { req.destroy(); reject(new Error('timeout')); });
|
|
515
|
+
});
|
|
516
|
+
const health = JSON.parse(data);
|
|
517
|
+
return health.status === 'ok';
|
|
518
|
+
}
|
|
519
|
+
catch {
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Clean up a stale daemon: remove PID file and attempt to kill the process.
|
|
525
|
+
* Returns the stale PID for logging purposes.
|
|
526
|
+
*/
|
|
527
|
+
function cleanupStaleDaemon() {
|
|
528
|
+
if (!(0, fs_1.existsSync)(constants_js_1.PID_FILE))
|
|
529
|
+
return null;
|
|
530
|
+
const pidStr = (0, fs_1.readFileSync)(constants_js_1.PID_FILE, 'utf-8').trim();
|
|
531
|
+
const pid = parseInt(pidStr, 10);
|
|
532
|
+
// Remove stale PID file
|
|
533
|
+
try {
|
|
534
|
+
(0, fs_1.unlinkSync)(constants_js_1.PID_FILE);
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
// ignore
|
|
538
|
+
}
|
|
539
|
+
// Try to kill the stale process (might be our daemon but unhealthy)
|
|
540
|
+
if (!isNaN(pid)) {
|
|
541
|
+
try {
|
|
542
|
+
process.kill(pid, 'SIGTERM');
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
// Process doesn't exist — that's fine
|
|
546
|
+
}
|
|
547
|
+
return pid;
|
|
548
|
+
}
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
436
551
|
/**
|
|
437
552
|
* Start daemon as a detached background process.
|
|
553
|
+
* Always removes any existing PID file first — the new daemon writes its own.
|
|
438
554
|
*/
|
|
439
555
|
function startDaemonBackground(options) {
|
|
556
|
+
// Clean any stale PID file before spawning
|
|
557
|
+
try {
|
|
558
|
+
if ((0, fs_1.existsSync)(constants_js_1.PID_FILE))
|
|
559
|
+
(0, fs_1.unlinkSync)(constants_js_1.PID_FILE);
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
// ignore
|
|
563
|
+
}
|
|
440
564
|
const args = [process.argv[1], 'start', '--foreground'];
|
|
441
565
|
if (options?.noWatch) {
|
|
442
566
|
args.push('--no-watch');
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find the clooks binary path by checking PATH or falling back to process.argv[1].
|
|
3
|
+
*/
|
|
4
|
+
declare function findClooksPath(): string;
|
|
5
|
+
declare const PLIST_LABEL = "com.clooks.daemon";
|
|
6
|
+
declare function getPlistPath(): string;
|
|
7
|
+
declare const SYSTEMD_SERVICE_NAME = "clooks";
|
|
8
|
+
declare function getSystemdServicePath(): string;
|
|
9
|
+
declare const TASK_NAME = "clooks";
|
|
10
|
+
export type ServiceStatus = 'running' | 'stopped' | 'not-installed';
|
|
11
|
+
/**
|
|
12
|
+
* Install clooks as an OS service (launchd on macOS, systemd on Linux, schtasks on Windows).
|
|
13
|
+
*/
|
|
14
|
+
export declare function installService(): void;
|
|
15
|
+
/**
|
|
16
|
+
* Uninstall the clooks OS service.
|
|
17
|
+
*/
|
|
18
|
+
export declare function uninstallService(): void;
|
|
19
|
+
/**
|
|
20
|
+
* Check if the clooks OS service is installed.
|
|
21
|
+
*/
|
|
22
|
+
export declare function isServiceInstalled(): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Get the current service status.
|
|
25
|
+
*/
|
|
26
|
+
export declare function getServiceStatus(): ServiceStatus;
|
|
27
|
+
export { findClooksPath, getPlistPath, getSystemdServicePath, PLIST_LABEL, SYSTEMD_SERVICE_NAME, TASK_NAME };
|